mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27d904f23 | ||
|
|
cd7f182401 | ||
|
|
260b06540f | ||
|
|
57089a3abf | ||
|
|
3c67392455 | ||
|
|
a01563931b | ||
|
|
0ccafbabf7 | ||
|
|
cd0fa39f49 | ||
|
|
c78121888b | ||
|
|
f1be47adc4 | ||
|
|
2638d199f2 | ||
|
|
44b970fc72 | ||
|
|
a03d41ebf5 | ||
|
|
00c4095e59 | ||
|
|
aebe9944ed | ||
|
|
8fdca2baea | ||
|
|
39e4e08d9c | ||
|
|
3ef402cc71 | ||
|
|
ee7b8fae6a | ||
|
|
604e11d239 | ||
|
|
04d71be651 | ||
|
|
5ca36721b5 | ||
|
|
a3083a4ac2 | ||
|
|
b2503ff6ba | ||
|
|
b27f0ad7d3 | ||
|
|
0c36ef7275 | ||
|
|
110c79c8dc | ||
|
|
e8c31a7800 | ||
|
|
2527f140c0 | ||
|
|
aaee43417f | ||
|
|
a622d953ad | ||
|
|
16c9af6e32 | ||
|
|
64717fffd5 | ||
|
|
2ba045c8e7 | ||
|
|
8b0b7706a3 | ||
|
|
fa34a5602e | ||
|
|
d14591b36a | ||
|
|
2bb7baebde | ||
|
|
aa597135b1 | ||
|
|
42b8e2f347 | ||
|
|
9c312b7b98 | ||
|
|
05e4bf69f7 | ||
|
|
eb57b73442 | ||
|
|
b366967bcc | ||
|
|
8771ec9e3d | ||
|
|
55002aefa0 | ||
|
|
1a7490497b | ||
|
|
786a317b26 | ||
|
|
73d15da765 | ||
|
|
6abb6635b2 | ||
|
|
90cc055521 | ||
|
|
134f5830e1 | ||
|
|
222d76f685 | ||
|
|
1d512ce524 | ||
|
|
c33fb7ba9b | ||
|
|
084d76884a | ||
|
|
94a2fe9e5e | ||
|
|
eb0586be8e |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
name: Forward issue events to Grok Bot
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
# PR comments are handled elsewhere; only forward real issues.
|
||||
if: github.event_name == 'issues' || github.event.issue.pull_request == null
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Forward minimal event to Grok Bot webhook
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
|
||||
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
|
||||
echo "Grok Bot webhook secrets not set; skipping."
|
||||
exit 0
|
||||
fi
|
||||
jq --arg event "$GITHUB_EVENT_NAME" '
|
||||
def clip:
|
||||
if type == "string" then .[0:4000] else . end;
|
||||
{
|
||||
event: $event,
|
||||
action: .action,
|
||||
repository: .repository.full_name,
|
||||
sender: .sender.login,
|
||||
issue: (
|
||||
{
|
||||
number: .issue.number,
|
||||
title: .issue.title,
|
||||
author: .issue.user.login,
|
||||
url: .issue.html_url
|
||||
} + (
|
||||
if $event == "issues" then {
|
||||
author_association: .issue.author_association,
|
||||
body: (.issue.body | clip)
|
||||
} else {} end
|
||||
)
|
||||
),
|
||||
comment: (
|
||||
if .comment then
|
||||
{
|
||||
id: .comment.id,
|
||||
author: .comment.user.login,
|
||||
url: .comment.html_url,
|
||||
node_id: .comment.node_id,
|
||||
author_association: .comment.author_association,
|
||||
user_type: .comment.user.type,
|
||||
created_at: .comment.created_at,
|
||||
updated_at: .comment.updated_at,
|
||||
body: (.comment.body | clip)
|
||||
} + (
|
||||
if .action == "edited" then {
|
||||
previous_body: (.changes.body.from | clip)
|
||||
} else {} end
|
||||
)
|
||||
else null end
|
||||
)
|
||||
}
|
||||
' "$GITHUB_EVENT_PATH" \
|
||||
| curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: Bearer $WEBHOOK_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @- "$WEBHOOK_URL"
|
||||
@@ -2,7 +2,7 @@ name: spam-comments
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,13 +10,13 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
delete-spam:
|
||||
hide-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout filter rules
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Delete suspected malware spam
|
||||
- name: Hide suspected malware spam
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
@@ -25,26 +25,59 @@ jobs:
|
||||
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
|
||||
);
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const comment = context.payload.comment || {};
|
||||
const issue = context.payload.issue || {};
|
||||
const login = (comment.user && comment.user.login) || 'unknown';
|
||||
const result = detectSpamComment({
|
||||
body: comment.body || '',
|
||||
authorAssociation: comment.author_association,
|
||||
userType: comment.user && comment.user.type,
|
||||
commentAuthor: comment.user && comment.user.login,
|
||||
issueAuthor: issue.user && issue.user.login,
|
||||
});
|
||||
|
||||
core.info(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.spam) {
|
||||
core.info('Comment did not match the zip/dangerous-file spam filter.');
|
||||
core.info('Comment did not match the risky-download spam filter.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
const matchedUrl = result.matchedUrls.join(', ');
|
||||
if (!comment.node_id) {
|
||||
core.setFailed(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.graphql(
|
||||
`mutation($input: MinimizeCommentInput!) {
|
||||
minimizeComment(input: $input) {
|
||||
minimizedComment {
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
input: {
|
||||
subjectId: comment.node_id,
|
||||
classifier: 'SPAM',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = String((error && error.message) || error);
|
||||
if (/already minimized/i.test(message)) {
|
||||
core.info(
|
||||
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
core.notice(
|
||||
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
|
||||
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
|
||||
);
|
||||
+20
-13
@@ -29,13 +29,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/binaricat">
|
||||
<img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" width="150" alt="GitHub でスポンサーになる">
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/binaricat"><img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" height="28" alt="GitHub でスポンサーになる"></a>
|
||||
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Ko-fi でサポート">
|
||||
</a>
|
||||
<a href="https://ko-fi.com/binaricat"><img src="https://img.shields.io/badge/Ko--fi-Support-29abe0?style=for-the-badge&logo=kofi&logoColor=white" height="28" alt="Ko-fi でサポート"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -67,13 +63,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +82,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
- [モニタリングビュー](#モニタリングビュー)
|
||||
- [分割ターミナル](#分割ターミナル)
|
||||
- [対応ディストリビューション](#対応ディストリビューション)
|
||||
- [はじめに](#はじめに)
|
||||
@@ -133,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
|
||||
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
|
||||
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
@@ -152,18 +150,27 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
@@ -29,13 +35,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/binaricat">
|
||||
<img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" width="150" alt="Sponsor on GitHub">
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/binaricat"><img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" height="28" alt="Sponsor on GitHub"></a>
|
||||
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
|
||||
</a>
|
||||
<a href="https://ko-fi.com/binaricat"><img src="https://img.shields.io/badge/Ko--fi-Support-29abe0?style=for-the-badge&logo=kofi&logoColor=white" height="28" alt="Support on Ko-fi"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -67,13 +69,15 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -89,6 +93,7 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
@@ -134,6 +139,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
@@ -153,9 +159,11 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
|
||||
|
||||
<a name="vault-views"></a>
|
||||
@@ -163,9 +171,23 @@ The main window is designed for long-running SSH workflows: quick access to sess
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
|
||||
|
||||
|
||||
## Monitoring View
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
|
||||
<a name="split-terminals"></a>
|
||||
@@ -227,6 +249,24 @@ 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.
|
||||
|
||||
<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)
|
||||
|
||||
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:
|
||||
|
||||
+20
-13
@@ -29,13 +29,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/binaricat">
|
||||
<img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" width="150" alt="通过 GitHub 赞助我">
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/binaricat"><img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" height="28" alt="通过 GitHub 赞助我"></a>
|
||||
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="在 Ko-fi 上支持我">
|
||||
</a>
|
||||
<a href="https://ko-fi.com/binaricat"><img src="https://img.shields.io/badge/Ko--fi-Support-29abe0?style=for-the-badge&logo=kofi&logoColor=white" height="28" alt="在 Ko-fi 上支持我"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -67,13 +63,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +82,7 @@
|
||||
- [界面截图](#界面截图)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
- [监控视图](#监控视图)
|
||||
- [分屏终端](#分屏终端)
|
||||
- [支持的发行版](#支持的发行版)
|
||||
- [快速开始](#快速开始)
|
||||
@@ -133,6 +130,7 @@
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
|
||||
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
|
||||
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
@@ -152,18 +150,27 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
+20
-13
@@ -29,13 +29,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/binaricat">
|
||||
<img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" width="150" alt="透過 GitHub 贊助我">
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/binaricat"><img src="https://img.shields.io/badge/GitHub-Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" height="28" alt="透過 GitHub 贊助我"></a>
|
||||
|
||||
<a href="https://ko-fi.com/binaricat">
|
||||
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
|
||||
</a>
|
||||
<a href="https://ko-fi.com/binaricat"><img src="https://img.shields.io/badge/Ko--fi-Support-29abe0?style=for-the-badge&logo=kofi&logoColor=white" height="28" alt="Support on Ko-fi"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,7 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
|
||||
---
|
||||
|
||||
@@ -67,13 +63,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +82,7 @@
|
||||
- [介面截圖](#介面截圖)
|
||||
- [主視窗](#主視窗)
|
||||
- [Vault 檢視](#vault-檢視)
|
||||
- [監控檢視](#監控檢視)
|
||||
- [分割終端](#分割終端)
|
||||
- [支援的發行版](#支援的發行版)
|
||||
- [快速開始](#快速開始)
|
||||
@@ -133,6 +130,7 @@
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
@@ -152,18 +150,27 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
@@ -534,9 +534,10 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
|
||||
if (!source) return undefined;
|
||||
|
||||
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
|
||||
// source that reflects `cd`s in a running local terminal — lastCwd is a
|
||||
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
|
||||
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the
|
||||
// post-command probe, including local shells). lastCwd on the session object
|
||||
// is only the startup snapshot. Falls through to the SSH probe / lastCwd
|
||||
// when absent.
|
||||
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
||||
|
||||
const bridge = netcattyBridge?.get?.();
|
||||
|
||||
@@ -15,4 +15,14 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
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\(\)/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("handleConnectToHost reads temporary connection settings at call time", () => {
|
||||
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
|
||||
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
|
||||
|
||||
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
|
||||
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
|
||||
const handler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
|
||||
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
|
||||
assert.match(handler, /terminalSettings: liveTerminalSettings/);
|
||||
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
|
||||
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
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';
|
||||
|
||||
@@ -1447,13 +1449,24 @@ export function AppSideEffects() {
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// VaultView memo ignores onConnect identity, so this callback can stay
|
||||
// closed over an older render. Temporary-connection toggles are read here,
|
||||
// at connect time, so turning them on or off applies without a reload.
|
||||
const temporaryConnectionSettingsRef = useRef(terminalSettings);
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
if (host.ephemeral) {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
temporaryConnectionPrepareOptions(liveTerminalSettings),
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
|
||||
if (existingIndex < 0) return [...previous, connectionHost];
|
||||
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
|
||||
});
|
||||
}
|
||||
const effectiveHostResolver = alreadyEffective
|
||||
@@ -1463,16 +1476,16 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1557,7 +1570,7 @@ export function AppSideEffects() {
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
@@ -1721,14 +1734,44 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
// Shell discovery is part of this queue. A later path-only request, including
|
||||
// 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;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
|
||||
void openTerminalQueueRef.current.enqueue(() => {
|
||||
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 }) => {
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
@@ -376,6 +377,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
|
||||
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
|
||||
const handleFillTotp = useCallback(async () => {
|
||||
if (!pendingTotpUri) return null;
|
||||
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
|
||||
return result?.ok && result.code ? result.code : null;
|
||||
}, [pendingTotpUri]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -930,6 +939,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
@@ -1022,6 +1032,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
canFillTotp={Boolean(pendingTotpUri)}
|
||||
onFillTotp={handleFillTotp}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
|
||||
? { totpUri: request.totpUri }
|
||||
: {}),
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
|
||||
@@ -356,6 +356,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'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.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.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',
|
||||
@@ -368,6 +376,38 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
'ai.externalMcp.http.port': 'Port',
|
||||
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenAddress': 'Listen address',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Client address',
|
||||
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
|
||||
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerate',
|
||||
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
|
||||
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token env',
|
||||
'ai.externalMcp.http.notListening': 'Not listening',
|
||||
'ai.externalMcp.http.listening': 'Listening',
|
||||
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
|
||||
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
|
||||
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
|
||||
'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.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
|
||||
'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.
|
||||
@@ -440,6 +440,22 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
|
||||
- \`top-tabs-host-tree-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-view\`
|
||||
@@ -533,7 +549,7 @@ Highlight the focused split pane:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 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 */',
|
||||
'/* 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 */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -712,7 +728,7 @@ Highlight the focused split pane:
|
||||
'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.desc':
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'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.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'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.',
|
||||
@@ -820,6 +836,10 @@ Highlight the focused split pane:
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
@@ -829,6 +849,9 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Default proxy',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'None',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
@@ -910,11 +912,13 @@ export const enTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.verificationCode': 'Verification code',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
'keyboard.interactive.fillTotp': 'Fill verification code',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.quickConnectTo': 'Quick connect to {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Tabs',
|
||||
'qs.newWorkspace': 'New Workspace',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
@@ -876,6 +880,35 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
|
||||
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Authenticator (TOTP)',
|
||||
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
|
||||
'hostDetails.totp.setup': 'Set up…',
|
||||
'hostDetails.totp.edit': 'Edit',
|
||||
'hostDetails.totp.modalTitle': 'Set up authenticator',
|
||||
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
|
||||
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
|
||||
'hostDetails.totp.secret': 'Secret or otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
|
||||
'hostDetails.totp.importQr': 'Import QR image',
|
||||
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
|
||||
'hostDetails.totp.scanning': 'Scanning screens…',
|
||||
'hostDetails.totp.importedAccount': 'Imported {account}',
|
||||
'hostDetails.totp.preview': 'Current code',
|
||||
'hostDetails.totp.countdown': '{seconds}s remaining',
|
||||
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
|
||||
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
|
||||
'hostDetails.totp.clear': 'Remove authenticator',
|
||||
'hostDetails.totp.removeTitle': 'Remove authenticator?',
|
||||
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
|
||||
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
|
||||
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
|
||||
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
|
||||
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
|
||||
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
|
||||
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
@@ -949,6 +982,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.direct': 'Direct (no proxy)',
|
||||
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -347,6 +347,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'min',
|
||||
'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.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.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',
|
||||
@@ -359,6 +367,38 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
'ai.externalMcp.http.port': 'Puerto',
|
||||
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
|
||||
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
|
||||
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerar',
|
||||
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
|
||||
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
|
||||
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
|
||||
'ai.externalMcp.http.notListening': 'Sin escucha',
|
||||
'ai.externalMcp.http.listening': 'Escuchando',
|
||||
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
|
||||
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
|
||||
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
|
||||
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Paleta oscura',
|
||||
'settings.appearance.customCss': 'CSS personalizado',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
|
||||
'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.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Ayuda 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.
|
||||
@@ -355,6 +355,22 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
|
||||
- \`top-tabs-host-tree-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-view\`
|
||||
@@ -448,7 +464,7 @@ Resalta el panel dividido enfocado:
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 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 */',
|
||||
'/* 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 */',
|
||||
'settings.appearance.language': 'Idioma',
|
||||
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
|
||||
'settings.appearance.uiFont': 'Fuente de la interfaz',
|
||||
@@ -627,7 +643,7 @@ Resalta el panel dividido enfocado:
|
||||
'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.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'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.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'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.',
|
||||
@@ -735,6 +751,10 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.section.connection': 'Conexión',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
@@ -744,6 +764,9 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.x11Display': 'Pantalla X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
|
||||
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'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.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
@@ -872,11 +874,13 @@ export const esTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.verificationCode': 'Código de verificación',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.quickConnectTo': 'Conexión rápida a {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Pestañas',
|
||||
'qs.newWorkspace': 'Nuevo espacio de trabajo',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
@@ -866,6 +870,35 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Autenticador (TOTP)',
|
||||
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
|
||||
'hostDetails.totp.setup': 'Configurar…',
|
||||
'hostDetails.totp.edit': 'Editar',
|
||||
'hostDetails.totp.modalTitle': 'Configurar autenticador',
|
||||
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
|
||||
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
|
||||
'hostDetails.totp.secret': 'Secreto o URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
|
||||
'hostDetails.totp.importQr': 'Importar imagen QR',
|
||||
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
|
||||
'hostDetails.totp.scanning': 'Escaneando pantallas…',
|
||||
'hostDetails.totp.importedAccount': 'Importado: {account}',
|
||||
'hostDetails.totp.preview': 'Código actual',
|
||||
'hostDetails.totp.countdown': 'Quedan {seconds} s',
|
||||
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
|
||||
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
|
||||
'hostDetails.totp.clear': 'Quitar autenticador',
|
||||
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
|
||||
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
|
||||
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
|
||||
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
|
||||
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
|
||||
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
|
||||
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
@@ -939,6 +972,8 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.direct': 'Directo (sin proxy)',
|
||||
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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\}/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const KEYS = [
|
||||
"ai.externalMcp.http.title",
|
||||
"ai.externalMcp.http.description",
|
||||
"ai.externalMcp.http.enable",
|
||||
"ai.externalMcp.http.port",
|
||||
"ai.externalMcp.http.portHint",
|
||||
"ai.externalMcp.http.allowCustomBind",
|
||||
"ai.externalMcp.http.allowCustomBind.description",
|
||||
"ai.externalMcp.http.listenAddress",
|
||||
"ai.externalMcp.http.listenAddress.placeholder",
|
||||
"ai.externalMcp.http.clientHost",
|
||||
"ai.externalMcp.http.clientHost.description",
|
||||
"ai.externalMcp.http.clientHost.placeholder",
|
||||
"ai.externalMcp.http.clientHostRequired",
|
||||
"ai.externalMcp.http.invalidClientHost",
|
||||
"ai.externalMcp.http.warning",
|
||||
"ai.externalMcp.http.url",
|
||||
"ai.externalMcp.http.token",
|
||||
"ai.externalMcp.http.regenerate",
|
||||
"ai.externalMcp.http.regenerateHint",
|
||||
"ai.externalMcp.http.wslHint",
|
||||
"ai.externalMcp.http.codexSnippet",
|
||||
"ai.externalMcp.http.claudeCommand",
|
||||
"ai.externalMcp.http.genericSnippet",
|
||||
"ai.externalMcp.http.envLine",
|
||||
"ai.externalMcp.http.notListening",
|
||||
"ai.externalMcp.http.listening",
|
||||
"ai.externalMcp.http.portInUse",
|
||||
"ai.externalMcp.http.requiresExternalMcp",
|
||||
"ai.externalMcp.http.invalidPort",
|
||||
"ai.externalMcp.http.invalidAddress",
|
||||
"ai.externalMcp.http.listenFailed",
|
||||
"ai.externalMcp.http.legacySseNote",
|
||||
] as const;
|
||||
|
||||
test("External MCP HTTP strings exist in every locale", () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
|
||||
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
|
||||
}
|
||||
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
|
||||
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
|
||||
});
|
||||
@@ -303,6 +303,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': 'мин',
|
||||
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
|
||||
'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.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
|
||||
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
|
||||
@@ -315,6 +323,38 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
'ai.externalMcp.http.port': 'Порт',
|
||||
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Адрес клиента',
|
||||
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
|
||||
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Создать заново',
|
||||
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
|
||||
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
|
||||
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
|
||||
'ai.externalMcp.http.notListening': 'Не слушает',
|
||||
'ai.externalMcp.http.listening': 'Слушает',
|
||||
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
|
||||
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
|
||||
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
|
||||
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
|
||||
@@ -423,6 +423,22 @@ export const ruCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-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\`
|
||||
@@ -502,7 +518,7 @@ export const ruCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !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/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !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/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -663,7 +679,7 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
@@ -775,6 +791,10 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
@@ -782,6 +802,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display': 'Дисплей X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Нет',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
|
||||
@@ -130,6 +130,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
@@ -917,11 +919,13 @@ export const ruTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.verificationCode': 'Код подтверждения',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
|
||||
@@ -710,6 +710,10 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.quickConnectTo': 'Быстрое подключение к {target}',
|
||||
'qs.hosts': 'Хосты',
|
||||
'qs.tabs': 'Вкладки',
|
||||
'qs.newWorkspace': 'Новая рабочая область',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
@@ -914,6 +918,35 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
|
||||
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
|
||||
'hostDetails.totp.setup': 'Настроить…',
|
||||
'hostDetails.totp.edit': 'Изменить',
|
||||
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
|
||||
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
|
||||
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
|
||||
'hostDetails.totp.secret': 'Секрет или URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
|
||||
'hostDetails.totp.importQr': 'Импорт изображения QR',
|
||||
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
|
||||
'hostDetails.totp.scanning': 'Сканирование экранов…',
|
||||
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
|
||||
'hostDetails.totp.preview': 'Текущий код',
|
||||
'hostDetails.totp.countdown': 'Осталось {seconds} с',
|
||||
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
|
||||
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
|
||||
'hostDetails.totp.clear': 'Удалить аутентификатор',
|
||||
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
|
||||
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
|
||||
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
|
||||
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
|
||||
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
|
||||
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
|
||||
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
@@ -987,6 +1020,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
|
||||
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,6 +47,56 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection legacy algorithm labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms",
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection network device mode labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
"settings.terminal.connection.defaultProxy.desc",
|
||||
"settings.terminal.connection.defaultProxy.none",
|
||||
"hostDetails.proxy.direct",
|
||||
"hostDetails.proxy.directHint",
|
||||
];
|
||||
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 global default proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
|
||||
@@ -356,6 +356,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分钟',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
|
||||
'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.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
|
||||
@@ -368,6 +376,38 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
'ai.externalMcp.http.port': '端口',
|
||||
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '监听地址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '客户端地址',
|
||||
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新生成',
|
||||
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
|
||||
'ai.externalMcp.http.notListening': '未监听',
|
||||
'ai.externalMcp.http.listening': '监听中',
|
||||
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
|
||||
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
|
||||
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
|
||||
@@ -444,6 +444,22 @@ export const zhCNCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-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\`
|
||||
@@ -537,7 +553,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 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/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 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/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -1123,6 +1139,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.quickConnectTo': '快速连接到 {target}',
|
||||
'qs.hosts': '主机',
|
||||
'qs.tabs': '标签页',
|
||||
'qs.newWorkspace': '新建工作区',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
@@ -454,6 +454,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
@@ -996,11 +1003,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.verificationCode': '验证码',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
'keyboard.interactive.fillTotp': '填入验证码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.mfaFirst': '优先交互式认证',
|
||||
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
|
||||
'hostDetails.totp.title': '验证器 (TOTP)',
|
||||
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
|
||||
'hostDetails.totp.setup': '设置…',
|
||||
'hostDetails.totp.edit': '编辑',
|
||||
'hostDetails.totp.modalTitle': '设置验证器',
|
||||
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
|
||||
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
|
||||
'hostDetails.totp.secret': '密钥或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
|
||||
'hostDetails.totp.importQr': '导入二维码图片',
|
||||
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
|
||||
'hostDetails.totp.scanning': '正在扫描屏幕…',
|
||||
'hostDetails.totp.importedAccount': '已导入 {account}',
|
||||
'hostDetails.totp.preview': '当前验证码',
|
||||
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
|
||||
'hostDetails.totp.promptPattern': '自定义提示正则',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
|
||||
'hostDetails.totp.clear': '移除验证器',
|
||||
'hostDetails.totp.removeTitle': '移除验证器?',
|
||||
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
|
||||
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
|
||||
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
|
||||
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
|
||||
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
|
||||
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
|
||||
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
|
||||
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
@@ -363,6 +392,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.direct': '直连(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
'hostDetails.envVars': '环境变量',
|
||||
@@ -532,6 +563,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -346,6 +346,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.idleTimeout.minutes': '分鐘',
|
||||
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
|
||||
'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.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
|
||||
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
|
||||
@@ -358,6 +366,38 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
'ai.externalMcp.http.port': '連接埠',
|
||||
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '監聽位址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '用戶端位址',
|
||||
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新產生',
|
||||
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
|
||||
'ai.externalMcp.http.notListening': '未監聽',
|
||||
'ai.externalMcp.http.listening': '監聽中',
|
||||
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
|
||||
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
|
||||
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主題',
|
||||
'settings.appearance.customCss': '自訂 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。',
|
||||
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
|
||||
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
|
||||
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
|
||||
@@ -446,6 +446,22 @@ export const zhTWCoreMessages: Messages = {
|
||||
- \`top-tabs-host-tree-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\`
|
||||
@@ -539,7 +555,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
\`\`\`
|
||||
`,
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 範例 — 由於 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/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'/* 範例 — 由於 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/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
|
||||
'settings.appearance.language': '語言',
|
||||
'settings.appearance.language.desc': '選擇介面語言',
|
||||
'settings.appearance.uiFont': '介面字型',
|
||||
@@ -1125,6 +1141,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.quickConnectTo': '快速連線到 {target}',
|
||||
'qs.hosts': '主機',
|
||||
'qs.tabs': '分頁',
|
||||
'qs.newWorkspace': '新增工作區',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
@@ -454,6 +454,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
@@ -996,11 +1003,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.verificationCode': '驗證碼',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.submit': '送出',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
'keyboard.interactive.fillTotp': '填入驗證碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.totp.title': '驗證器 (TOTP)',
|
||||
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
|
||||
'hostDetails.totp.setup': '設定…',
|
||||
'hostDetails.totp.edit': '編輯',
|
||||
'hostDetails.totp.modalTitle': '設定驗證器',
|
||||
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
|
||||
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
|
||||
'hostDetails.totp.secret': '金鑰或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
|
||||
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
|
||||
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
|
||||
'hostDetails.totp.scanning': '正在掃描螢幕…',
|
||||
'hostDetails.totp.importedAccount': '已匯入 {account}',
|
||||
'hostDetails.totp.preview': '目前驗證碼',
|
||||
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
|
||||
'hostDetails.totp.promptPattern': '自訂提示規則',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
|
||||
'hostDetails.totp.clear': '移除驗證器',
|
||||
'hostDetails.totp.removeTitle': '移除驗證器?',
|
||||
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
|
||||
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
|
||||
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
|
||||
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
|
||||
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
@@ -363,6 +392,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '設定代理主機',
|
||||
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxy.none': '無',
|
||||
'hostDetails.proxy.direct': '直連(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
|
||||
'hostDetails.proxy.edit': '編輯代理',
|
||||
'hostDetails.proxy.configure': '設定代理',
|
||||
'hostDetails.envVars': '環境變數',
|
||||
@@ -532,6 +563,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
openQuickConnectFromInput,
|
||||
registerQuickConnectOpener,
|
||||
type QuickConnectOpenRequest,
|
||||
} from "./quickConnectBridge.ts";
|
||||
|
||||
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
|
||||
assert.deepEqual(seen, [{
|
||||
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
|
||||
warnings: [],
|
||||
}]);
|
||||
|
||||
unregister();
|
||||
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
|
||||
});
|
||||
|
||||
test("openQuickConnectFromInput ignores text that is not an address", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput("core switch"), false);
|
||||
assert.deepEqual(seen, []);
|
||||
unregister();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
parseQuickConnectInputWithWarnings,
|
||||
type QuickConnectTarget,
|
||||
} from "../domain/quickConnect";
|
||||
|
||||
export interface QuickConnectOpenRequest {
|
||||
target: QuickConnectTarget;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
|
||||
|
||||
let opener: QuickConnectOpener | null = null;
|
||||
|
||||
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
|
||||
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) opener = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openQuickConnectFromInput(input: string): boolean {
|
||||
const parsed = parseQuickConnectInputWithWarnings(input);
|
||||
if (!parsed.target || !opener) return false;
|
||||
opener({ target: parsed.target, warnings: parsed.warnings });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,10 +13,11 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
|
||||
|
||||
export interface CaptureInheritedCwdOptions {
|
||||
/**
|
||||
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
|
||||
* cwd map rather than the session object. This is the freshest value and the
|
||||
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
|
||||
* never mirrored onto `TerminalSession.lastCwd`).
|
||||
* The session's live tracked cwd (OSC 7 or the post-command cwd probe),
|
||||
* sourced from the terminal-state cwd map rather than the session object.
|
||||
* This is the freshest value and the one that reflects `cd`s in a running
|
||||
* local terminal. The live value stays off `TerminalSession.lastCwd` until
|
||||
* session restore persists it.
|
||||
*/
|
||||
liveCwd?: string;
|
||||
/**
|
||||
@@ -35,7 +36,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
|
||||
/**
|
||||
* Resolve the working directory a clone/split should inherit from its source.
|
||||
*
|
||||
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
|
||||
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed)
|
||||
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
|
||||
* 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
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
@@ -58,6 +59,10 @@ import {
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
isValidUiFontId,
|
||||
@@ -98,6 +103,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -146,6 +152,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -292,6 +299,11 @@ export function useSettingsIpcSync({
|
||||
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
|
||||
));
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -354,6 +366,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
@@ -214,3 +216,76 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
|
||||
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,8 +1,15 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
type HotkeyScheme,
|
||||
type SessionLogFormat,
|
||||
type TerminalSettings,
|
||||
} from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
@@ -91,6 +98,26 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
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
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
|
||||
// Regression for #3588: command submit clears cwd to null and schedules a
|
||||
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
|
||||
// gap — that race aborted the navigate and left the panel stuck.
|
||||
const navigatedPaths: string[] = [];
|
||||
let probeCalls = 0;
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
// First-open may probe; keep the pane where it already is.
|
||||
return connection.currentPath;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
const probesAfterOpen = probeCalls;
|
||||
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
deferProbe = true;
|
||||
// Command-submit style clear: no trusted cwd yet.
|
||||
activeTerminalCwd = null;
|
||||
activeTerminalCwdTrusted = false;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
|
||||
activeTerminalCwd = "/etc";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
assert.deepEqual(navigatedPaths, ["/etc"]);
|
||||
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
|
||||
// backend-strict probe, or inferred). Polling preferFreshBackend here while
|
||||
// cwd is null/untrusted races that probe: navigate starts, the probe then
|
||||
// invalidates the sync generation, abort restores the previous path, and
|
||||
// without a connectionPath effect dependency the panel stays put (#3588).
|
||||
// Manual "Go to terminal directory" and first-open sync still probe.
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd && originChanged) {
|
||||
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
|
||||
@@ -712,3 +712,127 @@ test("owned cancelled directory settles recoverable children without dropping co
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const liveSize of [37, 3, 0]) {
|
||||
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statLocal: async (path: string) => path.endsWith("edited.txt")
|
||||
? { type: "file", size: liveSize, lastModified: 1758000002000 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `local-edited-${liveSize}`,
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, 1);
|
||||
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
// Target conflict stat: missing path means the copy has no conflict.
|
||||
lstatLocal: async () => null,
|
||||
statLocal: async (path: string) => {
|
||||
if (!path.endsWith("late.txt")) return null;
|
||||
// Let the stream finish before the stat resolves — fired-and-forgotten
|
||||
// discovery used to patch the terminal task with this stale total.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return { type: "file", size: 9_999, lastModified: 1758000002000 };
|
||||
},
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: 15,
|
||||
totalBytes: 15,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "local-late-stat",
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
// Give the no longer pending deferred stat time to land if it leaked.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
|
||||
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
|
||||
(candidate) => candidate.originalFileName === "late.txt",
|
||||
);
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "completed");
|
||||
assert.equal(
|
||||
row.transferredBytes,
|
||||
row.totalBytes,
|
||||
"a deferred stat must not desync the completed row's total from its transferred bytes",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -811,8 +811,8 @@ export const useSftpTransfers = ({
|
||||
})();
|
||||
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
// check, but always settle the promise before any dialog or transfer
|
||||
// launch (bounded per-task; the task is already visible/cancellable).
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
@@ -823,19 +823,23 @@ export const useSftpTransfers = ({
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
if (sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
// discovery even without a conflict so its statLocal updateTask cannot
|
||||
// resolve after the transfer finished and overwrite the bridge's
|
||||
// authoritative total with a stale late size (the conflict dialog also
|
||||
// needs it: #3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
if (conflict) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1267,25 +1271,24 @@ export const useSftpTransfers = ({
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
// measures the live size. Local files also start unknown so deferred
|
||||
// discovery reads their current size after edits, not the cached listing.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
: 0;
|
||||
// Only keep an mtime paired with a live re-stat or directory totals.
|
||||
// A file without a live stat must stay fully unknown
|
||||
// (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
const sourceLastModified = !file.isDirectory && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
|
||||
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
|
||||
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', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const enterReconnectEnabledIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
|
||||
// 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();
|
||||
@@ -3,6 +3,17 @@ import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
readExternalMcpHttpSettings,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -11,9 +22,11 @@ import {
|
||||
getExternalMcpStartupReadyWaiterCountForTests,
|
||||
isExternalMcpStartupReady,
|
||||
markExternalMcpStartupReady,
|
||||
normalizeExternalMcpApprovalAlertMode,
|
||||
normalizeExternalMcpIdleTimeoutMinutes,
|
||||
normalizeExternalMcpMode,
|
||||
normalizeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpSilentSessions,
|
||||
readExternalMcpStoredEnabled,
|
||||
@@ -25,6 +38,7 @@ import {
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpSilentSessions,
|
||||
} from './useExternalMcpToggleState.ts';
|
||||
@@ -69,6 +83,22 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
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]) {
|
||||
@@ -395,3 +425,30 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
@@ -9,11 +15,15 @@ import {
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
|
||||
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 MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
@@ -100,10 +110,23 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
@@ -183,6 +206,26 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
|
||||
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). */
|
||||
export function readExternalMcpSilentSessions(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
|
||||
@@ -217,6 +260,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
|
||||
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
|
||||
|
||||
export function normalizeExternalMcpHttpPort(value: number | null): number {
|
||||
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
|
||||
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
|
||||
const address = (value ?? '').trim();
|
||||
if (!address) return '0.0.0.0';
|
||||
return address;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpEnabled(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpPort(): number {
|
||||
return normalizeExternalMcpHttpPort(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpPort(port: number): number {
|
||||
const normalized = normalizeExternalMcpHttpPort(port);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpAllowCustomBind(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
|
||||
return allowCustomBind;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpListenAddress(): string {
|
||||
return normalizeExternalMcpHttpListenAddress(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
|
||||
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpClientHost(): string {
|
||||
return normalizeExternalMcpHttpClientHost(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpClientHost(clientHost: string): string {
|
||||
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
|
||||
return {
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
};
|
||||
}
|
||||
|
||||
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
|
||||
ExternalMcpConfig,
|
||||
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
|
||||
> {
|
||||
return {
|
||||
httpEnabled: settings.enabled,
|
||||
httpPort: settings.port,
|
||||
httpAllowCustomBind: settings.allowCustomBind,
|
||||
httpListenAddress: settings.listenAddress,
|
||||
httpClientHost: settings.clientHost,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -239,11 +385,19 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
allowCustomBind: false,
|
||||
listenAddress: '0.0.0.0',
|
||||
clientHost: '',
|
||||
},
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -252,6 +406,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -265,14 +420,17 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
});
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
|
||||
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
|
||||
return useSyncExternalStore(
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useServerStats } from './useServerStats';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
for (const scenario of ['retry', 'pause', 'manual'] as const) {
|
||||
test(`server stats ${scenario} after three failures`, async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
getServerStats: async () => {
|
||||
calls++;
|
||||
return calls <= 3 ? { success: false, error: 'temporary failure' }
|
||||
: { success: true, stats: { cpu: 42 } };
|
||||
},
|
||||
}));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled = true }: { enabled?: boolean }) {
|
||||
current = useServerStats({ sessionId: `recovery-${scenario}`, enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
await act(async () => t.mock.timers.tick(3000));
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(current!.error, 'temporary failure');
|
||||
if (scenario === 'pause') {
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 3, 'disabled clients must not retry');
|
||||
await act(async () => root!.update(React.createElement(Probe)));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
} else if (scenario === 'manual') {
|
||||
await act(async () => current!.refresh());
|
||||
} else {
|
||||
await act(async () => t.mock.timers.tick(29_999));
|
||||
assert.equal(calls, 3);
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
await act(async () => t.mock.timers.tick(1));
|
||||
}
|
||||
assert.equal(calls, 4);
|
||||
assert.equal(current!.stats.cpu, 42);
|
||||
assert.equal(current!.error, null);
|
||||
await act(async () => t.mock.timers.tick(5000));
|
||||
assert.equal(calls, 5, 'successful recovery must resume normal polling');
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('stats reply after disabling cannot publish or schedule recovery', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
|
||||
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
let finish: (result: unknown) => void;
|
||||
let calls = 0;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({ getServerStats: () => {
|
||||
calls++;
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
} }));
|
||||
let current: ReturnType<typeof useServerStats>;
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
current = useServerStats({ sessionId: 'late-disabled', enabled,
|
||||
refreshInterval: 5, isSupportedOs: true, isConnected: true });
|
||||
return null;
|
||||
}
|
||||
let root: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = create(React.createElement(Probe, { enabled: true })); });
|
||||
await act(async () => t.mock.timers.tick(2000));
|
||||
assert.equal(calls, 1);
|
||||
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
|
||||
await act(async () => finish!({ success: true, stats: { cpu: 99 } }));
|
||||
assert.equal(current!.stats.lastUpdated, null);
|
||||
assert.equal(current!.isLoading, false);
|
||||
await act(async () => t.mock.timers.tick(60_000));
|
||||
assert.equal(calls, 1);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
@@ -24,6 +24,14 @@ export interface ProcessInfo {
|
||||
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 {
|
||||
hostname?: string; // Remote hostname
|
||||
osName?: string; // Remote operating system name
|
||||
@@ -37,6 +45,7 @@ export interface ServerStats {
|
||||
gpuName: string | null; // First GPU name (tooltip)
|
||||
gpuMemUsed: number | null; // Summed VRAM used in MB
|
||||
gpuMemTotal: number | null; // Summed VRAM total in MB
|
||||
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
@@ -86,6 +95,7 @@ interface SharedServerStatsSession {
|
||||
clients: Map<number, ServerStatsClient>;
|
||||
intervalRef: ReturnType<typeof setInterval> | null;
|
||||
initialTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
retryTimerRef: ReturnType<typeof setTimeout> | null;
|
||||
connectedAt: number;
|
||||
hasFetched: boolean;
|
||||
fetchGeneration: number;
|
||||
@@ -115,6 +125,7 @@ function createEmptyServerStats(): ServerStats {
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
@@ -154,6 +165,7 @@ function getSharedServerStatsSession(sessionId: string): SharedServerStatsSessio
|
||||
clients: new Map(),
|
||||
intervalRef: null,
|
||||
initialTimerRef: null,
|
||||
retryTimerRef: null,
|
||||
connectedAt: 0,
|
||||
hasFetched: false,
|
||||
fetchGeneration: 0,
|
||||
@@ -180,6 +192,10 @@ function updateServerStatsState(session: SharedServerStatsSession, patch: Partia
|
||||
}
|
||||
|
||||
function clearServerStatsTimers(session: SharedServerStatsSession): void {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
if (session.initialTimerRef) {
|
||||
clearTimeout(session.initialTimerRef);
|
||||
session.initialTimerRef = null;
|
||||
@@ -212,6 +228,7 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
gpuName: stats.gpuName ?? null,
|
||||
gpuMemUsed: stats.gpuMemUsed ?? null,
|
||||
gpuMemTotal: stats.gpuMemTotal ?? null,
|
||||
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
|
||||
memTotal: stats.memTotal ?? null,
|
||||
memUsed: stats.memUsed ?? null,
|
||||
memFree: stats.memFree ?? null,
|
||||
@@ -236,10 +253,17 @@ function markServerStatsFailure(session: SharedServerStatsSession, message: stri
|
||||
session.consecutiveFailures += 1;
|
||||
updateServerStatsState(session, { error: message });
|
||||
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
|
||||
// Stop polling this session. This covers network devices that advertise
|
||||
// OpenSSH but do not support the POSIX stats shell command.
|
||||
// Back off unsupported or temporarily unavailable probes, then recover
|
||||
// automatically. Three transient failures must not disable the panel for
|
||||
// the entire lifetime of an otherwise healthy terminal session.
|
||||
session.givenUp = true;
|
||||
clearServerStatsTimers(session);
|
||||
session.retryTimerRef = setTimeout(() => {
|
||||
session.retryTimerRef = null;
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
reconcileSharedServerStatsSession(session);
|
||||
}, 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +280,10 @@ async function fetchSharedServerStats(session: SharedServerStatsSession, force =
|
||||
if (!bridge?.getServerStats) return;
|
||||
|
||||
if (force) {
|
||||
if (session.retryTimerRef) {
|
||||
clearTimeout(session.retryTimerRef);
|
||||
session.retryTimerRef = null;
|
||||
}
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
}
|
||||
@@ -319,6 +347,11 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
|
||||
// (or re-enabling a terminal stats strip) can paint immediately instead of
|
||||
// flashing "No system overview data yet." Still clear give-up / failure so a
|
||||
// later resume can auto-retry (old reset wiped stats + flags together).
|
||||
// Invalidate a request before pausing: a late reply must not publish data
|
||||
// or re-arm failure/retry timers after disconnect/disable.
|
||||
if (session.pollingActive || session.inflight || session.givenUp) {
|
||||
session.fetchGeneration += 1;
|
||||
}
|
||||
clearServerStatsTimers(session);
|
||||
session.givenUp = false;
|
||||
session.consecutiveFailures = 0;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from "../../domain/globalDefaultProxy.ts";
|
||||
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { useSettingsState } from "./useSettingsState.ts";
|
||||
|
||||
type Notification = { key: string; value: unknown };
|
||||
|
||||
const withSettingsDom = async (
|
||||
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
|
||||
) => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
dom.window.netcatty = {
|
||||
notifySettingsChanged: (payload: Notification) => {
|
||||
notifications.push(payload);
|
||||
},
|
||||
} as Window["netcatty"];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await run({ dom, root, notifications });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (saved[key] === undefined) delete globalAny[key];
|
||||
else {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const proxyNotifications = (notifications: Notification[]) => (
|
||||
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
|
||||
);
|
||||
|
||||
test("closing a second settings instance still persists a cleared global default proxy", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
let showSecond = true;
|
||||
function SettingsProbe() {
|
||||
useSettingsState({ enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(SettingsProbe),
|
||||
showSecond ? React.createElement(SettingsProbe) : null,
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
|
||||
|
||||
showSecond = false;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
notifications.length = 0;
|
||||
await act(async () => {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.deepEqual(proxyNotifications(notifications), [
|
||||
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("a peer session window follows global default proxy storage without settings sync", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
function PeerSettings() {
|
||||
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(PeerSettings));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
|
||||
notifications.length = 0;
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
|
||||
await act(async () => {
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
|
||||
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
newValue: "profile-2",
|
||||
storageArea: dom.window.localStorage,
|
||||
}));
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
|
||||
assert.deepEqual(proxyNotifications(notifications), []);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
@@ -104,6 +105,11 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
registerGlobalDefaultProxyProfileSetter,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
resolveSftpTransferConcurrency,
|
||||
@@ -151,6 +157,7 @@ import {
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -360,6 +367,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
@@ -574,6 +582,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
});
|
||||
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
return next;
|
||||
});
|
||||
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
|
||||
setHttpNetworkProxyState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -796,6 +811,37 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
setGlobalDefaultProxyProfileIdState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
|
||||
}, [globalDefaultProxyProfileId]);
|
||||
|
||||
useEffect(
|
||||
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
|
||||
[setGlobalDefaultProxyProfileId],
|
||||
);
|
||||
|
||||
// Peer session windows set enableSettingsSync false, which skips IPC sync.
|
||||
// This setting still has to follow localStorage or an already-open session
|
||||
// window keeps the proxy id it read at mount.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
|
||||
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
@@ -1079,6 +1125,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
|
||||
),
|
||||
);
|
||||
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
|
||||
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -1227,6 +1278,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -2211,6 +2263,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
@@ -2233,7 +2287,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -454,6 +454,30 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload round-trips the global default proxy profile", async () => {
|
||||
localStorage.clear();
|
||||
const absent = buildSyncPayload(vault([]));
|
||||
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
|
||||
const selected = buildSyncPayload(vault([]));
|
||||
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
|
||||
const cleared = buildSyncPayload(vault([]));
|
||||
assert.equal(cleared.settings?.defaultProxyProfileId, "");
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(selected, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
|
||||
|
||||
await applySyncPayload(cleared, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
));
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
@@ -345,6 +347,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
@@ -592,6 +595,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
}
|
||||
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
|
||||
if (storedDefaultProxyProfileId !== null) {
|
||||
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
|
||||
}
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -906,6 +913,12 @@ async function applySyncableSettings(
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
if (typeof settings.defaultProxyProfileId === 'string') {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
|
||||
@@ -15,6 +15,11 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
|
||||
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", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
c.agentForwarding !== undefined || c.x11Forwarding !== 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 ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
@@ -262,6 +262,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.agentForwarding;
|
||||
delete next.x11Forwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
delete next.legacyAlgorithms;
|
||||
@@ -499,6 +500,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedX11Forwarding = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedStartupCommandRunMode = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return "paste";
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
|
||||
@@ -568,6 +573,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
@@ -778,6 +784,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
inheritedX11Forwarding={inheritedX11Forwarding}
|
||||
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedX11Forwarding,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
@@ -395,6 +396,16 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
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
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
|
||||
@@ -30,6 +30,17 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
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', () => {
|
||||
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
|
||||
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import { applyGroupDefaults } from "../domain/groupConfig";
|
||||
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 { customThemeStore } from "../application/state/customThemeStore";
|
||||
@@ -63,6 +64,15 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
|
||||
? form.useSshAgent !== false
|
||||
: 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 (
|
||||
<>
|
||||
@@ -337,7 +347,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
{showX11Forwarding && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -345,8 +355,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
enabled={effectiveX11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
@@ -687,7 +697,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
{form.directProxy ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxy.direct")}
|
||||
</Badge>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{t("hostDetails.proxy.directHint")}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostTotpFields } from "./HostTotpFields";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
totpFlushRef,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
effectiveProtocol={effectiveProtocol}
|
||||
flushRef={totpFlushRef}
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.doesNotMatch(markup, /--token secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import type { HostTotpFlushResult } from "./HostTotpFields";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
||||
const savingHostRef = useRef(false);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
@@ -453,7 +456,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
@@ -465,19 +468,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectDirectProxy = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return { ...rest, directProxy: true } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -527,7 +537,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
||||
: hasHostname;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (savingHostRef.current) return;
|
||||
savingHostRef.current = true;
|
||||
try {
|
||||
const flushed = await totpFlushRef.current?.();
|
||||
if (flushed && !flushed.ok) return;
|
||||
const hostname = form.hostname.trim()
|
||||
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
||||
if (!hostname || !canSaveHost) return;
|
||||
@@ -608,6 +623,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
authPolicyVersion: 1,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
if (flushed?.ok) {
|
||||
if (flushed.totp) cleaned.totp = flushed.totp;
|
||||
else delete cleaned.totp;
|
||||
}
|
||||
if (pluginProtocolActive) {
|
||||
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
||||
if (!pluginConnection) return;
|
||||
@@ -735,6 +754,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
} finally {
|
||||
savingHostRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
@@ -886,8 +908,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
directProxy={form.directProxy}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onSelectDirectProxy={selectDirectProxy}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -1128,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
totpFlushRef={totpFlushRef}
|
||||
update={update}
|
||||
groupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Host } from "../domain/models";
|
||||
|
||||
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
install("window", dom.window);
|
||||
install("document", dom.window.document);
|
||||
install("navigator", dom.window.navigator);
|
||||
install("HTMLElement", dom.window.HTMLElement);
|
||||
install("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
install("HTMLButtonElement", dom.window.HTMLButtonElement);
|
||||
install("Element", dom.window.Element);
|
||||
install("Node", dom.window.Node);
|
||||
install("MutationObserver", dom.window.MutationObserver);
|
||||
install("Event", dom.window.Event);
|
||||
install("CustomEvent", dom.window.CustomEvent);
|
||||
install("MouseEvent", dom.window.MouseEvent);
|
||||
install("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
install("FocusEvent", dom.window.FocusEvent);
|
||||
install("PointerEvent", dom.window.PointerEvent);
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
Object.assign(dom.window, { netcatty: {} });
|
||||
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTotpFields } = await import("./HostTotpFields.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
|
||||
const form = {
|
||||
id: "host-1",
|
||||
label: "Jump",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
let latest = form;
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const t = (key: string) => key;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={latest}
|
||||
setForm={(updater) => {
|
||||
latest = typeof updater === "function" ? updater(latest) : updater;
|
||||
}}
|
||||
effectiveProtocol="ssh"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const markup = dom.window.document.body.innerHTML;
|
||||
assert.match(markup, /hostDetails\.totp\.setup/);
|
||||
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
|
||||
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
|
||||
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
|
||||
|
||||
const button = [...dom.window.document.querySelectorAll("button")]
|
||||
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
|
||||
assert.ok(button);
|
||||
await act(async () => { button.click(); });
|
||||
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
|
||||
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
|
||||
assert.equal(latest.totp, undefined);
|
||||
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
|
||||
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
|
||||
assert.match(fields, /setConfirmClear\(true\)/);
|
||||
assert.match(fields, /variant="secondary"/);
|
||||
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
|
||||
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
|
||||
const totpAt = sections.indexOf("<HostTotpFields");
|
||||
assert.ok(passwordAt > 0 && totpAt > passwordAt);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { HostDetailsSettingRow } from "./host-details";
|
||||
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Preview = {
|
||||
uri: string;
|
||||
code: string;
|
||||
secondsRemaining: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type HostTotpFlushResult =
|
||||
| { ok: true; totp: HostTotpConfig | undefined }
|
||||
| { ok: false };
|
||||
|
||||
type HostTotpFieldsProps = {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
form: Host;
|
||||
setForm: (updater: Host | ((host: Host) => Host)) => void;
|
||||
effectiveProtocol?: string;
|
||||
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
|
||||
};
|
||||
|
||||
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
|
||||
|
||||
function errorKey(error: string | undefined): string {
|
||||
switch (error) {
|
||||
case "hotp":
|
||||
return "hostDetails.totp.error.hotp";
|
||||
case "migration":
|
||||
return "hostDetails.totp.error.migration";
|
||||
case "not-found":
|
||||
return "hostDetails.totp.error.notFound";
|
||||
case "screen-permission-denied":
|
||||
return "hostDetails.totp.error.permission";
|
||||
case "unavailable":
|
||||
return "hostDetails.totp.error.unavailable";
|
||||
case "pattern":
|
||||
return "hostDetails.totp.error.pattern";
|
||||
default:
|
||||
return "hostDetails.totp.error.invalid";
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return jsQR(image.data, image.width, image.height)?.data || "";
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function validPattern(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length > 200) return null;
|
||||
try {
|
||||
RE2JS.compile(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function importedAccount(preview: Preview): string {
|
||||
const issuer = preview.issuer?.trim() || "";
|
||||
const label = preview.label?.trim() || "";
|
||||
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
|
||||
return `${issuer} (${label})`;
|
||||
}
|
||||
return label || issuer;
|
||||
}
|
||||
|
||||
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const hostRef = useRef(hostId);
|
||||
hostRef.current = hostId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setPreview(null);
|
||||
return undefined;
|
||||
}
|
||||
const expectedHostId = hostId;
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) return undefined;
|
||||
const endsAtRef = { current: 0 };
|
||||
let refreshed = false;
|
||||
const apply = (next: Preview) => {
|
||||
if (cancelled || hostRef.current !== expectedHostId) return;
|
||||
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
|
||||
refreshed = false;
|
||||
setPreview(next);
|
||||
};
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled || !endsAtRef.current) return;
|
||||
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
|
||||
setPreview((current) => (
|
||||
current && current.secondsRemaining !== left
|
||||
? { ...current, secondsRemaining: left }
|
||||
: current
|
||||
));
|
||||
if (left !== 0 || refreshed) return;
|
||||
refreshed = true;
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [hostId, source]);
|
||||
|
||||
return source ? preview : null;
|
||||
}
|
||||
|
||||
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
effectiveProtocol,
|
||||
flushRef,
|
||||
}) => {
|
||||
const configured = Boolean(form.totp?.uri);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
|
||||
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
|
||||
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const hostIdRef = useRef(form.id);
|
||||
const formRef = useRef(form);
|
||||
const setupOpenRef = useRef(setupOpen);
|
||||
const draftRef = useRef(draft);
|
||||
const patternRef = useRef(pattern);
|
||||
const autoSubmitRef = useRef(autoSubmit);
|
||||
const modalUriRef = useRef(modalUri);
|
||||
const requestRef = useRef(0);
|
||||
formRef.current = form;
|
||||
setupOpenRef.current = setupOpen;
|
||||
draftRef.current = draft;
|
||||
patternRef.current = pattern;
|
||||
autoSubmitRef.current = autoSubmit;
|
||||
modalUriRef.current = modalUri;
|
||||
|
||||
const [trackedHostId, setTrackedHostId] = useState(form.id);
|
||||
if (form.id !== trackedHostId) {
|
||||
setTrackedHostId(form.id);
|
||||
setSetupOpen(false);
|
||||
setDraft("");
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
setModalUri(undefined);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
setConfirmClear(false);
|
||||
draftRef.current = "";
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
modalUriRef.current = undefined;
|
||||
setupOpenRef.current = false;
|
||||
}
|
||||
hostIdRef.current = form.id;
|
||||
|
||||
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
|
||||
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
|
||||
|
||||
const writeTotp = (next: HostTotpConfig | undefined) => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
setForm((current) => {
|
||||
if (current.id !== expectedHostId) return current;
|
||||
const updated = { ...current };
|
||||
if (next?.uri) updated.totp = next;
|
||||
else delete updated.totp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const previewText = async (text: string): Promise<string | null> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) {
|
||||
setError("unavailable");
|
||||
return null;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bridge.previewTotp({ text });
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "invalid");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
return result.uri;
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
return null;
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const patternValue = validPattern(patternRef.current);
|
||||
if (patternValue == null) {
|
||||
setError("pattern");
|
||||
return { ok: false };
|
||||
}
|
||||
let uri = modalUriRef.current || formRef.current.totp?.uri;
|
||||
const text = draftRef.current.trim();
|
||||
if (text) {
|
||||
uri = await previewText(text) || undefined;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!uri) return { ok: false };
|
||||
}
|
||||
if (!uri) {
|
||||
if (options?.requireSecret) {
|
||||
setError("invalid");
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, totp: formRef.current.totp };
|
||||
}
|
||||
const next: HostTotpConfig = {
|
||||
uri,
|
||||
autoSubmit: autoSubmitRef.current,
|
||||
...(patternValue ? { promptPattern: patternValue } : {}),
|
||||
};
|
||||
writeTotp(next);
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
return { ok: true, totp: next };
|
||||
};
|
||||
const commitRef = useRef(commitSetup);
|
||||
commitRef.current = commitSetup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushRef) return undefined;
|
||||
const flush = async (): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
|
||||
return commitRef.current();
|
||||
};
|
||||
flushRef.current = flush;
|
||||
return () => {
|
||||
if (flushRef.current === flush) flushRef.current = null;
|
||||
};
|
||||
}, [flushRef]);
|
||||
|
||||
const openSetup = () => {
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setSetupOpen(true);
|
||||
setupOpenRef.current = true;
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
requestRef.current += 1;
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const clearTotp = () => {
|
||||
cancelSetup();
|
||||
writeTotp(undefined);
|
||||
};
|
||||
|
||||
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
|
||||
|
||||
const scanScreen = async () => {
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scanTotpQrFromScreen) {
|
||||
setError("unavailable");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await bridge.scanTotpQrFromScreen();
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "not-found");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.totp.title")}
|
||||
hint={t("hostDetails.totp.warning")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{rowPreview && (
|
||||
<span
|
||||
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
|
||||
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
|
||||
>
|
||||
{rowPreview.code}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={openSetup}
|
||||
>
|
||||
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
|
||||
<Input
|
||||
id="host-totp-secret"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
draftRef.current = event.target.value;
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draftRef.current.trim()) void previewText(draftRef.current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitSetup({ requireSecret: true });
|
||||
}
|
||||
}}
|
||||
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.secret")}
|
||||
className="h-8 font-mono text-xs"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("hostDetails.totp.importQr")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => { void scanScreen(); }}
|
||||
>
|
||||
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
setBusy(true);
|
||||
void decodeQrFile(file)
|
||||
.then((text) => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!text) {
|
||||
setError("not-found");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
return previewText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
setError("invalid");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{modalPreview && (
|
||||
<div className="space-y-1">
|
||||
{importedAccount(modalPreview) ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hostDetails.totp.preview")}
|
||||
{" "}
|
||||
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
|
||||
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSubmit}
|
||||
onCheckedChange={(checked) => {
|
||||
setAutoSubmit(checked);
|
||||
autoSubmitRef.current = checked;
|
||||
}}
|
||||
aria-label={t("hostDetails.totp.autoSubmit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
|
||||
<Input
|
||||
id="host-totp-pattern"
|
||||
value={pattern}
|
||||
onChange={(event) => {
|
||||
setPattern(event.target.value);
|
||||
patternRef.current = event.target.value;
|
||||
setError((current) => (current === "pattern" ? null : current));
|
||||
}}
|
||||
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.promptPattern")}
|
||||
className="h-8 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{configured ? (
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
|
||||
{t("hostDetails.totp.clear")}
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelSetup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{confirmClear ? (
|
||||
<SystemPanelConfirmDialog
|
||||
open
|
||||
destructive
|
||||
title={t("hostDetails.totp.removeTitle")}
|
||||
message={t("hostDetails.totp.removeMessage")}
|
||||
confirmLabel={t("hostDetails.totp.clear")}
|
||||
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
|
||||
onConfirm={() => {
|
||||
setConfirmClear(false);
|
||||
clearTotp();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,48 +4,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
keyboardInteractiveFieldLabel,
|
||||
keyboardInteractiveGuidance,
|
||||
totpFillPlacement,
|
||||
visibleTotpFillPlacements,
|
||||
} from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
|
||||
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
|
||||
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
|
||||
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
|
||||
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
|
||||
});
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
|
||||
assert.doesNotMatch(modalSource, /<pre/);
|
||||
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
|
||||
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
|
||||
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
|
||||
assert.match(modalSource, /variant="secondary"/);
|
||||
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
|
||||
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
|
||||
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const esm = await import("../domain/totpPrompt.shared.mjs");
|
||||
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
|
||||
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
|
||||
assert.doesNotMatch(modalSource, /ring-primary/);
|
||||
assert.doesNotMatch(modalSource, /className="w-full"/);
|
||||
assert.doesNotMatch(modalSource, /onTypeTotp/);
|
||||
});
|
||||
|
||||
assert.equal(text, "");
|
||||
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "Duo Security",
|
||||
hostname: "vpn.example",
|
||||
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
|
||||
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
|
||||
}), "Duo Security\n1. Duo Push\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "host",
|
||||
hostname: "other",
|
||||
instructions: "| Verification code:",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "host");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
|
||||
}), "1. OTP\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "1. OTP",
|
||||
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
|
||||
}), "1. OTP");
|
||||
});
|
||||
|
||||
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
|
||||
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
]), ["hidden", "preferred"]);
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
]), ["hidden"]);
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
/** otpauth URI from the connection attempt that opened this dialog. */
|
||||
totpUri?: string;
|
||||
}
|
||||
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
/** Last meaningful line of a server prompt, without the raw banner. */
|
||||
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = lines.at(-1) || "";
|
||||
const cleaned = last
|
||||
.replace(/^[\s||>•*-]+/, "")
|
||||
.replace(/[::]\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
function guidanceKeys(text: string): string[] {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
|
||||
return [trimmed, label].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
|
||||
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
|
||||
const labelKeys = new Set<string>();
|
||||
const promptLines: string[] = [];
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
for (const key of guidanceKeys(last)) labelKeys.add(key);
|
||||
promptLines.push(...parts.slice(0, -1));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
const push = (text: string) => {
|
||||
const line = text.trim().replace(/^\|\s*/, "");
|
||||
if (!line) return;
|
||||
const keys = guidanceKeys(line);
|
||||
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
|
||||
for (const key of keys) seen.add(key);
|
||||
lines.push(line);
|
||||
};
|
||||
const hostname = (request.hostname || "").trim().toLowerCase();
|
||||
const name = (request.name || "").trim();
|
||||
if (name && name.toLowerCase() !== hostname) push(name);
|
||||
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
|
||||
for (const part of promptLines) push(part);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
canFillTotp?: boolean;
|
||||
onFillTotp?: () => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
|
||||
|
||||
export type TotpFillPlacement = "preferred" | "available" | "hidden";
|
||||
|
||||
function lastPromptLine(prompt: string | undefined): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.at(-1) || "";
|
||||
}
|
||||
|
||||
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
|
||||
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
|
||||
const rawLast = lastPromptLine(prompt.prompt);
|
||||
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
|
||||
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
|
||||
if (OTP_PROMPT.test(label)) return "preferred";
|
||||
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
|
||||
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
|
||||
return "available";
|
||||
}
|
||||
|
||||
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
|
||||
const placements = prompts.map(totpFillPlacement);
|
||||
const hasPreferred = placements.some((placement) => placement === "preferred");
|
||||
if (!hasPreferred) return placements;
|
||||
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
canFillTotp = false,
|
||||
onFillTotp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleFillTotp = useCallback(async (index: number) => {
|
||||
if (!request || isSubmitting || !onFillTotp) return;
|
||||
const prompt = request.prompts[index];
|
||||
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
|
||||
const code = await onFillTotp();
|
||||
if (!code) return;
|
||||
handleResponseChange(index, code);
|
||||
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
|
||||
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
|
||||
const guidance = keyboardInteractiveGuidance(request);
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{guidance ? (
|
||||
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
const promptLabel = keyboardInteractiveFieldLabel(
|
||||
prompt.prompt,
|
||||
t("keyboard.interactive.verificationCode"),
|
||||
);
|
||||
const fillPlacement = fillPlacements[index] ?? "hidden";
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
{showPromptLabel && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
{promptLabel}
|
||||
</Label>
|
||||
)}
|
||||
{fillPlacement !== "hidden" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
data-totp-fill={fillPlacement}
|
||||
onClick={() => { void handleFillTotp(index); }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("keyboard.interactive.fillTotp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { connectionTotpFields } from "../domain/totpConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
...connectionTotpFields(effectiveExportHost),
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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, PortForwardingRule, PortForwardingType } from "../domain/models";
|
||||
import { EditPanel } from "./port-forwarding/EditPanel";
|
||||
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
|
||||
import { WizardContent } from "./port-forwarding/WizardContent";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
const baseHost: Omit<Host, "os"> = {
|
||||
id: "host-1",
|
||||
label: "Forwarding host",
|
||||
hostname: "example.com",
|
||||
username: "user",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const baseRule: PortForwardingRule = {
|
||||
id: "rule-1",
|
||||
label: "Test forwarding rule",
|
||||
type: "local",
|
||||
hostId: baseHost.id,
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "localhost",
|
||||
remotePort: 80,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const noop = () => undefined;
|
||||
const panelRenderers = {
|
||||
edit: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<EditPanel
|
||||
rule={draft}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onDuplicate={noop}
|
||||
onDelete={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
new: (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<NewFormPanel
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onDraftChange={noop}
|
||||
onSave={noop}
|
||||
onClose={noop}
|
||||
onOpenHostSelector={noop}
|
||||
onOpenWizard={noop}
|
||||
isValid={false}
|
||||
/>
|
||||
),
|
||||
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="remote-host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
|
||||
<WizardContent
|
||||
step="host-selection"
|
||||
type={draft.type}
|
||||
draft={draft}
|
||||
hosts={hosts}
|
||||
onTypeChange={noop}
|
||||
onDraftChange={noop}
|
||||
onOpenHostSelector={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
|
||||
{ name: "missing OS", fields: {} },
|
||||
{ name: "undefined OS", fields: { os: undefined } },
|
||||
{ name: "null OS", fields: { os: null } },
|
||||
{ name: "empty OS", fields: { os: "" } },
|
||||
{ name: "Linux", fields: { os: "linux" } },
|
||||
{ name: "Windows", fields: { os: "windows" } },
|
||||
{ name: "macOS", fields: { os: "macos" } },
|
||||
];
|
||||
|
||||
function render(panel: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>{panel}</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
|
||||
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
|
||||
for (const { name, fields } of hostCases) {
|
||||
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
|
||||
// Persisted legacy records can violate the required Host.os type at runtime.
|
||||
const host = { ...baseHost, ...fields } as unknown as Host;
|
||||
const markup = render(renderPanel([host], { ...baseRule, type }));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /lucide-server/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test(`${panelName} preserves the selected host's distro icon`, () => {
|
||||
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
|
||||
const markup = render(renderPanel([host], baseRule));
|
||||
|
||||
assert.match(markup, /Forwarding host/);
|
||||
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when no host is selected`, () => {
|
||||
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
|
||||
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
|
||||
const markup = render(renderPanel([], baseRule));
|
||||
|
||||
assert.match(markup, /Select a host/);
|
||||
assert.doesNotMatch(markup, /Forwarding host/);
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
} from "../domain/globalDefaultProxy";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
@@ -391,6 +395,9 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
}
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The vault surface is visibility:hidden while a terminal tab is active.
|
||||
// Portal so the same wizard opened from the tab-bar switcher stays visible.
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
return createPortal(wizard, document.body);
|
||||
}
|
||||
return wizard;
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "saved-1",
|
||||
label: "core-switch",
|
||||
hostname: "10.1.1.1",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
}) as Host;
|
||||
|
||||
const renderSwitcher = (
|
||||
query: string,
|
||||
results: Host[] = [],
|
||||
locale = "en",
|
||||
) => renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query={query}
|
||||
results={results}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
const hostAt = markup.indexOf("core-switch");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(hostAt > selectedAt);
|
||||
assert.ok(quickConnectAt > hostAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher does not steal Enter from a saved host label", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
|
||||
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
|
||||
const hostAt = markup.indexOf(`>${label}<`);
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.equal(markup.includes("Quick connect to"), false, label);
|
||||
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching open session ahead of an address row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.4.4.4"
|
||||
results={[]}
|
||||
sessions={[{
|
||||
id: "sess-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "open-core",
|
||||
username: "admin",
|
||||
hostname: "10.4.4.4",
|
||||
status: "connected",
|
||||
} as TerminalSession]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const sessionAt = markup.indexOf("open-core");
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(sessionAt > selectedAt);
|
||||
assert.ok(quickConnectAt > sessionAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher offers quick connect when nothing else matches", () => {
|
||||
const markup = renderSwitcher("root@10.2.0.8:2222");
|
||||
|
||||
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
|
||||
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
|
||||
});
|
||||
|
||||
test("quick switcher does not offer quick connect for ordinary search text", () => {
|
||||
const markup = renderSwitcher("core switch");
|
||||
|
||||
assert.doesNotMatch(markup, /Quick connect to/);
|
||||
});
|
||||
|
||||
test("quick switcher quick connect row uses zh-TW wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-TW">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.0.0.8"
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /快速連線到 10\.0\.0\.8/);
|
||||
assert.match(markup, />主機</);
|
||||
assert.match(markup, />新增工作區</);
|
||||
assert.doesNotMatch(markup, /New Workspace/);
|
||||
});
|
||||
|
||||
test("quick switcher section labels use zh-CN wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query=""
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, />主机</);
|
||||
assert.match(markup, />标签页</);
|
||||
assert.match(markup, />新建工作区</);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -30,11 +31,12 @@ type QuickSwitcherItemBase = {
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
quickConnectInput?: string;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
@@ -165,6 +167,7 @@ interface QuickSwitcherProps {
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
onQuickConnect?: (input: string) => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
onQuickConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -335,19 +339,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
const quickConnectItem: QuickSwitcherItem | null =
|
||||
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
|
||||
? {
|
||||
type: "quick-connect",
|
||||
id: "quick-connect",
|
||||
title: t("qs.quickConnectTo", { target: trimmedQuery }),
|
||||
quickConnectInput: trimmedQuery,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
|
||||
// The address row is the default Enter target only when nothing else matches.
|
||||
// Matching hosts and open tabs stay ahead of it.
|
||||
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
|
||||
pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", t("qs.hosts"));
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
if (quickConnectItem) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
pushHeader("header:tabs", t("qs.tabs"));
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
@@ -357,6 +379,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
@@ -445,6 +468,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "quick-connect":
|
||||
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
|
||||
onClose();
|
||||
break;
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
@@ -542,7 +569,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
<span>{t("qs.newWorkspace")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +603,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "quick-connect") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
|
||||
@@ -141,6 +141,8 @@ type TerminalTabSettingsProps = Pick<
|
||||
| 'setLocalShellSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
| 'globalDefaultProxyProfileId'
|
||||
| 'setGlobalDefaultProxyProfileId'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
@@ -171,6 +173,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setLocalShellSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
@@ -204,6 +208,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -599,6 +605,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
@@ -76,8 +76,8 @@ import {
|
||||
resolveLocateSftpPathInTerminalAction,
|
||||
resolveLocateSftpPathSessionId,
|
||||
} from "../domain/sftpLocatePathInTerminal";
|
||||
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
|
||||
sessionId: string;
|
||||
singleChannel: boolean;
|
||||
remoteSshVersion?: string;
|
||||
} | null>(null);
|
||||
const activeSessionStatusForBanner = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
||||
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}
|
||||
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
|
||||
if (!readRemoteInfo) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (cancelled) return;
|
||||
setSftpBannerDecision({
|
||||
sessionId: activeSessionId,
|
||||
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
|
||||
setSftpBannerDecision({ sessionId: activeSessionId });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
// The peer banner is known only after the terminal handshake. Wait for it
|
||||
// before choosing a shared terminal channel, so a one-channel bastion
|
||||
// dials its file login directly.
|
||||
// before choosing a shared terminal channel, so a one-channel bastion or
|
||||
// network-vendor switch dials its file login directly.
|
||||
if (
|
||||
activeSessionId
|
||||
&& activeSessionStatus === "connected"
|
||||
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel;
|
||||
// Independent login: network-device mode, a network-vendor banner, or a
|
||||
// one-channel bastion. Do not open SFTP on the terminal connection.
|
||||
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
});
|
||||
const reuseTerminalTransport = !dedicatedSftpLogin && (
|
||||
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
|
||||
);
|
||||
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
activeHost={activeHost}
|
||||
activeSessionId={activeSessionId}
|
||||
bannerRequiresSingleChannel={
|
||||
sftpBannerDecision?.sessionId === activeSessionId
|
||||
&& sftpBannerDecision.singleChannel
|
||||
}
|
||||
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
|
||||
host: activeHost,
|
||||
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
|
||||
? sftpBannerDecision.remoteSshVersion
|
||||
: undefined,
|
||||
})}
|
||||
focusedSessionId={focusedSessionId}
|
||||
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
||||
renderOverlays={renderOverlays}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
@@ -161,16 +162,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
resolveTransferSourceSessionId,
|
||||
]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
// Memoized on vault props, so this subscription is what refreshes the list
|
||||
// after the global default changes.
|
||||
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
|
||||
: applyGroupDefaults(h, {}, inheritanceOptions);
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
|
||||
+57
-16
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -163,8 +163,12 @@ import {
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
|
||||
import {
|
||||
applyUserCursorPreference,
|
||||
applyUserCursorWidthPreference,
|
||||
@@ -439,7 +443,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const recorder = useScriptRecorder(sessionId);
|
||||
const recorderRef = useRef(recorder);
|
||||
recorderRef.current = recorder;
|
||||
const passwordPromptActiveRef = useRef(false);
|
||||
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
|
||||
const terminalTitleRef = useRef<string | undefined>(undefined);
|
||||
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
|
||||
useEffect(() => registerTerminalSensitiveInputReader(
|
||||
@@ -448,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -599,18 +605,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
const readiness = {
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
});
|
||||
};
|
||||
return options?.allowDraft
|
||||
? isShellPromptReadyForInsertion(readiness)
|
||||
: isIdleShellReadyForCommandInjection(readiness);
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
@@ -1162,7 +1171,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}): Promise<string | undefined> => {
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// The backend pwd probe opens an exec channel on the terminal connection;
|
||||
// network devices (mode, detected distro, or vendor banner such as Huawei
|
||||
// `-`) can drop the whole session for it (#1043, #3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionRef.current) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
|
||||
@@ -1175,7 +1194,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requireActiveShellCwd: options?.requireActiveShellCwd,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
}, [host, terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
@@ -1685,6 +1704,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (typeof payload.passwordPromptActive === "boolean") {
|
||||
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) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
||||
@@ -1991,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2003,23 +2029,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
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) {
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
}
|
||||
}, [isNetworkDevice]);
|
||||
|
||||
@@ -2331,6 +2364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}), []);
|
||||
|
||||
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
||||
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
||||
|
||||
@@ -2420,7 +2455,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
}, [pluginTerminalLifecycle]);
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// 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((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -4321,6 +4361,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
xTermRuntimeContextRef.current = {
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize: effectiveFontSize,
|
||||
@@ -4644,7 +4685,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
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, 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, 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 });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
|
||||
import {
|
||||
@@ -114,7 +113,7 @@ import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
@@ -1348,7 +1347,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
// Local POSIX shells do not emit OSC 7. They share this probe so session
|
||||
// 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 visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
@@ -1372,6 +1376,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
canProbe: async () => {
|
||||
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);
|
||||
if (!host) return false;
|
||||
const hostDeviceClass = classifyDistroId(host.distro);
|
||||
@@ -1594,7 +1600,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}): Promise<string | null> => {
|
||||
const sessionId = getActiveTerminalSessionId();
|
||||
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
|
||||
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
// Same rule as the terminal's own SFTP path lookup (#3631).
|
||||
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
|
||||
if (!skipBackendPwd && sessionId && host) {
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
|
||||
skipBackendPwd = !shouldProbeSessionCwd({
|
||||
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
});
|
||||
}
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
rendererCwdSource: sessionId
|
||||
|
||||
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("main window controls keep mouse presses from taking focus", () => {
|
||||
const windowControlsSource = topTabItemsSource.slice(
|
||||
topTabItemsSource.indexOf("export const WindowControls"),
|
||||
topTabItemsSource.indexOf("WindowControls.displayName"),
|
||||
);
|
||||
|
||||
assert.match(
|
||||
windowControlsSource,
|
||||
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
|
||||
);
|
||||
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
|
||||
const closeButton = windowControlsSource.match(
|
||||
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
|
||||
)?.[0] ?? "";
|
||||
assert.equal(closeButton.includes("onMouseDown"), false);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
|
||||
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredNumber } from "../application/state/useStoredNumber";
|
||||
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
|
||||
setQuickConnectTarget(target);
|
||||
setQuickConnectWarnings(warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}), []);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
null,
|
||||
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
|
||||
// Only these terminal connection settings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
// and we don't want to re-render for them. onConnect is also omitted:
|
||||
// temporary-connection toggles are read from a ref at connect time.
|
||||
prev.terminalSettings?.verifyHostKeys ===
|
||||
next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval ===
|
||||
|
||||
@@ -51,3 +51,17 @@ test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* even when the Catty AI side panel has never been opened.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,13 @@ import {
|
||||
resolveCapabilityId,
|
||||
} from '../../infrastructure/ai/harness/permissionGrants';
|
||||
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__';
|
||||
|
||||
@@ -31,19 +38,37 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
|
||||
export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
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(() => {
|
||||
const handler = (request: ApprovalRequest) => {
|
||||
if (!isExternalMcpApproval(request)) return;
|
||||
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);
|
||||
replayPendingApprovals(handler);
|
||||
return unsub;
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onApprovalCleared((clearedIds) => {
|
||||
for (const id of clearedIds) alertedIdsRef.current.delete(id);
|
||||
setPendingApprovals((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const id of clearedIds) next.delete(id);
|
||||
@@ -129,7 +154,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
{entries.map(([id, req]) => (
|
||||
<div key={id} className="space-y-1.5">
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
name={externalMcpApprovalToolLabel(req.toolName)}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
|
||||
@@ -27,8 +27,10 @@ export interface ProxyPanelProps {
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
directProxy?: boolean;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onSelectDirectProxy?: () => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -42,8 +44,10 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
directProxy = false,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onSelectDirectProxy,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
@@ -54,13 +58,18 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
const directValue = '__direct__';
|
||||
const selectedProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
|
||||
[proxyProfiles, selectedProxyProfileId],
|
||||
);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
|
||||
const selectedValue = directProxy
|
||||
? directValue
|
||||
: selectedProfile
|
||||
? selectedProfile.id
|
||||
: customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
@@ -71,8 +80,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
@@ -103,7 +112,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && onSelectProxyProfile && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
@@ -111,7 +120,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
onValueChange={(value) => {
|
||||
if (value === directValue) {
|
||||
onSelectDirectProxy?.();
|
||||
return;
|
||||
}
|
||||
onSelectProxyProfile(value === customValue ? undefined : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
@@ -120,6 +135,9 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{onSelectDirectProxy && (
|
||||
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
|
||||
)}
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
@@ -163,7 +181,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isUsingProfile && (
|
||||
{!isUsingProfile && !directProxy && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
@@ -328,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={selectedHost.os[0].toUpperCase()}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="tree"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
|
||||
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar
|
||||
host={selectedHost}
|
||||
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
|
||||
size="sm"
|
||||
/>
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,13 @@ import type {
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
ProxyProfile,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
@@ -43,6 +46,34 @@ import {
|
||||
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
|
||||
} from "../../../domain/terminalInlineImages";
|
||||
|
||||
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
|
||||
|
||||
const readSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
};
|
||||
|
||||
const useSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
|
||||
useEffect(() => {
|
||||
const refresh = (key?: string | null) => {
|
||||
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
|
||||
setProfiles(readSavedProxyProfiles());
|
||||
};
|
||||
const onAdapterChange = (event: Event) => {
|
||||
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => refresh(event.key);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
@@ -87,6 +118,8 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
globalDefaultProxyProfileId?: string;
|
||||
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -117,8 +150,14 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const proxyProfiles = useSavedProxyProfiles();
|
||||
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
|
||||
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
|
||||
: GLOBAL_DEFAULT_PROXY_NONE;
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
@@ -932,6 +971,23 @@ function SettingsTerminalTab(props: {
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-default-proxy"
|
||||
label={t("settings.terminal.connection.defaultProxy")}
|
||||
description={t("settings.terminal.connection.defaultProxy.desc")}
|
||||
>
|
||||
<Select
|
||||
value={defaultProxyValue}
|
||||
onChange={(value) => {
|
||||
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
|
||||
}}
|
||||
options={[
|
||||
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
|
||||
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
|
||||
]}
|
||||
className="w-56"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-verify-host-keys"
|
||||
label={t("settings.terminal.connection.verifyHostKeys")}
|
||||
@@ -942,6 +998,26 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-legacy-algorithms"
|
||||
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
|
||||
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-network-device-mode"
|
||||
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
|
||||
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
|
||||
>
|
||||
<Input
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
|
||||
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
|
||||
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
|
||||
placeholder="\\n"
|
||||
placeholder="\\e\\r"
|
||||
className="w-56 font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,31 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
readExternalMcpFocusOnHostOpen,
|
||||
readExternalMcpIdleTimeoutMinutes,
|
||||
readExternalMcpMode,
|
||||
readExternalMcpSilentSessions,
|
||||
readSessionIdleTimeoutMinutes,
|
||||
writeExternalMcpApprovalAlertMode,
|
||||
writeExternalMcpFocusOnHostOpen,
|
||||
writeExternalMcpIdleTimeoutMinutes,
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpClientHost,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpClientHost,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpHttpSettings,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
@@ -19,70 +33,14 @@ import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
|
||||
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
|
||||
|
||||
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExternalMcpStatus = {
|
||||
ok: boolean;
|
||||
enabled?: boolean;
|
||||
@@ -97,6 +55,7 @@ type ExternalMcpStatus = {
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
permissionMode?: string;
|
||||
error?: string | null;
|
||||
http?: ExternalMcpHttpViewStatus | null;
|
||||
};
|
||||
|
||||
type ClientSetupStatus = {
|
||||
@@ -297,8 +256,19 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
|
||||
() => readExternalMcpApprovalAlertMode(),
|
||||
);
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -344,11 +314,51 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
|
||||
}, []);
|
||||
|
||||
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
|
||||
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
|
||||
}, []);
|
||||
|
||||
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
|
||||
setSilentSessionsRaw(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 bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
@@ -698,6 +708,24 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
label={t("ai.externalMcp.mode")}
|
||||
description={t("ai.externalMcp.mode.description")}
|
||||
@@ -778,6 +806,17 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
externalMcpEnabled={enabled}
|
||||
settings={httpSettings}
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
buildCodexHttpToml,
|
||||
buildExternalMcpHttpUrl,
|
||||
buildGenericHttpMcpJson,
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
enabled?: boolean;
|
||||
listening?: boolean;
|
||||
port?: number;
|
||||
listenAddress?: string;
|
||||
allowCustomBind?: boolean;
|
||||
clientHost?: string;
|
||||
url?: string | null;
|
||||
bearerToken?: string | null;
|
||||
state?: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
export type ExternalMcpHttpViewSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpHttpSectionProps = {
|
||||
externalMcpEnabled: boolean;
|
||||
settings: ExternalMcpHttpViewSettings;
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
};
|
||||
|
||||
function isWildcardAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
return address === "0.0.0.0" || address === "::";
|
||||
}
|
||||
|
||||
function isIpAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
}
|
||||
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
|
||||
}
|
||||
|
||||
function parsePort(value: string): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
|
||||
return port;
|
||||
}
|
||||
|
||||
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
externalMcpEnabled,
|
||||
settings,
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
const [portError, setPortError] = useState(false);
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
const advertisedHost = status?.clientHost ?? settings.clientHost;
|
||||
const url = status
|
||||
? (status.url || "")
|
||||
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
|
||||
const showClientHost = settings.allowCustomBind && (
|
||||
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
|
||||
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
|
||||
else if (status?.errorCode === "port_in_use") {
|
||||
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
if (port == null) {
|
||||
setPortError(true);
|
||||
return;
|
||||
}
|
||||
setPortError(false);
|
||||
setPortDraft(String(port));
|
||||
if (port !== settings.port) onChange({ ...settings, port });
|
||||
};
|
||||
|
||||
const commitClientHost = () => {
|
||||
const clientHost = clientHostDraft.trim();
|
||||
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
|
||||
setClientHostError(true);
|
||||
return;
|
||||
}
|
||||
setClientHostError(false);
|
||||
setClientHostDraft(clientHost);
|
||||
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
|
||||
};
|
||||
|
||||
const commitAddress = () => {
|
||||
const address = addressDraft.trim();
|
||||
if (!isIpAddress(address)) {
|
||||
setAddressError(true);
|
||||
return;
|
||||
}
|
||||
setAddressError(false);
|
||||
setAddressDraft(address);
|
||||
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) => onChange({ ...settings, enabled })}
|
||||
ariaLabel={t("ai.externalMcp.http.enable")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.port")}
|
||||
description={t("ai.externalMcp.http.portHint")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
onChange={(event) => {
|
||||
setPortDraft(event.currentTarget.value);
|
||||
setPortError(false);
|
||||
}}
|
||||
onBlur={commitPort}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.allowCustomBind")}
|
||||
description={t("ai.externalMcp.http.allowCustomBind.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.allowCustomBind}
|
||||
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
|
||||
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
|
||||
/>
|
||||
</SettingRow>
|
||||
{settings.allowCustomBind ? (
|
||||
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
setAddressDraft(event.currentTarget.value);
|
||||
setAddressError(false);
|
||||
}}
|
||||
onBlur={commitAddress}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
{showClientHost ? (
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.clientHost")}
|
||||
description={t("ai.externalMcp.http.clientHost.description")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
setClientHostDraft(event.currentTarget.value);
|
||||
setClientHostError(false);
|
||||
}}
|
||||
onBlur={commitClientHost}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
{settings.allowCustomBind ? (
|
||||
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
copyKey="http-url"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.codexSnippet")}
|
||||
value={buildCodexHttpToml(url)}
|
||||
copyKey="http-codex"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -177,6 +177,12 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
rotateHttpToken?: boolean;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
externalMcpGetUniversalSetupPrompt?: () => Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
|
||||
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const slotSource = readFileSync(
|
||||
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
|
||||
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
|
||||
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
|
||||
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
|
||||
assert.match(sidePanelSource, /reuseTransport: false/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
|
||||
);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId: reuseTerminalTransport/,
|
||||
);
|
||||
});
|
||||
|
||||
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
|
||||
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
|
||||
});
|
||||
|
||||
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
|
||||
);
|
||||
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
|
||||
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "general" },
|
||||
remoteSshVersion,
|
||||
}),
|
||||
true,
|
||||
remoteSshVersion,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sftpRequiresDedicatedLogin({
|
||||
host: { deviceType: "network" },
|
||||
remoteSshVersion: "OpenSSH_9.6",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
|
||||
|
||||
const assertBannerPwdGuard = (source: string, fnName: string) => {
|
||||
const start = source.indexOf(`const ${fnName} = useCallback`);
|
||||
assert.ok(start >= 0, fnName);
|
||||
const body = source.slice(start, start + 1600);
|
||||
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
|
||||
assert.match(body, /getSessionRemoteInfo\?\./);
|
||||
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
|
||||
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
|
||||
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
|
||||
assert.match(body, /getSessionPwd: skipBackendPwd/);
|
||||
};
|
||||
|
||||
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
|
||||
assertBannerPwdGuard(layerSource, "getTerminalCwd");
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
|
||||
);
|
||||
assert.match(
|
||||
layerSource,
|
||||
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user