Compare commits

...
39 Commits
Author SHA1 Message Date
陈大猫 94e359f6d7 [codex] Add macOS Dock new connection menu (#1935)
* Add macOS Dock new connection menu

* Fix Dock menu window readiness

* Wait for renderer before Dock menu delivery

* Queue Dock host connects until vault ready
2026-07-04 22:14:44 +08:00
陈大猫 101ff24a1f [codex] add system overview dashboard (#1934)
* Add system overview dashboard

* Fix system overview polling edge cases
2026-07-04 21:52:14 +08:00
陈大猫 9fe1fc7aa4 Fix manual session log capture (#1933) 2026-07-04 20:29:43 +08:00
陈大猫 1dbf46e32f Default SSH auto reconnect off (#1932) 2026-07-04 19:37:11 +08:00
陈大猫 6e3401f9dc [codex] Add SSH auto reconnect (#1931)
* Add SSH auto reconnect

* Fix auto reconnect for hibernated sessions
2026-07-04 19:20:24 +08:00
陈大猫 6944b77ec7 Fix terminal Shift mouse selection (#1930) 2026-07-04 19:04:45 +08:00
陈大猫 3f2e33f6e0 [codex] Remember serial YMODEM send directory (#1928)
* Remember serial YMODEM send directory

* Handle YMODEM path edge cases
2026-07-04 17:38:33 +08:00
陈大猫 2b9846d1d2 Fix terminal font remeasure after load (#1927) 2026-07-04 17:04:48 +08:00
陈大猫 b6a7f40175 Fix Ctrl-L scrollback preservation (#1923) 2026-07-04 16:02:31 +08:00
陈大猫 aa4b7f46e5 Show enter reconnect hint (#1925) 2026-07-04 15:14:55 +08:00
HLDandbincxz c79d51858a feat: add script dialog form controls (#1907)
* feat: add script dialog form controls

* feat: add script dialog text and number fields

* feat: add conditional script dialog fields

* fix: address script dialog review feedback

* fix: harden script dialog form validation

* fix: harden script dialog review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-04 15:04:08 +08:00
陈大猫 e430a42dbe Fix SFTP follow cwd manual navigation (#1922) 2026-07-04 15:03:01 +08:00
陈大猫 5dec129088 [codex] Add enter reconnect for disconnected terminals (#1924)
* Add enter reconnect for disconnected terminals

* Honor workspace compose bar for enter reconnect
2026-07-04 15:00:45 +08:00
陈大猫 f299271b1b fix opencode windows permission allowlist (#1921) 2026-07-04 14:02:12 +08:00
陈大猫 49787215d2 Fix RPM build-id file conflicts (#1919) 2026-07-04 13:34:04 +08:00
陈大猫 5e34538c55 Add double-click copy for session tabs (#1917) 2026-07-04 10:59:34 +08:00
陈大猫 862c3f6fb5 Fix vault host drag reset (#1916) 2026-07-04 10:22:03 +08:00
陈大猫 2edbeae02c Remove unused code and assets
Remove unused code, assets, and stale config entries.
2026-07-03 10:47:59 +08:00
陈大猫 02ada0c61d Update Nix release metadata for v1.1.56
Update Nix release metadata for v1.1.56
2026-07-03 00:30:15 +08:00
陈大猫 98e95c36dc Align localized READMEs
Align localized READMEs
2026-07-03 00:03:14 +08:00
Copilot a1b1c37f36 Update Chinese and Japanese READMEs to match English README (#1894)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-02 23:45:52 +08:00
陈大猫 45905f8f34 Clean up README by removing unused image URLs (#1893)
Removed unnecessary image links from the README.
2026-07-02 23:36:24 +08:00
陈大猫 6d83b32815 Remove demos and project structure from README (#1892)
Removed demo section and project structure details from README.
2026-07-02 23:25:47 +08:00
陈大猫 a366cf81d9 fix ssh-agent certificate auth (#1889) 2026-07-02 23:00:57 +08:00
陈大猫 3a8bd7eea7 Update README.md (#1891) 2026-07-02 22:58:03 +08:00
陈大猫 a46c3321db Fix explicit script wait APIs (#1885) 2026-07-02 17:48:04 +08:00
陈大猫 eedb123b3a Update Nix release metadata for v1.1.55 (#1883) 2026-07-02 15:58:06 +08:00
陈大猫 1c7dc33852 [codex] Fix terminal catch-up after backgrounding (#1881)
* Fix terminal catch-up after backgrounding

* Harden background terminal output flushing
2026-07-02 14:25:48 +08:00
Ryanisgoodandbincxz 49a3c31de8 修复 SSH 加密默认密钥重试导致会话提前关闭 (#1878)
* fix: prevent ssh auth retry from closing session early

* fix: cover ssh auth retry stale exits

* fix: handle ssh retry reuse edge cases

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-02 14:15:41 +08:00
陈大猫 82e318c17f Update Nix release metadata for v1.1.54 (#1875) 2026-07-02 02:01:16 +08:00
陈大猫 920e3cf8ec Adjust all hosts header spacing (#1874) 2026-07-02 01:38:14 +08:00
陈大猫 2fe7de6cd7 Merge pull request #1873 from binaricat/codex/terminal-ui-dev-perf
[codex] Improve terminal tab switching stability
2026-07-02 01:25:57 +08:00
bincxz f3ac99e587 Address terminal UI review findings 2026-07-02 01:13:38 +08:00
bincxz fe7a867f38 Improve terminal tab switching stability 2026-07-02 01:00:32 +08:00
陈大猫 7d70d85d23 Merge pull request #1872 from binaricat/codex/terminal-tab-switch-recovery
[codex] Fix terminal tab switch recovery
2026-07-02 00:05:56 +08:00
bincxz 5a70e9d6b8 Respect scroll-on-output for hidden recovery 2026-07-01 23:54:48 +08:00
bincxz 65b00ae12f Fix terminal tab switch recovery 2026-07-01 23:44:37 +08:00
陈大猫 c36ff6bd49 Merge pull request #1869 from binaricat/codex/update-nix-release-v1.1.53
update nix release metadata for v1.1.53
2026-07-01 21:16:32 +08:00
bincxz 269b4d7a7a update nix release metadata for v1.1.53 2026-07-01 21:16:03 +08:00
184 changed files with 9746 additions and 1911 deletions

No files matched your search

+8 -2
View File
@@ -411,8 +411,8 @@ jobs:
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Install Linux packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
- name: Set version
shell: bash
@@ -453,6 +453,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -566,6 +569,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+13 -2
View File
@@ -72,7 +72,7 @@ import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -114,6 +114,7 @@ function App({ settings }: { settings: SettingsState }) {
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
@@ -557,6 +558,7 @@ function App({ settings }: { settings: SettingsState }) {
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
@@ -589,7 +591,7 @@ function App({ settings }: { settings: SettingsState }) {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
_handleTrayPanelConnectRequest(hostId);
});
return () => {
unsubscribeJump?.();
@@ -597,6 +599,15 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: _handleTrayPanelConnect,
pendingHostIds: pendingTrayPanelConnectHostIds,
setPendingHostIds: setPendingTrayPanelConnectHostIds,
}));
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
+54 -76
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<strong>🔥 AI 搭載の SSH クライアント、SFTP ブラウザ & ターミナルマネージャー 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<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" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — あなたの IT 運用 AI パートナー
> 🚀 **AI で日常の IT 運用作業を加速。** Catty Agent はサーバー環境を理解し、コマンドを実行し、自然な会話で複数ホストの複雑な操作をこなす内蔵 AI アシスタントです。
### 🔥 Catty Agent にできること
- 🚀 **自然言語でサーバー管理** — コマンドを暗記せず、必要なことを話しかけるだけ
- 🔥 **リアルタイムサーバー診断** — 会話を通じてステータス確認、ログ調査、リソース監視
- 🚀 **マルチホスト連携** — 複数サーバーのタスクを同時にコーディネート
- 🔥 **インテリジェントなコンテキスト認識** — サーバー環境を理解し、的確な回答を提供
- 🚀 **ワンステップで複雑な操作** — クラスター構築、サービスデプロイなど簡単な指示で実行
### 🎬 AI の動作デモ
#### 🔥 シングルホスト — インテリジェントなサーバー診断
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 マルチホスト — 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" />
---
# 目次 <!-- omit in toc -->
- [🔥 Catty Agent — AI パートナー](#catty-agent)
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
@@ -71,9 +100,10 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** デュアルペインのファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
- **Netcatty は** SSH、ローカルターミナル、Telnet、Mosh、シリアル接続をサポートします(利用可能な場合)
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh またはローカル/シリアルセッション経由でシェルに接続します
---
@@ -82,9 +112,9 @@
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
- **ワークスペース中心** — 分割ペイン + タブ + セッション復元で常時使うワークフローに対応
- **Vault の整理** — グリッド/リスト/ツリー表示、高速検索、ドラッグしやすいワークフロー
- **本格的な SFTP** — 内蔵エディタ + ドラッグ&ドロップ + スムーズなファイル操作
---
@@ -109,43 +139,6 @@
---
<a name="デモ"></a>
# デモ
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="スクリーンショット"></a>
# スクリーンショット
@@ -154,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
![Vault グリッドビュー](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault リストビュー](screenshots/vault_list_view.png)
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
![分割ウィンドウ](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -201,8 +190,6 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
<a name="はじめに"></a>
# はじめに
@@ -220,6 +207,16 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
### Nix / NixOS
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
```bash
nix run github:binaricat/Netcatty
```
宣言型インストールには、Netcatty flake を input として追加し、NixOS または Home Manager のパッケージリストで `inputs.netcatty.packages.${pkgs.system}.default` を使用してください。
### 前提条件
- Node.js 18+ と npm
- macOS、Windows 10+、または Linux
@@ -238,25 +235,6 @@ npm install
npm run dev
```
### プロジェクト構造
```
├── App.tsx # メイン React アプリケーション
├── components/ # React コンポーネント
│ ├── Terminal.tsx # ターミナルコンポーネント
│ ├── SftpView.tsx # SFTP ブラウザ
│ ├── VaultView.tsx # ホスト管理
│ ├── KeyManager.tsx # SSH キー管理
│ └── ...
├── application/ # 状態管理 & 国際化
├── domain/ # ドメインモデル & ロジック
├── infrastructure/ # サービス & アダプター
├── electron/ # Electron メインプロセス
│ ├── main.cjs # メインエントリ
│ └── bridges/ # IPC ブリッジ
└── public/ # 静的アセット & アイコン
```
---
<a name="ビルドとパッケージ"></a>
+9 -114
View File
@@ -40,8 +40,7 @@
---
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
<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" />
---
@@ -63,25 +62,13 @@
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.
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 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.
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
@@ -91,7 +78,6 @@ https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
@@ -150,76 +136,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
---
<a name="demos"></a>
# Demos
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
---
<a name="screenshots"></a>
@@ -230,29 +146,27 @@ https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
![Main Window (Dark)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
![Main Window (Light)](screenshots/main-window-light.png)
<a name="vault-views"></a>
## Vault Views
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
![Vault Grid View](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault List View](screenshots/vault_list_view.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
![Vault Tree View (Light)](screenshots/treeview-light.png)
<a name="split-terminals"></a>
## Split Terminals
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
![Split Windows](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -322,25 +236,6 @@ npm install
npm run dev
```
### Project Structure
```
├── App.tsx # Main React application
├── components/ # React components
│ ├── Terminal.tsx # Terminal component
│ ├── SftpView.tsx # SFTP browser
│ ├── VaultView.tsx # Host management
│ ├── KeyManager.tsx # SSH key management
│ └── ...
├── application/ # State management & i18n
├── domain/ # Domain models & logic
├── infrastructure/ # Services & adapters
├── electron/ # Electron main process
│ ├── main.cjs # Main entry
│ └── bridges/ # IPC bridges
└── public/ # Static assets & icons
```
---
<a name="build--package"></a>
+52 -73
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<strong>🔥 AI 驱动的 SSH 客户端、SFTP 浏览器 & 终端管理器 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<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" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — 你的 IT 运维 AI 搭档
> 🚀 **用 AI 赋能你的日常 IT 运维工作。** Catty Agent 是内置的 AI 助手,能理解你的服务器环境、执行命令,并通过自然对话完成复杂的多主机操作。
### 🔥 Catty Agent 能做什么?
- 🚀 **自然语言管理服务器** —— 直接说出需求,不再需要记忆命令
- 🔥 **实时服务器诊断** —— 通过对话检查状态、查看日志、监控资源
- 🚀 **多主机协同** —— 跨多台服务器同时协调任务
- 🔥 **智能上下文感知** —— 理解你的服务器环境,给出有针对性的回应
- 🚀 **一键完成复杂操作** —— 搭集群、部署服务,一句话搞定
### 🎬 AI 实战演示
#### 🔥 单主机 —— 智能服务器诊断
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 多主机 —— 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" />
---
# 目录 <!-- omit in toc -->
- [🔥 Catty Agent — AI 搭档](#catty-agent)
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [主界面](#主界面)
- [Vault 视图](#vault-视图)
@@ -83,9 +112,9 @@
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
- **以工作区为核心** —— 分屏 + 标签页 + 会话恢复,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合快速搜索与拖拽流程更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传/下载,文件操作更丝滑
---
@@ -110,43 +139,6 @@
---
<a name="演示"></a>
# 演示
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="界面截图"></a>
# 界面截图
@@ -155,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
![主界面(深色)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![主界面(浅色)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
![Vault 网格视图](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault 列表视图](screenshots/vault_list_view.png)
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
![分屏窗口](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -219,6 +207,16 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### Nix / NixOS
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
```bash
nix run github:binaricat/Netcatty
```
声明式安装时,将 Netcatty flake 添加为输入,并在 NixOS 或 Home Manager 的软件包列表中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
### 前置条件
- Node.js 18+ 和 npm
- macOS、Windows 10+ 或 Linux
@@ -237,25 +235,6 @@ npm install
npm run dev
```
### 项目结构
```
├── App.tsx # 主 React 应用
├── components/ # React 组件
│ ├── Terminal.tsx # 终端组件
│ ├── SftpView.tsx # SFTP 浏览器
│ ├── VaultView.tsx # 主机管理
│ ├── KeyManager.tsx # SSH 密钥管理
│ └── ...
├── application/ # 状态管理 & 国际化
├── domain/ # 领域模型 & 逻辑
├── infrastructure/ # 服务 & 适配器
├── electron/ # Electron 主进程
│ ├── main.cjs # 主入口
│ └── bridges/ # IPC 桥接
└── public/ # 静态资源 & 图标
```
---
<a name="构建与打包"></a>
@@ -271,7 +250,7 @@ npm run pack
# 为特定平台打包
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS 安装程序)
npm run pack:linux # Linux (AppImage, deb, rpm)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
+55 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
+19 -1
View File
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
}
}
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
if (!hostId) return;
if (!isVaultInitialized) {
queueConnect(hostId);
return;
}
connectNow(hostId);
}
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
const hostIds = [...pendingHostIds];
setPendingHostIds([]);
hostIds.forEach((hostId: string) => connectNow(hostId));
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
-5
View File
@@ -113,8 +113,3 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}
+14 -1
View File
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
hosts: hostsForSystemMenu,
});
}, 250);
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, sessions, portForwardingRules, workspaces]);
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
+2
View File
@@ -488,6 +488,8 @@ export const enCoreMessages: Messages = {
'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.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',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
+6 -1
View File
@@ -9,7 +9,7 @@ export const enScriptsMessages = {
'scripts.trigger.manual': 'Manual run',
'scripts.trigger.onConnect': 'Run on connect',
'scripts.trigger.onOutput': 'Run on output match',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitForText or nct.screen.waitForRegex in script code.',
'scripts.actions.save': 'Save',
'scripts.actions.runNow': 'Run now',
'scripts.actions.openEditor': 'Open editor',
@@ -69,6 +69,11 @@ export const enScriptsMessages = {
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'OK',
'scripts.dialog.required': 'Required',
'scripts.dialog.numberInvalid': 'Enter a valid number',
'scripts.dialog.numberMin': 'Must be at least {min}',
'scripts.dialog.numberMax': 'Must be at most {max}',
'scripts.dialog.numberStep': 'Must use increments of {step}',
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
'scripts.dialog.retry': 'Retry',
'scripts.dialog.skip': 'Skip',
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
'systemManager.overview.memory': 'Memory',
'systemManager.overview.disk': 'Disk',
'systemManager.overview.network': 'Network',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} cores',
'systemManager.overview.load': 'Load',
'systemManager.overview.uptime': 'Uptime',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
'systemManager.overview.topProcesses': 'Top memory processes',
'systemManager.overview.noData': 'No data',
'systemManager.overview.noDisks': 'No disk data',
'systemManager.overview.noInterfaces': 'No interface data',
'systemManager.overview.noTopProcesses': 'No process data',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
+3
View File
@@ -166,6 +166,9 @@ export const enTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
'systemManager.overview.memory': 'Память',
'systemManager.overview.disk': 'Диск',
'systemManager.overview.network': 'Сеть',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} ядер',
'systemManager.overview.load': 'Нагрузка',
'systemManager.overview.uptime': 'Время работы',
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
'systemManager.overview.duration.minutes': '{{minutes}} мин',
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Задержка',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
'systemManager.overview.topProcesses': 'Процессы по памяти',
'systemManager.overview.noData': 'Нет данных',
'systemManager.overview.noDisks': 'Нет данных дисков',
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
+1
View File
@@ -186,6 +186,7 @@ export const ruTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
+6 -1
View File
@@ -9,7 +9,7 @@ export const zhCNScriptsMessages = {
'scripts.trigger.manual': '手动运行',
'scripts.trigger.onConnect': '连接时运行',
'scripts.trigger.onOutput': '输出匹配时运行',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待普通文本用 nct.screen.waitForText,正则用 nct.screen.waitForRegex。',
'scripts.actions.save': '保存',
'scripts.actions.runNow': '立即运行',
'scripts.actions.openEditor': '弹窗编辑',
@@ -69,6 +69,11 @@ export const zhCNScriptsMessages = {
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
'scripts.dialog.title': '脚本',
'scripts.dialog.ok': '确定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '请输入有效数字',
'scripts.dialog.numberMin': '不能小于 {min}',
'scripts.dialog.numberMax': '不能大于 {max}',
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
'scripts.dialog.waitForTimeoutTitle': '等待超时',
'scripts.dialog.retry': '重试',
'scripts.dialog.skip': '跳过',
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
'systemManager.overview.memory': '内存',
'systemManager.overview.disk': '磁盘',
'systemManager.overview.network': '网络',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核',
'systemManager.overview.load': '负载',
'systemManager.overview.uptime': '运行时间',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': '延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
'systemManager.overview.topProcesses': '内存占用最高进程',
'systemManager.overview.noData': '暂无数据',
'systemManager.overview.noDisks': '暂无磁盘数据',
'systemManager.overview.noInterfaces': '暂无网卡数据',
'systemManager.overview.noTopProcesses': '暂无进程数据',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
@@ -20,6 +20,9 @@ export const zhCNTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -332,6 +335,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
+6 -1
View File
@@ -9,7 +9,7 @@ export const zhTWScriptsMessages = {
'scripts.trigger.manual': '手動執行',
'scripts.trigger.onConnect': '連線時執行',
'scripts.trigger.onOutput': '輸出比對時執行',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待請用 nct.screen.waitFor。',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待一般文字用 nct.screen.waitForText,正規表示式用 nct.screen.waitForRegex。',
'scripts.actions.save': '儲存',
'scripts.actions.runNow': '立即執行',
'scripts.actions.openEditor': '彈出視窗編輯',
@@ -69,6 +69,11 @@ export const zhTWScriptsMessages = {
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
'scripts.dialog.title': '指令碼',
'scripts.dialog.ok': '確定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '請輸入有效數字',
'scripts.dialog.numberMin': '不能小於 {min}',
'scripts.dialog.numberMax': '不能大於 {max}',
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
'scripts.dialog.retry': '重試',
'scripts.dialog.skip': '跳過',
@@ -6,6 +6,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.noSession': '沒有使用中的終端工作階段。',
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
'systemManager.overview.loading': '正在載入系統概覽…',
'systemManager.overview.memory': '記憶體',
'systemManager.overview.disk': '磁碟',
'systemManager.overview.network': '網路',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核心',
'systemManager.overview.load': '負載',
'systemManager.overview.uptime': '執行時間',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
'systemManager.overview.system': '系統',
'systemManager.overview.kernel': '核心',
'systemManager.overview.swap': '交換區',
'systemManager.overview.latency': '延遲',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體占用最高程序',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.processes.search': '搜尋程序…',
'systemManager.processes.command': '指令',
'systemManager.processes.user': '使用者',
@@ -20,6 +20,9 @@ export const zhTWTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
@@ -332,6 +335,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
+41 -1
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
@@ -12,3 +13,42 @@ test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});
test('active tab changes do not start terminal layout suppression', async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
await new Promise((resolve) => setTimeout(resolve, 0));
let activeNotifyCount = 0;
let suppressNotifyCount = 0;
const unsubscribeActiveTab = activeTabStore.subscribe(() => {
activeNotifyCount += 1;
});
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
suppressNotifyCount += 1;
});
try {
activeTabStore.setActiveTabId(`no-suppress-test-${Date.now()}`);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(activeNotifyCount, 1);
assert.equal(suppressNotifyCount, 0);
assert.equal(terminalLayoutSuppressStore.getActive(), false);
} finally {
unsubscribeActiveTab();
unsubscribeSuppress();
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
}
});
-16
View File
@@ -1,7 +1,5 @@
import { useCallback, useSyncExternalStore } from 'react';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
@@ -39,20 +37,11 @@ class ActiveTabStore {
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -78,11 +67,6 @@ export const useActiveTabId = () => {
);
};
// Hook to get setter - never causes re-render
export const useSetActiveTabId = () => {
return activeTabStore.setActiveTabId;
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
-14
View File
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
-18
View File
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
);
};
/**
* Get font loading state
*/
export const useFontsLoading = (): boolean => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading
);
};
/**
* Get font by ID with fallback - useful for components that need a specific font
*/
export const useFontById = (fontId: string): TerminalFont => {
const fonts = useAvailableFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
/**
* Initialize fonts eagerly (call at app startup)
*/
@@ -1,63 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
const commonArgs = {
terminalThemeId: "netcatty-dark",
customThemes: [],
resolvedTheme: "dark" as const,
lightUiThemeId: "snow",
darkUiThemeId: "github",
accentMode: "theme" as const,
customAccent: "",
};
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
pendingFollowAppTerminalThemeId: "system-flexoki-light",
resolvedTheme: "dark",
lightUiThemeId: "flexoki",
darkUiThemeId: "github",
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-flexoki-light");
});
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-github-dark");
});
test("manual terminal theme uses the active per-mode pick", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: false,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "dracula");
});
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
terminalThemeId: "dracula",
followAppTerminalTheme: false,
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
});
assert.equal(theme.id, "dracula");
});
@@ -1,63 +0,0 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
import {
applyCustomAccentToTerminalTheme,
resolveFollowAppTerminalThemeId,
resolveManualTerminalThemeId,
} from '../../domain/terminalAppearance';
interface ResolveCurrentTerminalThemeParams {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
customThemes: TerminalTheme[];
followAppTerminalTheme: boolean;
pendingFollowAppTerminalThemeId?: string | null;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
}
export function resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
pendingFollowAppTerminalThemeId = null,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
if (followAppTerminalTheme) {
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = getBuiltinTerminalThemeById(followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const manualThemeId = resolveManualTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|| customThemes.find(t => t.id === manualThemeId)
|| getBuiltinTerminalThemeById(terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
+63 -7
View File
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
dirCacheTtlMs: number;
}
export type SftpNavigateResult = "reached" | "failed" | "aborted";
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
options?: SftpNavigateOptions,
): Promise<SftpNavigateResult> => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
return "aborted";
}
if (options?.shouldApply?.() === false) {
return "aborted";
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
const previousFilter = confirmed.filter;
const previousError = pane.error;
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
const getTargetPane = () => {
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
};
const shouldApplyNavigationState = () => (
getTargetPane()?.connection?.id === connectionId
&& (options?.shouldApply?.() ?? true)
);
const restoreConfirmedStateIfCurrent = () => {
if (!isTargetRequestCurrent()) return;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
filter: previousFilter,
error: previousError,
loading: false,
};
});
};
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
clearCacheForConnection(pane.connection.id);
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
if (options?.tabId) {
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
if (navSeqRef.current[side] !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
if (!isTargetRequestCurrent()) {
// This tab also has a newer navigation — drop completely.
return "aborted";
return "superseded";
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
dirCacheRef.current.set(cacheKey, {
files,
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
return "reached";
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return "aborted";
if (!isTargetRequestCurrent()) {
return "superseded";
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
let navigationFailed = false;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
@@ -0,0 +1,127 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
isTerminalAutoReconnectEnabled,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "./terminalAutoReconnect";
const sshHost = {
protocol: "ssh" as const,
hostname: "example.com",
};
test("terminal auto reconnect uses a five second retry delay", () => {
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
});
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
});
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: false,
}),
false,
);
});
test("normal shell exits do not auto reconnect", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "exited", exitCode: 0 },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
{ protocol: "telnet" as const, hostname: "router.local" },
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
];
for (const host of variants) {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "error", error: "connection reset" },
host,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("an active auto reconnect loop continues after failed retry attempts", () => {
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
loopActive: true,
}),
true,
);
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: false },
loopActive: true,
}),
false,
);
});
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: true,
isHibernated: false,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: true,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: false,
}),
false,
);
});
@@ -0,0 +1,66 @@
import type { TerminalSettings } from "../../domain/models";
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
type AutoReconnectHost = {
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
hostname?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
};
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
return settings?.sshAutoReconnectEnabled === true;
}
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
const protocol = host.protocol ?? "ssh";
return (
protocol === "ssh" &&
host.hostname !== "localhost" &&
host.moshEnabled !== true &&
host.etEnabled !== true
);
}
export function shouldAutoReconnectAfterExit({
evt,
host,
terminalSettings,
hasEverConnected,
}: {
evt: TerminalSessionExitEvent;
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
hasEverConnected: boolean;
}): boolean {
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive,
}: {
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
loopActive: boolean;
}): boolean {
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
}
export function canAttemptTerminalAutoReconnect({
hasTerminalRuntime,
isHibernated,
}: {
hasTerminalRuntime: boolean;
isHibernated: boolean;
}): boolean {
return hasTerminalRuntime || isHibernated;
}
-18
View File
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
+96 -60
View File
@@ -46,6 +46,79 @@ import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
export function addWorkspaceIfMissing(
workspaces: Workspace[],
workspace: Workspace,
): Workspace[] {
return workspaces.some(ws => ws.id === workspace.id) ? workspaces : [...workspaces, workspace];
}
export function addTerminalSessionIfMissing(
sessions: TerminalSession[],
session: TerminalSession,
): TerminalSession[] {
return sessions.some(candidate => candidate.id === session.id) ? sessions : [...sessions, session];
}
export function insertWorkspacePaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
}
export function appendWorkspaceRootPaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
direction: SplitDirection = 'vertical',
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) {
return ws.focusedSessionId === sessionId ? ws : { ...ws, focusedSessionId: sessionId };
}
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, sessionId, direction),
focusedSessionId: sessionId,
};
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
copiedTabId: string,
allTabIds: string[],
): string[] {
if (prevTabOrder.includes(copiedTabId)) return prevTabOrder;
const directIdx = prevTabOrder.indexOf(sourceTabId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, copiedTabId);
return next;
}
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sourceTabId);
if (sourceIdx === -1) return [...prevTabOrder, copiedTabId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, copiedTabId);
return next;
}
export const useSessionState = ({
persistSessionRestore = true,
@@ -634,14 +707,14 @@ export const useSessionState = ({
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
@@ -668,10 +741,7 @@ export const useSessionState = ({
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return prevWorkspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
@@ -726,16 +796,9 @@ export const useSessionState = ({
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
@@ -778,16 +841,9 @@ export const useSessionState = ({
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
@@ -808,6 +864,13 @@ export const useSessionState = ({
localShellType?: TerminalSession['shellType'];
},
) => {
const newSessionId = crypto.randomUUID();
const standaloneHint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const standaloneWorkspace = createWorkspaceEntity(sessionId, newSessionId, standaloneHint);
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
@@ -816,7 +879,7 @@ export const useSessionState = ({
if (session.workspaceId) {
// Create a new session with the same host
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
id: newSessionId,
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
});
@@ -829,36 +892,27 @@ export const useSessionState = ({
};
setWorkspaces(prevWorkspaces => {
return prevWorkspaces.map(ws => {
if (ws.id !== session.workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
});
return insertWorkspacePaneIfMissing(prevWorkspaces, session.workspaceId, newSession.id, hint);
});
return [...prevSessions, newSession];
return addTerminalSessionIfMissing(prevSessions, newSession);
}
// Session is standalone - create a new workspace
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
id: newSessionId,
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
setWorkspaces(prev => addWorkspaceIfMissing(prev, standaloneWorkspace));
setActiveTabId(standaloneWorkspace.id);
return prevSessions.map(s => {
if (s.id === sessionId) {
return { ...s, workspaceId: newWorkspace.id };
return { ...s, workspaceId: standaloneWorkspace.id };
}
return s;
}).concat({ ...newSession, workspaceId: newWorkspace.id });
}).concat({ ...newSession, workspaceId: standaloneWorkspace.id });
});
}, [setActiveTabId]);
@@ -1024,30 +1078,12 @@ export const useSessionState = ({
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession, Workspace } from '../../domain/models';
import { collectSessionIds, type SplitHint } from '../../domain/workspace';
import {
addTerminalSessionIfMissing,
addWorkspaceIfMissing,
appendWorkspaceRootPaneIfMissing,
insertCopiedTabOrderIdOnce,
insertWorkspacePaneIfMissing,
} from './useSessionState';
const session = (id: string, workspaceId?: string): TerminalSession => ({
id,
hostId: `host-${id}`,
hostLabel: `Host ${id}`,
hostname: `${id}.example.test`,
username: 'user',
status: 'connecting',
workspaceId,
});
const workspace = (id = 'ws-1'): Workspace => ({
id,
title: 'Workspace',
focusedSessionId: 's1',
root: { id: 'pane-1', type: 'pane', sessionId: 's1' },
});
const paneCount = (ws: Workspace, sessionId: string) => (
collectSessionIds(ws.root).filter(id => id === sessionId).length
);
test('workspace creation remains idempotent when the same update runs twice', () => {
const ws = workspace();
const once = addWorkspaceIfMissing([], ws);
const twice = addWorkspaceIfMissing(once, ws);
assert.equal(twice.length, 1);
assert.equal(twice[0], ws);
});
test('terminal session insertion remains idempotent when the same update runs twice', () => {
const newSession = session('s2', 'ws-1');
const once = addTerminalSessionIfMissing([session('s1', 'ws-1')], newSession);
const twice = addTerminalSessionIfMissing(once, newSession);
assert.equal(twice.filter(candidate => candidate.id === 's2').length, 1);
});
test('workspace pane insertion remains idempotent when the same update runs twice', () => {
const hint: SplitHint = {
direction: 'vertical',
position: 'right',
targetSessionId: 's1',
};
const once = insertWorkspacePaneIfMissing([workspace()], 'ws-1', 's2', hint);
const twice = insertWorkspacePaneIfMissing(once, 'ws-1', 's2', hint);
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
assert.equal(paneCount(twice[0], 's2'), 1);
});
test('workspace root append remains idempotent when the same update runs twice', () => {
const once = appendWorkspaceRootPaneIfMissing([workspace()], 'ws-1', 's2', 'vertical');
const twice = appendWorkspaceRootPaneIfMissing(once, 'ws-1', 's2', 'vertical');
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
assert.equal(paneCount(twice[0], 's2'), 1);
assert.equal(twice[0].focusedSessionId, 's2');
});
test('copied tab order insertion remains idempotent when the same update runs twice', () => {
const once = insertCopiedTabOrderIdOnce(['s1', 'ws-1'], 's1', 's2', ['s1', 'ws-1']);
const twice = insertCopiedTabOrderIdOnce(once, 's1', 's2', ['s1', 'ws-1']);
assert.deepEqual(twice, ['s1', 's2', 'ws-1']);
assert.equal(twice.filter(id => id === 's2').length, 1);
});
+1 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import type { Host, TerminalTheme } from '../../domain/models';
import type { TerminalTheme } from '../../domain/models';
import {
idleThemeUserIntent,
pickingThemeUserIntent,
@@ -113,7 +113,3 @@ export function useTerminalAppearanceInjection(
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
}
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
return { host, isEphemeral };
}
@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as storageKeys from "../../infrastructure/config/storageKeys.ts";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "./ymodemFileMemory.ts";
type TestStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const createStore = (initial: Record<string, string> = {}): {
store: TestStore;
values: Map<string, string>;
} => {
const values = new Map(Object.entries(initial));
return {
store: {
readString: (key: string) => values.get(key) ?? null,
writeString: (key: string, value: string) => {
values.set(key, value);
return true;
},
},
values,
};
};
test("uses the remembered YMODEM send directory as the file picker default path", () => {
assert.equal(
storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR,
"netcatty_terminal_ymodem_send_dir_v1",
);
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases");
});
test("preserves valid remembered directory whitespace", () => {
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases ");
});
test("remembers the directory containing a selected YMODEM send file", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases/device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware/releases",
);
});
test("keeps POSIX backslashes as part of the selected file name", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware",
);
});
test("remembers Windows-style YMODEM send directories", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("C:\\Users\\catty\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"C:\\Users\\catty",
);
});
test("ignores blank remembered paths and selected file names without a parent directory", () => {
const { store, values } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: " ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), undefined);
assert.equal(rememberYmodemSendFilePath("device.bin", store), false);
assert.equal(values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR), " ");
});
+38
View File
@@ -0,0 +1,38 @@
import { STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
type YmodemFileMemoryStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const getParentDirectory = (filePath: string): string | null => {
const lastForwardSlash = filePath.lastIndexOf("/");
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
const lastSeparator = isWindowsPath
? Math.max(lastForwardSlash, filePath.lastIndexOf("\\"))
: lastForwardSlash;
if (lastSeparator < 0) return null;
if (lastSeparator === 0) return filePath.slice(0, 1);
if (lastSeparator === 2 && filePath[1] === ":") return filePath.slice(0, 3);
return filePath.slice(0, lastSeparator);
};
export const getRememberedYmodemSendDefaultPath = (
store: YmodemFileMemoryStore = localStorageAdapter,
): string | undefined => {
const rememberedPath = store.readString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR);
if (!rememberedPath || rememberedPath.trim() === "") return undefined;
return rememberedPath;
};
export const rememberYmodemSendFilePath = (
filePath: string,
store: YmodemFileMemoryStore = localStorageAdapter,
): boolean => {
const directory = getParentDirectory(filePath);
if (!directory) return false;
return store.writeString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR, directory);
};
-16
View File
@@ -108,22 +108,6 @@ export function modelPresetsContainId(presets: AgentModelPreset[], modelId: stri
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
// reduce to their basename rather than staying as the full path.
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return sdkBackend === 'claude'
+63 -19
View File
@@ -43,6 +43,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
import {
mergeLatestFollowTerminalCwdHostSetting,
resolveHostFollowTerminalCwd,
shouldApplyFollowTerminalCwdSyncResult,
shouldClearBlockedFollowOnReach,
shouldFollowTerminalCwdNavigate,
type SftpFollowTerminalCwdBlock,
@@ -778,25 +779,32 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const followSyncGenerationRef = useRef(0);
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
const activeTerminalCwdRef = useRef(activeTerminalCwd);
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionIdRef = useRef(connectionId);
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
activeTerminalCwdRef.current = activeTerminalCwd;
connectionIdRef.current = connectionId;
const invalidateInFlightFollowSync = useCallback(() => {
followSyncGenerationRef.current += 1;
blockedFollowRef.current = null;
handledFollowRef.current = null;
}, []);
useEffect(() => {
blockedFollowRef.current = null;
invalidateInFlightFollowSync();
}, [
activeTerminalCwd,
followTerminalCwdHost?.id,
connectionId,
invalidateInFlightFollowSync,
]);
useEffect(() => {
@@ -805,15 +813,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
useEffect(() => {
const blockedFollow = blockedFollowRef.current;
if (
shouldClearBlockedFollowOnReach(
blockedFollowRef.current,
blockedFollow,
connectionId,
connectionPath,
sftp.leftPane.loading,
)
) {
blockedFollowRef.current = null;
handledFollowRef.current = blockedFollow;
}
}, [connectionId, connectionPath, sftp.leftPane.loading]);
@@ -824,6 +834,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
if (navigateResult === "reached") {
blockedFollowRef.current = null;
const connection = sftpRef.current.leftPane.connection;
if (connection?.id) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
}
}
}, [onGetTerminalCwd, sftpRef]);
@@ -834,16 +848,18 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const syncGeneration = followSyncGenerationRef.current;
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
let terminalCwd = activeTerminalCwd;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
}
if (!terminalCwd) return;
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -857,16 +873,43 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
blockedFollow: blockedFollowRef.current,
handledFollow: handledFollowRef.current,
})) {
if (
connection?.id
&& !connection.isLocal
&& connection.status === "connected"
&& connection.currentPath === terminalCwd
) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
}
return;
}
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd);
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
const expectedConnectionId = connection?.id ?? null;
const shouldApplyCurrentFollowSync = () => (
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
expectedConnectionId,
liveConnectionId: connectionIdRef.current,
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
expectedTerminalCwd: terminalCwd,
liveTerminalCwd: activeTerminalCwdRef.current,
requireLiveTerminalCwd: usesLiveTerminalCwd,
})
);
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
shouldApply: shouldApplyCurrentFollowSync,
});
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -877,8 +920,11 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
if (navigateResult === "failed" && currentConnection.id) {
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "superseded" && currentConnection.id) {
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "reached") {
blockedFollowRef.current = null;
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
}
}, [
activeTerminalCwd,
@@ -892,9 +938,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const handleToggleFollowTerminalCwd = useCallback(() => {
const nextEnabled = !effectiveFollowTerminalCwd;
if (!nextEnabled) {
invalidateInFlightFollowSync();
}
invalidateInFlightFollowSync();
if (followTerminalCwdHost?.id) {
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
}
@@ -910,7 +954,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
effectiveFollowTerminalCwd,
hasActiveWork,
isVisible,
sftp.leftPane.connection?.currentPath,
connectionId,
sftp.leftPane.connection?.status,
sftp.leftPane.connection?.isLocal,
syncFollowToTerminalCwd,
+192 -26
View File
@@ -45,6 +45,12 @@ import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "../application/state/terminalAutoReconnect";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
@@ -126,6 +132,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
@@ -363,6 +370,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoReconnectLoopActiveRef = useRef(false);
const autoReconnectAttemptRef = useRef(0);
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
@@ -560,6 +572,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
getInitialTerminalStatus()
));
const hasEverConnectedRef = useRef(status === "connected");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
@@ -999,14 +1012,74 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const resolvedChainHosts =
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
if (autoReconnectTimerRef.current) {
clearTimeout(autoReconnectTimerRef.current);
autoReconnectTimerRef.current = null;
}
if (options?.stopLoop !== false) {
autoReconnectLoopActiveRef.current = false;
autoReconnectAttemptRef.current = 0;
}
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
if (next === "connected") {
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
onStatusChange?.(sessionId, next);
};
}, [clearAutoReconnect, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
const shouldSchedule = trigger?.evt
? shouldAutoReconnectAfterExit({
evt: trigger.evt,
host,
terminalSettings,
hasEverConnected: hasEverConnectedRef.current,
})
: shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive: autoReconnectLoopActiveRef.current,
});
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
hasTerminalRuntime: Boolean(termRef.current),
isHibernated: hibernatedRef.current,
})) {
return false;
}
autoReconnectLoopActiveRef.current = true;
if (autoReconnectTimerRef.current) {
return true;
}
autoReconnectAttemptRef.current += 1;
const attempt = autoReconnectAttemptRef.current;
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
setError(null);
setShowLogs(true);
setIsDisconnectedDialogDismissed(false);
setProgressLogs((prev) => [...prev, scheduledMessage]);
updateStatus("connecting");
autoReconnectTimerRef.current = setTimeout(() => {
autoReconnectTimerRef.current = null;
startReconnectRef.current?.("auto");
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
return true;
}, [host, t, terminalSettings, updateStatus]);
const prepareRestoredReconnect = useCallback(() => {
if (restoreState !== "restored-disconnected") {
suppressHostStartupCommandRef.current = false;
@@ -1092,6 +1165,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasRuntimeRef.current = false;
};
const clearHibernateRuntimeState = useCallback(() => {
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [sessionId]);
const forceCloseHibernatedSession = useCallback(() => {
if (!terminalDataCapturedRef.current) {
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
@@ -1117,18 +1204,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}
sessionRef.current = null;
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
clearHibernateRuntimeState();
}, [clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
disposeDataRef.current?.();
@@ -1162,8 +1239,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
exitMessage,
);
onSessionExit?.(sessionId, evt);
scheduleAutoReconnect({ evt });
});
}, [onSessionExit, sessionId, terminalBackend]);
}, [onSessionExit, scheduleAutoReconnect, sessionId, terminalBackend]);
const applyHibernateSnapshot = useCallback((
snapshot: {
@@ -1282,6 +1360,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = false;
clearAutoReconnect();
cleanupSession();
disposeRuntimeOnly();
};
@@ -1371,6 +1450,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
scheduleAutoReconnect({ evt });
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: onTerminalOutput
@@ -1400,12 +1480,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionStartersRef.current = sessionStarters;
useEffect(() => {
if (status === 'disconnected') {
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
}
}, [status]);
useEffect(() => {
if (status !== "disconnected") return;
scheduleAutoReconnect();
}, [scheduleAutoReconnect, status]);
useEffect(() => {
if (!autoReconnectLoopActiveRef.current) return;
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
clearAutoReconnect();
if (hadPendingReconnect) {
updateStatus("disconnected");
}
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
useEffect(() => {
pendingScriptRunIdRef.current = null;
pendingScriptHandledRef.current = null;
@@ -1623,10 +1718,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const safeFit = (options?: { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) return;
if (!isVisibleRef.current && !options?.allowHidden) {
lastFittedSizeRef.current = null;
return;
}
const container = containerRef.current;
if (!container) return;
@@ -1696,7 +1794,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
typeof requestAnimationFrame === "function"
typeof requestAnimationFrame === "function" &&
!options?.immediate
) {
requestAnimationFrame(runFit);
} else {
@@ -1889,12 +1988,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
try {
const defaultPath = getRememberedYmodemSendDefaultPath();
const filePath = await selectFile(
t("terminal.ymodem.selectFile"),
undefined,
defaultPath,
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
);
if (!filePath) return;
rememberYmodemSendFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop() || filePath;
toast.info(t("terminal.ymodem.started", { fileName }));
@@ -1956,6 +2057,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
setIsCancelling(true);
@@ -1975,9 +2077,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
queueMicrotask(() => termRef.current?.focus());
};
const handleCloseDisconnectedSession = () => {
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
onCloseSession?.(sessionId);
@@ -2019,9 +2123,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
const startReconnect = (mode: "manual" | "auto" = "manual") => {
if (!termRef.current && mode === "auto" && hibernatedRef.current) {
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
if (!wakeForReconnect) {
updateStatus("disconnected");
return;
}
void wakeForReconnect().then((woke) => {
if (woke) {
startReconnectRef.current?.("auto");
return;
}
updateStatus("disconnected");
});
return;
}
if (!termRef.current) return;
prepareRestoredReconnect();
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = true;
}
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
@@ -2035,12 +2160,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
if (mode === "manual") {
hasRunStartupCommandRef.current = false;
}
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
updateStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setProgressLogs((prev) => (
mode === "auto"
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
: ["Retrying secure channel..."]
));
setShowLogs(true);
const startNewSession = () => {
@@ -2090,6 +2221,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
);
});
};
startReconnectRef.current = startReconnect;
const handleRetry = () => {
startReconnect("manual");
};
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
@@ -2453,6 +2589,36 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
wakeHibernatedRuntimeForReconnectRef.current = async () => {
if (!hibernatedRef.current) {
return Boolean(termRef.current);
}
const getPayload = (): TerminalHibernateWakePayload => ({
snapshot: hibernateSnapshotRef.current,
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
pendingBuffer: hibernatePendingBufferRef.current,
alternateScreen: hibernateAlternateScreenRef.current,
});
logger.info("[Terminal] Waking hibernated runtime for auto reconnect", {
sessionId,
snapshotChars: hibernateSnapshotRef.current.length,
viewportChars: hibernateViewportSnapshotRef.current.length,
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
pendingChars: hibernatePendingBufferRef.current.length,
});
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected: false }));
if (accepted === false || !termRef.current) {
return false;
}
clearHibernateRuntimeState();
return true;
};
useTerminalHibernateEffect({
sessionId,
isVisible,
@@ -2483,11 +2649,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, 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, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, 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, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+31 -3
View File
@@ -32,12 +32,15 @@ const {
} = await import("../application/state/terminalDragData.ts");
const {
activateLogViewTab,
createSessionTopTabDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
stopCloseButtonDoubleClickPropagation,
} = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -108,14 +111,39 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createSessionTopTabDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
);
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
stopCloseButtonDoubleClickPropagation({
stopPropagation: () => {
stopPropagationCount += 1;
},
});
assert.equal(stopPropagationCount, 1);
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
-23
View File
@@ -46,29 +46,6 @@ const HOST_ICON_COMPONENTS = {
"server-cog": ServerCog,
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
server: "hostDetails.icon.option.server",
terminal: "hostDetails.icon.option.terminal",
database: "hostDetails.icon.option.database",
cloud: "hostDetails.icon.option.cloud",
router: "hostDetails.icon.option.router",
shield: "hostDetails.icon.option.shield",
code: "hostDetails.icon.option.code",
box: "hostDetails.icon.option.box",
globe: "hostDetails.icon.option.globe",
cpu: "hostDetails.icon.option.cpu",
"hard-drive": "hostDetails.icon.option.hard-drive",
network: "hostDetails.icon.option.network",
wifi: "hostDetails.icon.option.wifi",
lock: "hostDetails.icon.option.lock",
key: "hostDetails.icon.option.key",
monitor: "hostDetails.icon.option.monitor",
container: "hostDetails.icon.option.container",
activity: "hostDetails.icon.option.activity",
zap: "hostDetails.icon.option.zap",
"server-cog": "hostDetails.icon.option.server-cog",
};
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
return <Icon className={className} />;
-25
View File
@@ -1,25 +0,0 @@
import React from "react";
import { LazyMessageResponse } from "../ai-elements/LazyMessageResponse";
export function MarkdownPreview({
content,
emptyText,
}: {
content: string;
emptyText: string;
}) {
const trimmed = content.trim();
if (!trimmed) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyText}
</div>
);
}
return (
<LazyMessageResponse className="text-sm leading-relaxed text-foreground/90">
{trimmed}
</LazyMessageResponse>
);
}
@@ -0,0 +1,529 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ScriptDialogRequest } from "../../types/global/netcatty-bridge-script.d.ts";
import {
applyFormValue,
getDialogFieldDomId,
getInitialFormValues,
getVisibleDialogFormFields,
normalizeDialogFormSubmitValues,
ScriptDialogFormBody,
ScriptDialogFormFields,
validateDialogFormValues,
} from "./ScriptDialogHost.tsx";
const formRequest: ScriptDialogRequest = {
requestId: "dialog-1",
type: "form",
message: "Choose options",
form: {
title: "Deploy",
message: "Choose options",
fields: [
{
type: "select",
name: "env",
label: "Environment",
options: [
{ label: "Development", value: "dev" },
{ label: "Production", value: "prod", description: "Use carefully" },
],
defaultValue: "dev",
},
{
type: "checkbox",
name: "restart",
label: "Restart service",
defaultValue: true,
},
{
type: "radio",
name: "mode",
label: "Mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea",
name: "notes",
label: "Notes",
defaultValue: "initial note",
required: false,
},
{
type: "number",
name: "retries",
label: "Retries",
defaultValue: 3,
min: 0,
step: 1,
},
],
},
};
test("script dialog form derives initial values from fields", () => {
assert.deepEqual(getInitialFormValues(formRequest), {
env: "dev",
restart: true,
mode: "safe",
notes: "initial note",
retries: 3,
});
});
test("script dialog form value helper preserves previous values for submit payload", () => {
const initial = getInitialFormValues(formRequest);
const withEnv = applyFormValue(initial, "env", "prod");
const withRestart = applyFormValue(withEnv, "restart", false);
const withMode = applyFormValue(withRestart, "mode", "fast");
const withNotes = applyFormValue(withMode, "notes", "ship it");
const submitted = normalizeDialogFormSubmitValues(
formRequest.form!,
applyFormValue(withNotes, "retries", "5"),
);
assert.deepEqual(submitted, {
env: "prod",
restart: false,
mode: "fast",
notes: "ship it",
retries: 5,
});
});
test("script dialog form validates required text and number fields", () => {
const emptyRequiredRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
...formRequest.form!.fields,
{ type: "textarea", name: "requiredNotes", label: "Required notes", defaultValue: "" },
{ type: "number", name: "requiredCount", label: "Required count" },
],
},
};
const values = getInitialFormValues(emptyRequiredRequest);
assert.deepEqual(validateDialogFormValues(emptyRequiredRequest.form!, values, "Required"), {
requiredNotes: "Required",
requiredCount: "Required",
});
});
test("script dialog form only requires checkboxes when explicitly marked required", () => {
const form = {
message: "Confirm",
fields: [
{
type: "checkbox" as const,
name: "optionalFlag",
label: "Optional flag",
defaultValue: false,
},
{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
defaultValue: false,
required: true,
},
],
};
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: false,
}, "Required"), {
confirmDanger: "Required",
});
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: true,
}, "Required"), {});
});
test("script dialog form validates number min max and step before submit", () => {
const form = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "delayMs",
label: "Delay",
defaultValue: 500,
min: 0,
max: 5000,
step: 100,
required: false,
}],
};
const messages = {
required: "Required",
numberInvalid: "Invalid",
numberMin: (min: number) => `Min ${min}`,
numberMax: (max: number) => `Max ${max}`,
numberStep: (step: number) => `Step ${step}`,
};
assert.deepEqual(validateDialogFormValues(form, { delayMs: -1 }, messages), {
delayMs: "Min 0",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5001 }, messages), {
delayMs: "Max 5000",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 550 }, messages), {
delayMs: "Step 100",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: "" }, messages), {});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5000 }, messages), {});
const defaultBasedForm = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "oddCount",
label: "Odd count",
defaultValue: 5,
step: 2,
}],
};
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 5 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 7 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 6 }, messages), {
oddCount: "Step 2",
});
});
test("script dialog form applies visibleWhen to rendering validation and submit payload", () => {
const conditionalRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "checkbox",
name: "confirmRemote",
label: "Confirm remote",
defaultValue: false,
visibleWhen: { field: "target", notEquals: "local" },
},
{
type: "textarea",
name: "localNote",
label: "Local note",
defaultValue: "local only",
required: false,
visibleWhen: { field: "target", equals: "local" },
},
{
type: "textarea",
name: "remoteDetail",
label: "Remote detail",
defaultValue: "hidden by hidden controller",
required: false,
visibleWhen: { field: "confirmRemote", truthy: true },
},
],
},
};
const localValues = getInitialFormValues(conditionalRequest);
const localVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, localValues).map((field) => field.name);
assert.deepEqual(localVisibleNames, ["target", "localNote"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, localValues, "Required"), {});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, localValues), {
target: "local",
localNote: "local only",
});
const remoteValues = applyFormValue(applyFormValue(localValues, "target", "remote"), "confirmRemote", true);
const remoteVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, remoteValues).map((field) => field.name);
assert.deepEqual(remoteVisibleNames, ["target", "host", "confirmRemote", "remoteDetail"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, remoteValues, "Required"), {
host: "Required",
});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, applyFormValue(remoteValues, "host", "example.com")), {
target: "remote",
host: "example.com",
confirmRemote: true,
remoteDetail: "hidden by hidden controller",
});
});
test("script dialog form does not show fields chained from hidden controllers", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "checkbox",
name: "advanced",
label: "Advanced",
defaultValue: true,
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "textarea",
name: "advancedNote",
label: "Advanced note",
defaultValue: "should stay hidden",
visibleWhen: { field: "advanced", truthy: true },
},
],
},
};
assert.deepEqual(
getVisibleDialogFormFields(request.form!, getInitialFormValues(request)).map((field) => field.name),
["target"],
);
assert.deepEqual(normalizeDialogFormSubmitValues(request.form!, getInitialFormValues(request)), {
target: "local",
});
});
test("script dialog form fields render select checkbox radio textarea and number controls", () => {
const values = applyFormValue(getInitialFormValues(formRequest), "env", "prod");
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Environment/);
assert.match(markup, /Use carefully/);
assert.match(markup, /id="script-dialog-env-label"/);
assert.match(markup, /aria-labelledby="script-dialog-env-label"/);
assert.match(markup, /Restart service/);
assert.match(markup, /type="checkbox"[^>]*checked=""/);
assert.match(markup, /type="radio"[^>]*checked=""[^>]*value="safe"/);
assert.match(markup, /type="radio"[^>]*value="fast"/);
assert.match(markup, /<textarea[^>]*>initial note<\/textarea>/);
assert.match(markup, /type="number"[^>]*min="0"[^>]*step="1"[^>]*value="3"/);
});
test("script dialog form fields do not render hidden visibleWhen controls", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
],
},
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={request.form!}
formValues={getInitialFormValues(request)}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Target/);
assert.doesNotMatch(markup, /Remote host/);
});
test("script dialog form body renders fields inside a constrained scroll area", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormBody
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /data-radix-scroll-area-viewport/);
assert.match(markup, /min-h-0/);
assert.match(markup, /Environment/);
});
test("script dialog form fields render required errors", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
formErrors={{ retries: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /aria-invalid="true"/);
assert.match(markup, /id="script-dialog-retries-error"/);
assert.match(markup, /aria-describedby="script-dialog-retries-error"/);
assert.match(markup, /Required/);
});
test("script dialog form fields associate checkbox errors with the input", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ confirmDanger: false }}
formErrors={{ confirmDanger: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-confirmDanger-description"/);
assert.match(markup, /id="script-dialog-confirmDanger-error"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirmDanger-description script-dialog-confirmDanger-error"/,
);
assert.match(markup, /aria-invalid="true"/);
});
test("script dialog form field DOM ids encode names with spaces", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirm danger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ "confirm danger": false }}
formErrors={{ "confirm danger": "Required" }}
onValueChange={() => {}}
/>,
);
assert.equal(getDialogFieldDomId("confirm danger"), "script-dialog-confirm%20danger");
assert.match(markup, /id="script-dialog-confirm%20danger"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirm%20danger-description script-dialog-confirm%20danger-error"/,
);
assert.doesNotMatch(markup, /script-dialog-confirm danger/);
});
test("script dialog form control ids encode spaced names for radio text and number fields", () => {
const form = {
message: "Spaced names",
fields: [
{
type: "radio" as const,
name: "run mode",
label: "Run mode",
description: "Choose mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea" as const,
name: "release notes",
label: "Release notes",
defaultValue: "",
},
{
type: "number" as const,
name: "retry count",
label: "Retry count",
defaultValue: 3,
},
],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{
"run mode": "safe",
"release notes": "",
"retry count": 3,
}}
formErrors={{
"run mode": "Required",
"release notes": "Required",
"retry count": "Required",
}}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-run%20mode-0"/);
assert.match(markup, /name="script-dialog-run%20mode"/);
assert.match(markup, /id="script-dialog-release%20notes"/);
assert.match(markup, /for="script-dialog-release%20notes"/);
assert.match(markup, /id="script-dialog-retry%20count"/);
assert.match(markup, /for="script-dialog-retry%20count"/);
assert.doesNotMatch(markup, /script-dialog-run mode/);
assert.doesNotMatch(markup, /script-dialog-release notes/);
assert.doesNotMatch(markup, /script-dialog-retry count/);
});
+467 -8
View File
@@ -10,18 +10,432 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type { ScriptDialogRequest } from '@/types/global/netcatty-bridge-script.d.ts';
import type {
ScriptDialogCondition,
ScriptDialogField,
ScriptDialogForm,
ScriptDialogFormValue,
ScriptDialogRequest,
} from '@/types/global/netcatty-bridge-script.d.ts';
type FormValues = Record<string, ScriptDialogFormValue>;
type FormErrors = Record<string, string>;
type FormValidationMessages = string | {
required: string;
numberInvalid: string;
numberMin: (min: number) => string;
numberMax: (max: number) => string;
numberStep: (step: number) => string;
};
export function getInitialFormValues(request: ScriptDialogRequest): FormValues {
if (request.type !== 'form' || !request.form) return {};
return Object.fromEntries(
request.form.fields.map((field) => [field.name, field.defaultValue]),
);
}
export function applyFormValue(values: FormValues, name: string, value: ScriptDialogFormValue): FormValues {
return { ...values, [name]: value };
}
export function getDialogFieldDomId(name: string): string {
return `script-dialog-${encodeURIComponent(name || 'field')}`;
}
function resolveValidationMessages(messages: FormValidationMessages = 'Required') {
if (typeof messages === 'string') {
return {
required: messages,
numberInvalid: messages,
numberMin: () => messages,
numberMax: () => messages,
numberStep: () => messages,
};
}
return messages;
}
function matchesNumberStep(value: number, step: number, base = 0) {
const quotient = (value - base) / step;
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
}
function getNumberStepBase(field: Extract<ScriptDialogField, { type: 'number' }>) {
return field.min ?? field.defaultValue ?? 0;
}
function getConditionFieldValue(
form: ScriptDialogForm,
values: FormValues,
fieldName: string,
): ScriptDialogFormValue {
const field = form.fields.find((candidate) => candidate.name === fieldName);
const value = values[fieldName];
if (field?.type === 'number') {
if (value === undefined || value === '') return undefined;
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (field?.type === 'checkbox') {
return Boolean(value);
}
return value;
}
export function evaluateDialogCondition(
form: ScriptDialogForm,
values: FormValues,
condition: ScriptDialogCondition,
): boolean {
const value = getConditionFieldValue(form, values, condition.field);
if ('equals' in condition) {
return value === condition.equals;
}
if ('notEquals' in condition) {
return value !== condition.notEquals;
}
if ('truthy' in condition) {
return Boolean(value);
}
return !value;
}
export function isDialogFieldVisible(
form: ScriptDialogForm,
field: ScriptDialogField,
values: FormValues,
): boolean {
return getVisibleDialogFormFields(form, values).some((visibleField) => visibleField.name === field.name);
}
export function getVisibleDialogFormFields(form: ScriptDialogForm, values: FormValues): ScriptDialogField[] {
const visibleFields: ScriptDialogField[] = [];
const visibleNames = new Set<string>();
for (const field of form.fields) {
if (!field.visibleWhen) {
visibleFields.push(field);
visibleNames.add(field.name);
continue;
}
if (!visibleNames.has(field.visibleWhen.field)) {
continue;
}
if (evaluateDialogCondition(form, values, field.visibleWhen)) {
visibleFields.push(field);
visibleNames.add(field.name);
}
}
return visibleFields;
}
export function validateDialogFormValues(
form: ScriptDialogForm,
values: FormValues,
messages: FormValidationMessages = 'Required',
): FormErrors {
const validationMessages = resolveValidationMessages(messages);
const errors: FormErrors = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'checkbox') {
if (field.required === true && !value) {
errors[field.name] = validationMessages.required;
}
continue;
}
if (field.type === 'number') {
const isEmpty = value === undefined || value === '';
if (isEmpty) {
if (field.required !== false) {
errors[field.name] = validationMessages.required;
}
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) {
errors[field.name] = validationMessages.numberInvalid;
continue;
}
if (field.min !== undefined && numberValue < field.min) {
errors[field.name] = validationMessages.numberMin(field.min);
continue;
}
if (field.max !== undefined && numberValue > field.max) {
errors[field.name] = validationMessages.numberMax(field.max);
continue;
}
if (field.step !== undefined && !matchesNumberStep(numberValue, field.step, getNumberStepBase(field))) {
errors[field.name] = validationMessages.numberStep(field.step);
}
continue;
}
if (field.required === false) continue;
if (value === undefined || String(value).trim() === '') {
errors[field.name] = validationMessages.required;
}
}
return errors;
}
export function normalizeDialogFormSubmitValues(form: ScriptDialogForm, values: FormValues): FormValues {
const next: FormValues = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'number') {
if (value === undefined || value === '') {
next[field.name] = undefined;
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
next[field.name] = Number.isFinite(numberValue) ? numberValue : undefined;
continue;
}
next[field.name] = value;
}
return next;
}
export function ScriptDialogFormFields({
form,
formValues,
formErrors = {},
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
const renderFormField = (field: ScriptDialogField) => {
const inputId = getDialogFieldDomId(field.name);
const fieldError = formErrors[field.name];
const descriptionId = field.description ? `${inputId}-description` : undefined;
const errorId = fieldError ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const fieldDescription = field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
) : null;
const inlineDescription = field.description ? (
<span id={descriptionId} className="mt-1 block text-xs text-muted-foreground">{field.description}</span>
) : null;
const errorMessage = fieldError ? (
<p id={errorId} className="text-xs text-destructive">{fieldError}</p>
) : null;
const inlineErrorMessage = fieldError ? (
<span id={errorId} className="mt-1 block text-xs text-destructive">{fieldError}</span>
) : null;
if (field.type === 'select') {
const labelId = `${inputId}-label`;
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
const selectedOption = field.options.find((option) => option.value === selectedValue);
const selectedDescriptionId = selectedOption?.description ? `${inputId}-selected-description` : undefined;
const selectDescribedBy = [describedBy, selectedDescriptionId].filter(Boolean).join(' ') || undefined;
return (
<div key={field.name} className="space-y-2">
<Label id={labelId} htmlFor={inputId}>{field.label}</Label>
<Select
value={selectedValue}
onValueChange={(value) => onValueChange(field.name, value)}
>
<SelectTrigger
id={inputId}
aria-labelledby={labelId}
aria-describedby={selectDescribedBy}
aria-invalid={fieldError ? true : undefined}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
textValue={option.label}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption?.description ? (
<p id={selectedDescriptionId} className="text-xs text-muted-foreground">{selectedOption.description}</p>
) : null}
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'radio') {
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
return (
<fieldset
key={field.name}
className="space-y-2"
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
>
<legend className="text-sm font-medium leading-none">{field.label}</legend>
{fieldDescription}
<div className="space-y-2">
{field.options.map((option, index) => {
const optionInputId = `${inputId}-${index}`;
return (
<label
key={option.value}
htmlFor={optionInputId}
className="flex items-start gap-2 rounded-md border border-border/60 px-3 py-2 text-sm"
>
<input
id={optionInputId}
type="radio"
name={inputId}
value={option.value}
checked={selectedValue === option.value}
disabled={option.disabled}
onChange={(event) => onValueChange(field.name, event.target.value)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block">{option.label}</span>
{option.description ? (
<span className="block text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
{errorMessage}
</fieldset>
);
}
if (field.type === 'textarea') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Textarea
id={inputId}
value={String(formValues[field.name] ?? field.defaultValue)}
placeholder={field.placeholder}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'number') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Input
id={inputId}
type="number"
value={formValues[field.name] ?? field.defaultValue ?? ''}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
return (
<div key={field.name} className="space-y-2">
<label htmlFor={inputId} className="flex items-start gap-2 text-sm">
<input
id={inputId}
type="checkbox"
checked={Boolean(formValues[field.name] ?? field.defaultValue)}
onChange={(event) => onValueChange(field.name, event.target.checked)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block font-medium leading-none">{field.label}</span>
{inlineDescription}
{inlineErrorMessage}
</span>
</label>
</div>
);
};
return (
<div className="space-y-4">
{getVisibleDialogFormFields(form, formValues).map(renderFormField)}
</div>
);
}
export function ScriptDialogFormBody({
form,
formValues,
formErrors,
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
return (
<ScrollArea className="min-h-0 pr-3">
<ScriptDialogFormFields
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={onValueChange}
/>
</ScrollArea>
);
}
export function ScriptDialogHost() {
const { t } = useI18n();
const [request, setRequest] = useState<ScriptDialogRequest | null>(null);
const [promptValue, setPromptValue] = useState('');
const [formValues, setFormValues] = useState<FormValues>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
useEffect(() => {
const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => {
setRequest(payload);
setPromptValue(payload.defaultValue ?? '');
setFormValues(getInitialFormValues(payload));
setFormErrors({});
});
return dispose;
}, []);
@@ -34,6 +448,38 @@ export function ScriptDialogHost() {
if (!request) return null;
const form = request.type === 'form' ? request.form : undefined;
const dialogTitle = request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: form?.title || t('scripts.dialog.title');
const message = form?.message ?? request.message;
const setFormValue = (name: string, value: ScriptDialogFormValue) => {
setFormValues((current) => applyFormValue(current, name, value));
setFormErrors((current) => {
if (!current[name]) return current;
const { [name]: _removed, ...rest } = current;
return rest;
});
};
const submitForm = () => {
if (!form) return;
const errors = validateDialogFormValues(form, formValues, {
required: t('scripts.dialog.required'),
numberInvalid: t('scripts.dialog.numberInvalid'),
numberMin: (min) => t('scripts.dialog.numberMin', { min }),
numberMax: (max) => t('scripts.dialog.numberMax', { max }),
numberStep: (step) => t('scripts.dialog.numberStep', { step }),
});
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
const submitValues = normalizeDialogFormSubmitValues(form, formValues);
void respond(submitValues);
};
return (
<Dialog open onOpenChange={(open) => {
if (!open) {
@@ -41,14 +487,10 @@ export function ScriptDialogHost() {
}
}}
>
<DialogContent>
<DialogContent className={form ? 'max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden' : undefined}>
<DialogHeader>
<DialogTitle>
{request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: t('scripts.dialog.title')}
</DialogTitle>
<DialogDescription>{request.message}</DialogDescription>
<DialogTitle>{dialogTitle}</DialogTitle>
{message ? <DialogDescription>{message}</DialogDescription> : null}
</DialogHeader>
{request.type === 'prompt' ? (
<Input
@@ -57,6 +499,14 @@ export function ScriptDialogHost() {
autoFocus
/>
) : null}
{form ? (
<ScriptDialogFormBody
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={setFormValue}
/>
) : null}
<DialogFooter>
{request.type === 'waitForTimeout' ? (
<>
@@ -80,6 +530,15 @@ export function ScriptDialogHost() {
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'form' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>
{form?.cancelLabel || t('common.cancel')}
</Button>
<Button onClick={submitForm}>
{form?.submitLabel || t('scripts.dialog.ok')}
</Button>
</>
) : (
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
)}
@@ -18,3 +18,9 @@ test("terminal settings expose host key verification toggle", () => {
assert.match(source, /checked=\{terminalSettings\.verifyHostKeys\}/);
assert.match(source, /updateTerminalSetting\("verifyHostKeys", v\)/);
});
test("terminal settings expose SSH auto reconnect toggle", () => {
assert.match(source, /settings\.terminal\.connection\.sshAutoReconnectEnabled/);
assert.match(source, /checked=\{terminalSettings\.sshAutoReconnectEnabled\}/);
assert.match(source, /updateTerminalSetting\("sshAutoReconnectEnabled", v\)/);
});
@@ -829,6 +829,15 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
description={t("settings.terminal.connection.sshAutoReconnectEnabled.desc")}
>
<Toggle
checked={terminalSettings.sshAutoReconnectEnabled}
onChange={(v) => updateTerminalSetting("sshAutoReconnectEnabled", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.connection.keepaliveInterval")}
description={t("settings.terminal.connection.keepaliveInterval.desc")}
@@ -1,9 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
mergeLatestFollowTerminalCwdHostSetting,
resolveHostFollowTerminalCwd,
resolveSftpFollowTerminalCwdTargetHost,
shouldApplyFollowTerminalCwdSyncResult,
shouldClearBlockedFollowOnReach,
shouldFollowTerminalCwdNavigate,
} from "./sftpFollowTerminalCwd";
@@ -18,6 +20,10 @@ const base = {
isConnected: true,
};
const readComponentSource = (relativePath: string) => (
readFileSync(new URL(relativePath, import.meta.url), "utf8")
);
test("shouldFollowTerminalCwdNavigate returns true when follow is on and paths differ", () => {
assert.equal(shouldFollowTerminalCwdNavigate(base), true);
});
@@ -73,6 +79,29 @@ test("shouldFollowTerminalCwdNavigate ignores blocked cwd when terminal cwd chan
);
});
test("shouldFollowTerminalCwdNavigate does not recapture manual navigation after the cwd was handled", () => {
assert.equal(
shouldFollowTerminalCwdNavigate({
...base,
currentPath: "/srv/bookmark",
handledFollow: { connectionId: "conn-1", terminalCwd: "/home/user/project" },
}),
false,
);
});
test("shouldFollowTerminalCwdNavigate resumes when the terminal cwd changes", () => {
assert.equal(
shouldFollowTerminalCwdNavigate({
...base,
terminalCwd: "/home/user/other",
currentPath: "/srv/bookmark",
handledFollow: { connectionId: "conn-1", terminalCwd: "/home/user/project" },
}),
true,
);
});
test("resolveHostFollowTerminalCwd inherits the global setting until the host overrides it", () => {
assert.equal(resolveHostFollowTerminalCwd(undefined, true), true);
assert.equal(resolveHostFollowTerminalCwd(undefined, false), false);
@@ -189,3 +218,150 @@ test("shouldClearBlockedFollowOnReach keeps block while navigation is still load
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult rejects stale follow results after cwd changes", () => {
let generation = 0;
const followA = generation;
generation += 1;
const followB = generation;
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: followB,
currentGeneration: generation,
followEnabled: true,
canFollow: true,
}),
true,
);
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: followA,
currentGeneration: generation,
followEnabled: true,
canFollow: true,
}),
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult rejects results after follow is unavailable", () => {
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: false,
canFollow: true,
}),
false,
);
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: true,
canFollow: false,
}),
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult rejects results for an old connection", () => {
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: true,
canFollow: true,
expectedConnectionId: "conn-1",
liveConnectionId: "conn-2",
paneConnectionId: "conn-2",
}),
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult rejects results for an old terminal cwd", () => {
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: true,
canFollow: true,
expectedConnectionId: "conn-1",
liveConnectionId: "conn-1",
paneConnectionId: "conn-1",
expectedTerminalCwd: "/srv/old",
liveTerminalCwd: "/srv/new",
}),
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult rejects missing live cwd when required", () => {
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: true,
canFollow: true,
expectedConnectionId: "conn-1",
liveConnectionId: "conn-1",
paneConnectionId: "conn-1",
expectedTerminalCwd: "/srv/project",
liveTerminalCwd: null,
requireLiveTerminalCwd: true,
}),
false,
);
});
test("shouldApplyFollowTerminalCwdSyncResult allows missing live cwd when target was fetched fresh", () => {
assert.equal(
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration: 2,
currentGeneration: 2,
followEnabled: true,
canFollow: true,
expectedConnectionId: "conn-1",
liveConnectionId: "conn-1",
paneConnectionId: "conn-1",
expectedTerminalCwd: "/srv/project",
liveTerminalCwd: null,
}),
true,
);
});
test("SftpSidePanel follow effect is not keyed by SFTP path changes", () => {
const source = readComponentSource("../SftpSidePanel.tsx");
const followEffect = source.match(
/useEffect\(\(\) => \{\n\s+if \(!effectiveFollowTerminalCwd[\s\S]*?void syncFollowToTerminalCwd\(\);\n\s+\}, \[\n(?<deps>[\s\S]*?)\n\s+\]\);/,
);
assert.ok(followEffect?.groups?.deps);
assert.match(followEffect.groups.deps, /activeTerminalCwd/);
assert.match(followEffect.groups.deps, /connectionId/);
assert.doesNotMatch(followEffect.groups.deps, /currentPath|connectionPath/);
});
test("SftpSidePanel passes a stale-result guard into automatic follow navigation", () => {
const source = readComponentSource("../SftpSidePanel.tsx");
assert.match(
source,
/navigateTo\("left", terminalCwd, \{\n\s+shouldApply: shouldApplyCurrentFollowSync,\n\s+\}\)/,
);
});
test("SftpSidePanel invalidates follow state whenever the follow toggle changes", () => {
const source = readComponentSource("../SftpSidePanel.tsx");
const toggleHandler = source.match(
/const handleToggleFollowTerminalCwd = useCallback\(\(\) => \{[\s\S]*?\}, \[effectiveFollowTerminalCwd/,
);
assert.ok(toggleHandler);
assert.match(toggleHandler[0], /invalidateInFlightFollowSync\(\);/);
assert.doesNotMatch(toggleHandler[0], /if \(!nextEnabled\)/);
});
+52
View File
@@ -13,6 +13,21 @@ export type SftpFollowTerminalCwdContext = {
isConnected: boolean;
/** Skip auto-follow while this terminal cwd cannot be reached on SFTP. */
blockedFollow?: SftpFollowTerminalCwdBlock | null;
/** Skip auto-follow after this terminal cwd was already handled for the connection. */
handledFollow?: SftpFollowTerminalCwdBlock | null;
};
export type SftpFollowTerminalCwdSyncResultContext = {
syncGeneration: number;
currentGeneration: number;
followEnabled: boolean;
canFollow: boolean;
expectedConnectionId?: string | null;
liveConnectionId?: string | null;
paneConnectionId?: string | null;
expectedTerminalCwd?: string | null;
liveTerminalCwd?: string | null;
requireLiveTerminalCwd?: boolean;
};
export const resolveHostFollowTerminalCwd = (
@@ -59,6 +74,34 @@ export const shouldClearBlockedFollowOnReach = (
);
};
/** Whether an async follow result still belongs to the current terminal/connection state. */
export const shouldApplyFollowTerminalCwdSyncResult = ({
syncGeneration,
currentGeneration,
followEnabled,
canFollow,
expectedConnectionId,
liveConnectionId,
paneConnectionId,
expectedTerminalCwd,
liveTerminalCwd,
requireLiveTerminalCwd = false,
}: SftpFollowTerminalCwdSyncResultContext): boolean => {
if (syncGeneration !== currentGeneration || !followEnabled || !canFollow) {
return false;
}
if (expectedConnectionId !== undefined) {
if (!expectedConnectionId) return false;
if (liveConnectionId !== undefined && liveConnectionId !== expectedConnectionId) return false;
if (paneConnectionId !== undefined && paneConnectionId !== expectedConnectionId) return false;
}
if (expectedTerminalCwd !== undefined) {
if (requireLiveTerminalCwd && !liveTerminalCwd) return false;
if (liveTerminalCwd && liveTerminalCwd !== expectedTerminalCwd) return false;
}
return true;
};
/** Whether SFTP should auto-navigate to match the linked terminal cwd. */
export const shouldFollowTerminalCwdNavigate = ({
followEnabled,
@@ -69,10 +112,19 @@ export const shouldFollowTerminalCwdNavigate = ({
hasActiveWork,
isConnected,
blockedFollow,
handledFollow,
}: SftpFollowTerminalCwdContext): boolean => {
if (!followEnabled || !isVisible || !isConnected) return false;
if (hasActiveWork) return false;
if (!terminalCwd || terminalCwd.trim().length === 0) return false;
if (
handledFollow
&& connectionId
&& handledFollow.connectionId === connectionId
&& handledFollow.terminalCwd === terminalCwd
) {
return false;
}
if (
blockedFollow
&& connectionId
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import { normalizeTerminalSettings } from "../../domain/models/terminal.ts";
import type { Host, TerminalSession } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import { SystemManagerSidePanel } from "./SystemManagerSidePanel.tsx";
const session: TerminalSession = {
id: "session-1",
hostId: "host-1",
hostLabel: "Demo",
username: "root",
hostname: "demo.local",
status: "connected",
protocol: "ssh",
};
const host: Host = {
id: "host-1",
label: "Demo",
hostname: "demo.local",
username: "root",
tags: [],
os: "linux",
};
test("system side panel renders the graphical overview as the first tab", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<SystemManagerSidePanel
session={session}
sessionHost={host}
isVisible={false}
terminalSettings={normalizeTerminalSettings()}
snippets={[]}
/>
</TooltipProvider>
</I18nProvider>,
);
assert.match(markup, /Overview/);
assert.match(markup, /data-section="system-manager-overview"/);
assert.doesNotMatch(markup, /Live server health/);
assert.doesNotMatch(markup, /System overview/);
});
test("overview tab reuses the shared server stats source", () => {
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
assert.match(source, /function MetricCard\(\{[\s\S]*trendValues,\s*trendMax,\s*tone,/);
assert.match(source, /useServerStats\(\{/);
assert.match(source, /setHistory\(\[\]\)/);
assert.match(source, /if \(!isVisible \|\| !hasStats\) return/);
assert.match(source, /SystemPanelInlineError[\s\S]*onRetry=\{\(\) => void refresh\(\)\}/);
assert.doesNotMatch(source, /usePolling/);
assert.doesNotMatch(source, /backend\.getServerStats/);
});
test("overview tab unsubscribes while another system tab is active", () => {
const source = readFileSync(new URL("./SystemManagerSidePanel.tsx", import.meta.url), "utf8");
assert.match(source, /resolvedTab === 'overview' && \(/);
assert.match(source, /<SystemOverviewTab/);
});
@@ -1,4 +1,5 @@
import { Activity, Box, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import { Activity, Box, Gauge, LayoutList, Loader2, TerminalSquare } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React, { memo, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
@@ -6,11 +7,12 @@ import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
import { buildSystemManagerTabs } from '../../domain/systemManager/systemTarget';
import { buildSystemManagerTabs, shouldCollectServerStats } from '../../domain/systemManager/systemTarget';
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
import { DockerManagerTab } from './DockerManagerTab';
import { ProcessManagerTab } from './ProcessManagerTab';
import { SystemOverviewTab } from './SystemOverviewTab';
import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
@@ -61,9 +63,13 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
() => buildSystemManagerTabs(sessionHost, capabilities, session),
[capabilities, session, sessionHost],
);
const isStatsSupportedOs = useMemo(
() => shouldCollectServerStats(sessionHost, capabilities, session),
[capabilities, session, sessionHost],
);
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('processes');
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'processes';
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('overview');
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'overview';
// Must be defined before early returns to comply with React rules of hooks.
const prevTabRef = React.useRef(resolvedTab);
@@ -153,7 +159,8 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
);
}
const tabDefs: { id: SystemManagerSubTab; icon: typeof LayoutList; label: string }[] = [
const tabDefs: { id: SystemManagerSubTab; icon: LucideIcon; label: string }[] = [
{ id: 'overview', icon: Gauge, label: t('systemManager.tabs.overview') },
{ id: 'processes', icon: LayoutList, label: t('systemManager.tabs.processes') },
{ id: 'tmux', icon: TerminalSquare, label: t('systemManager.tabs.tmux') },
{ id: 'docker', icon: Box, label: t('systemManager.tabs.docker') },
@@ -195,6 +202,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'overview' && 'hidden')}>
{resolvedTab === 'overview' && (
<SystemOverviewTab
sessionId={sessionId}
isVisible={isVisible}
isSupportedOs={isStatsSupportedOs}
refreshIntervalSec={terminalSettings.serverStatsRefreshInterval}
/>
)}
</div>
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'processes' && 'hidden')}>
<ProcessManagerTab
sessionId={sessionId}
@@ -0,0 +1,431 @@
import {
Activity,
Clock3,
Cpu,
HardDrive,
MemoryStick,
Network,
} from 'lucide-react';
import React, { memo, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { useServerStats } from '../terminal/hooks/useServerStats';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelInlineError,
SystemPanelLoading,
SystemPanelShell,
} from './SystemPanelUi';
interface SystemOverviewTabProps {
sessionId: string;
isVisible: boolean;
isSupportedOs: boolean;
refreshIntervalSec: number;
}
interface OverviewSample {
at: number;
cpu: number;
memory: number;
disk: number;
network: number;
}
function clampPercent(value: number | null | undefined): number | null {
if (!Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Number(value)));
}
function ratioPercent(used: number | null | undefined, total: number | null | undefined): number | null {
if (!Number.isFinite(used) || !Number.isFinite(total) || Number(total) <= 0) return null;
return clampPercent((Number(used) / Number(total)) * 100);
}
function formatPercent(value: number | null | undefined, digits = 0): string {
if (!Number.isFinite(value)) return '--';
return `${Number(value).toFixed(digits)}%`;
}
function formatBytes(bytes: number): string {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`;
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${Math.round(value)} B`;
}
function formatThroughput(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
function formatStorageGb(gb: number | null | undefined): string {
if (!Number.isFinite(gb)) return '--';
const value = Number(gb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} TB`;
return `${value.toFixed(value >= 10 ? 0 : 1)} GB`;
}
function formatMemoryMb(mb: number | null | undefined): string {
if (!Number.isFinite(mb)) return '--';
const value = Number(mb);
if (value >= 1024) return `${(value / 1024).toFixed(1)} GB`;
return `${Math.round(value)} MB`;
}
function formatDuration(seconds: number | null | undefined, t: ReturnType<typeof useI18n>['t']): string {
if (!Number.isFinite(seconds) || Number(seconds) < 0) return '--';
const totalHours = Math.floor(Number(seconds) / 3600);
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
const minutes = Math.floor((Number(seconds) % 3600) / 60);
if (days > 0) return t('systemManager.overview.duration.daysHours', { days, hours });
if (hours > 0) return t('systemManager.overview.duration.hoursMinutes', { hours, minutes });
return t('systemManager.overview.duration.minutes', { minutes });
}
function formatLoad(loadAverage: number[] | undefined): string {
if (!loadAverage || loadAverage.length === 0) return '--';
return loadAverage.map((load) => load.toFixed(2)).join(' / ');
}
function MetricTrend({
values,
max,
className,
}: {
values: number[];
max?: number;
className?: string;
}) {
const width = 120;
const height = 34;
const finite = values.filter((value) => Number.isFinite(value));
const computedMax = max ?? Math.max(1, ...finite);
const safeValues = values.length > 1 ? values : [0, values[0] ?? 0];
const points = safeValues.map((value, index) => {
const x = safeValues.length === 1 ? width : (index / (safeValues.length - 1)) * width;
const clamped = Math.max(0, Math.min(computedMax, Number.isFinite(value) ? value : 0));
const y = height - (clamped / computedMax) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const area = `M0,${height} L${points.join(' L')} L${width},${height} Z`;
return (
<svg className={cn('h-9 w-full overflow-visible', className)} viewBox={`0 0 ${width} ${height}`} role="img">
<path d={area} fill="currentColor" opacity="0.10" />
<polyline points={points.join(' ')} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function RadialGauge({
value,
className,
}: {
value: number | null;
className?: string;
}) {
const clamped = clampPercent(value) ?? 0;
return (
<div className={cn('relative h-16 w-16 shrink-0', className)}>
<svg viewBox="0 0 44 44" className="h-full w-full -rotate-90">
<circle cx="22" cy="22" r="18" fill="none" stroke="currentColor" strokeWidth="5" className="text-muted/70" />
<circle
cx="22"
cy="22"
r="18"
fill="none"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${clamped} ${100 - clamped}`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-[12px] font-semibold tabular-nums text-foreground">
{formatPercent(value)}
</div>
</div>
);
}
function MetricCard({
label,
value,
detail,
icon: Icon,
gaugeValue,
trendValues,
trendMax,
tone,
}: {
label: string;
value: string;
detail: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
gaugeValue: number | null;
trendValues: number[];
trendMax?: number;
tone: string;
}) {
return (
<section className="rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<Icon size={13} className={tone} />
<span>{label}</span>
</div>
<div className="truncate text-lg font-semibold tabular-nums leading-tight text-foreground">{value}</div>
<div className="mt-1 truncate text-[10px] text-muted-foreground">{detail}</div>
</div>
<RadialGauge value={gaugeValue} className={tone} />
</div>
<MetricTrend values={trendValues} max={trendMax} className={cn('mt-2', tone)} />
</section>
);
}
function InfoPill({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="min-w-0 rounded-md border border-border/50 bg-background/70 px-2.5 py-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-xs font-medium text-foreground">{value || '--'}</div>
</div>
);
}
export const SystemOverviewTab = memo(function SystemOverviewTab({
sessionId,
isVisible,
isSupportedOs,
refreshIntervalSec,
}: SystemOverviewTabProps) {
const { t } = useI18n();
const [history, setHistory] = useState<OverviewSample[]>([]);
const {
stats,
error,
isLoading: loading,
refresh,
} = useServerStats({
sessionId,
enabled: true,
refreshInterval: refreshIntervalSec,
isSupportedOs,
isConnected: true,
isVisible,
});
const hasStats = Boolean(stats.lastUpdated);
const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal);
const diskPercent = clampPercent(stats?.diskPercent);
const networkSpeed = (stats?.netRxSpeed ?? 0) + (stats?.netTxSpeed ?? 0);
const networkGauge = Math.min(100, Math.log10(networkSpeed + 1) * 14);
const loadOne = stats?.loadAverage?.[0] ?? null;
const loadPercent = ratioPercent(loadOne, stats?.cpuCores);
useEffect(() => {
setHistory([]);
}, [sessionId]);
useEffect(() => {
if (!isVisible || !hasStats) return;
setHistory((prev) => {
const next = [
...prev,
{
at: Date.now(),
cpu: clampPercent(stats.cpu) ?? 0,
memory: memoryPercent ?? 0,
disk: diskPercent ?? 0,
network: networkSpeed,
},
];
return next.slice(-24);
});
}, [diskPercent, hasStats, isVisible, memoryPercent, networkSpeed, stats.cpu]);
const trends = useMemo(() => ({
cpu: history.map((sample) => sample.cpu),
memory: history.map((sample) => sample.memory),
disk: history.map((sample) => sample.disk),
network: history.map((sample) => sample.network),
}), [history]);
const showBlockingError = Boolean(error && !hasStats && !loading);
const showInitialLoading = Boolean(loading && !hasStats);
return (
<SystemPanelShell section="system-manager-overview">
{error && hasStats && !loading && (
<SystemPanelInlineError
message={error}
onRetry={() => void refresh()}
retryLabel={t('history.action.retry')}
loading={loading}
/>
)}
{showBlockingError && error ? (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
) : showInitialLoading ? (
<SystemPanelLoading message={t('systemManager.overview.loading')} />
) : !hasStats ? (
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
) : (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3">
<div className="grid grid-cols-2 gap-2">
<MetricCard
label="CPU"
value={formatPercent(stats.cpu)}
detail={stats.cpuCores ? t('systemManager.overview.cores', { count: String(stats.cpuCores) }) : '--'}
icon={Cpu}
gaugeValue={stats.cpu}
trendValues={trends.cpu}
trendMax={100}
tone="text-sky-500"
/>
<MetricCard
label={t('systemManager.overview.memory')}
value={formatPercent(memoryPercent)}
detail={`${formatMemoryMb(stats.memUsed)} / ${formatMemoryMb(stats.memTotal)}`}
icon={MemoryStick}
gaugeValue={memoryPercent}
trendValues={trends.memory}
trendMax={100}
tone="text-emerald-500"
/>
<MetricCard
label={t('systemManager.overview.disk')}
value={formatPercent(diskPercent)}
detail={`${formatStorageGb(stats.diskUsed)} / ${formatStorageGb(stats.diskTotal)}`}
icon={HardDrive}
gaugeValue={diskPercent}
trendValues={trends.disk}
trendMax={100}
tone="text-amber-500"
/>
<MetricCard
label={t('systemManager.overview.network')}
value={formatThroughput(networkSpeed)}
detail={`${t('systemManager.overview.rx')} ${formatThroughput(stats.netRxSpeed)} · ${t('systemManager.overview.tx')} ${formatThroughput(stats.netTxSpeed)}`}
icon={Network}
gaugeValue={networkGauge}
trendValues={trends.network}
tone="text-cyan-500"
/>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<InfoPill label={t('systemManager.overview.load')} value={formatLoad(stats.loadAverage)} />
<InfoPill label={t('systemManager.overview.uptime')} value={formatDuration(stats.uptimeSeconds, t)} />
<InfoPill label={t('systemManager.overview.system')} value={stats.osName || '--'} />
<InfoPill label={t('systemManager.overview.kernel')} value={stats.kernelRelease || '--'} />
<InfoPill label={t('systemManager.overview.swap')} value={`${formatMemoryMb(stats.swapUsed)} / ${formatMemoryMb(stats.swapTotal)}`} />
<InfoPill label={t('systemManager.overview.latency')} value={Number.isFinite(stats.latencyMs) ? `${Math.round(stats.latencyMs ?? 0)} ms` : '--'} />
</div>
<section className="mt-3 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<Cpu size={13} className="text-sky-500" />
{t('systemManager.overview.cpuCores')}
</div>
<span className="text-[10px] text-muted-foreground">
{loadPercent !== null ? `${t('systemManager.overview.load')} ${formatPercent(loadPercent)}` : t('systemManager.overview.noData')}
</span>
</div>
{stats.cpuPerCore.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground">{t('systemManager.overview.noData')}</div>
)}
</section>
<section className="mt-3 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-foreground">
<HardDrive size={13} className="text-amber-500" />
{t('systemManager.overview.disks')}
</div>
{stats.disks.length > 0 ? (
<div className="space-y-2">
{stats.disks.slice(0, 5).map((disk) => (
<div key={disk.mountPoint} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate text-foreground">{disk.mountPoint}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatStorageGb(disk.used)} / {formatStorageGb(disk.total)}
</span>
</div>
<ResourceBar label="" value={disk.percent} />
</div>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground">{t('systemManager.overview.noDisks')}</div>
)}
</section>
<section className="mt-3 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-foreground">
<Network size={13} className="text-cyan-500" />
{t('systemManager.overview.interfaces')}
</div>
{stats.netInterfaces.length > 0 ? (
<div className="space-y-2">
{stats.netInterfaces.slice(0, 5).map((iface) => (
<div key={iface.name} className="grid grid-cols-[1fr_auto] items-center gap-2 text-[11px]">
<span className="min-w-0 truncate text-foreground">{iface.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{t('systemManager.overview.rx')} {formatThroughput(iface.rxSpeed)} · {t('systemManager.overview.tx')} {formatThroughput(iface.txSpeed)}
</span>
</div>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground">{t('systemManager.overview.noInterfaces')}</div>
)}
</section>
<section className="mt-3 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-foreground">
<Clock3 size={13} className="text-rose-500" />
{t('systemManager.overview.topProcesses')}
</div>
{stats.topProcesses.length > 0 ? (
<div className="space-y-2">
{stats.topProcesses.slice(0, 5).map((proc) => (
<div key={`${proc.pid}-${proc.command}`} className="space-y-1">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="min-w-0 truncate text-foreground">{proc.command}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">PID {proc.pid}</span>
</div>
<ResourceBar label="MEM" value={proc.memPercent} />
</div>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground">{t('systemManager.overview.noTopProcesses')}</div>
)}
</section>
</div>
)}
</SystemPanelShell>
);
});
@@ -248,13 +248,30 @@ export const SystemPanelError = memo(function SystemPanelError({
export const SystemPanelInlineError = memo(function SystemPanelInlineError({
message,
onRetry,
retryLabel,
loading,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
loading?: boolean;
}) {
return (
<div className="shrink-0 flex items-center gap-2 px-3 py-2 text-[11px] text-muted-foreground border-b border-border/30 bg-muted/20">
<Unplug size={12} className="shrink-0 opacity-60" />
<span className="min-w-0 truncate">{message}</span>
{onRetry && retryLabel && (
<button
type="button"
onClick={onRetry}
disabled={loading}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<RefreshCw size={10} className={cn(loading && 'animate-spin')} />
{retryLabel}
</button>
)}
</div>
);
});
@@ -108,6 +108,25 @@ test("does not show a countdown while waiting for user input", () => {
assert.equal(markup.includes("Timeout in"), false);
});
test("shows enter reconnect hint when disconnected reconnect is available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
showEnterReconnectHint: true,
});
assert.match(markup, /Press Enter to reconnect/);
});
test("does not show enter reconnect hint until the caller marks enter reconnect available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
});
assert.equal(markup.includes("Press Enter to reconnect"), false);
});
test("renders changed host key warning in the same connection dialog", () => {
const markup = renderDialog({
hostKeyVerification: {
@@ -35,6 +35,7 @@ export interface TerminalConnectionDialogProps {
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
onDismissDisconnected?: () => void;
showEnterReconnectHint?: boolean;
hostKeyVerification?: {
hostKeyInfo: HostKeyInfo;
onClose: () => void;
@@ -42,7 +43,7 @@ export interface TerminalConnectionDialogProps {
onAddAndContinue: () => void;
};
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | 'showEnterReconnectHint'>;
}
// Helper to get protocol display info
@@ -89,6 +90,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
authProps,
keys,
onDismissDisconnected,
showEnterReconnectHint,
hostKeyVerification,
progressProps,
}) => {
@@ -324,6 +326,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
status={status}
error={error}
showLogs={showLogs}
showEnterReconnectHint={showEnterReconnectHint}
reconnectLabel={isRestoredDisconnected ? t('terminal.restore.placeholder.reconnect') : undefined}
{...progressProps}
/>
@@ -13,6 +13,7 @@ export interface TerminalConnectionProgressProps {
error: string | null;
timeLeft: number;
isAwaitingUserInput?: boolean;
showEnterReconnectHint?: boolean;
isCancelling: boolean;
showLogs: boolean;
progressLogs: string[];
@@ -56,6 +57,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
error,
timeLeft,
isAwaitingUserInput = false,
showEnterReconnectHint = false,
isCancelling: _isCancelling,
showLogs,
progressLogs,
@@ -94,18 +96,23 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
<TerminalConnectionLogList progressLogs={progressLogs} error={error} />
)}
<div className="flex justify-end gap-2">
{status !== 'connecting' && (
<>
{status !== 'connecting' && (
<div className="flex flex-wrap items-center justify-between gap-2">
{showEnterReconnectHint && (
<div className="min-w-0 break-words text-[11px] leading-5 text-muted-foreground">
{t('terminal.progress.enterReconnectHint')}
</div>
)}
<div className="ml-auto flex shrink-0 justify-end gap-2">
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {reconnectLabel ?? t('terminal.progress.startOver')}
</Button>
</>
)}
</div>
</div>
</div>
)}
</>
);
};
+103 -6
View File
@@ -53,6 +53,15 @@ const shouldRenderTerminalContextMenuContent = (
}) => boolean;
}
).shouldRenderTerminalContextMenuContent;
const shouldAllowSuppressedTerminalContextMenuContent = (
terminalContextMenu as {
shouldAllowSuppressedTerminalContextMenuContent?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}) => boolean;
}
).shouldAllowSuppressedTerminalContextMenuContent;
test("shows reconnect only for reconnectable terminals with a handler", () => {
assert.equal(typeof shouldShowReconnectAction, "function");
@@ -216,30 +225,40 @@ test("opens a middle-click menu even when right-click is configured to paste", (
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
typeof shouldRenderTerminalContextMenuContent !== "function" ||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
) {
return;
}
const middleClickEvent = {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
};
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
event: middleClickEvent,
rightClickBehavior: "paste",
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
event: middleClickEvent,
isAlternateScreen: true,
showReconnectAction: false,
});
assert.equal(allowSuppressedMenuContent, true);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: true,
allowSuppressedMenuContent,
}),
true,
);
@@ -256,6 +275,16 @@ test("opens and renders middle-click menu while alternate-screen mouse tracking
}),
false,
);
assert.equal(
shouldAllowSuppressedTerminalContextMenuContent({
event: {
nativeEvent: {} as MouseEvent,
},
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
@@ -265,3 +294,71 @@ test("opens and renders middle-click menu while alternate-screen mouse tracking
false,
);
});
test("opens Shift right-click menu content for all right-click modes while mouse tracking suppresses unmodified menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function" ||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
) {
return;
}
const event = {
shiftKey: true,
nativeEvent: {} as MouseEvent,
};
for (const rightClickBehavior of ["context-menu", "paste", "select-word"] as const) {
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior,
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
event,
isAlternateScreen: true,
showReconnectAction: false,
});
assert.equal(allowSuppressedMenuContent, true);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent,
}),
true,
);
}
assert.equal(
shouldOpenTerminalContextMenu({
event: {
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldAllowSuppressedTerminalContextMenuContent({
event: {
nativeEvent: {} as MouseEvent,
},
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
});
+22 -3
View File
@@ -91,6 +91,18 @@ export const shouldRenderTerminalContextMenuContent = ({
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
export const shouldAllowSuppressedTerminalContextMenuContent = ({
event,
isAlternateScreen,
showReconnectAction,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}): boolean =>
isMiddleClickContextMenuEvent(event.nativeEvent)
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }));
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
@@ -106,11 +118,15 @@ export const shouldOpenTerminalContextMenu = ({
return true;
}
if (event.shiftKey) {
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
return false;
}
return Boolean(event.shiftKey || rightClickBehavior === 'context-menu');
return rightClickBehavior === 'context-menu';
};
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
@@ -187,7 +203,6 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
isAlternateScreen,
showReconnectAction,
});
const isMiddleClickMenu = isMiddleClickContextMenuEvent(e.nativeEvent);
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
e.preventDefault();
@@ -202,7 +217,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
pane.setAttribute('data-menu-open', '');
markedPaneRef.current = pane;
}
setAllowSuppressedMenuContent(isMiddleClickMenu);
setAllowSuppressedMenuContent(shouldAllowSuppressedTerminalContextMenuContent({
event: e,
isAlternateScreen,
showReconnectAction,
}));
return;
}
+65
View File
@@ -5,6 +5,8 @@ import { readFileSync } from "node:fs";
import {
formatTerminalTitleConnectionAddress,
getLineTimestampToggleHostUpdate,
shouldBlockTerminalReconnectForTarget,
shouldReconnectTerminalOnEnterKey,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
} from "./TerminalView.tsx";
@@ -66,6 +68,69 @@ test("selection AI overlay honors the visibility preference", () => {
);
});
test("disconnected terminal reconnects on plain Enter when input is not claimed elsewhere", () => {
assert.equal(
shouldReconnectTerminalOnEnterKey({
key: "Enter",
status: "disconnected",
hasRetryHandler: true,
isSearchOpen: false,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
}),
true,
);
});
test("terminal enter reconnect ignores active controls and non-disconnected states", () => {
const base = {
key: "Enter",
status: "disconnected" as const,
hasRetryHandler: true,
isSearchOpen: false,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
};
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isSearchOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
});
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: false,
hasInteractiveAncestor: true,
}),
true,
);
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: true,
hasInteractiveAncestor: true,
}),
false,
);
assert.equal(
shouldBlockTerminalReconnectForTarget({
isWithinXterm: false,
hasInteractiveAncestor: false,
}),
false,
);
});
test("terminal title formats the connection address for remote sessions", () => {
assert.equal(
formatTerminalTitleConnectionAddress({
+108
View File
@@ -72,6 +72,68 @@ export function shouldShowSelectionAIOverlay({
);
}
export function shouldReconnectTerminalOnEnterKey({
key,
status,
hasRetryHandler,
isSearchOpen,
isComposeBarOpen,
needsAuth,
needsHostKeyVerification,
hasBlockingOverlay,
altKey,
ctrlKey,
metaKey,
shiftKey,
isComposing,
}: {
key: string;
status?: string;
hasRetryHandler: boolean;
isSearchOpen: boolean;
isComposeBarOpen: boolean;
needsAuth: boolean;
needsHostKeyVerification: boolean;
hasBlockingOverlay: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
isComposing?: boolean;
}): boolean {
return key === "Enter"
&& status === "disconnected"
&& hasRetryHandler
&& !isSearchOpen
&& !isComposeBarOpen
&& !needsAuth
&& !needsHostKeyVerification
&& !hasBlockingOverlay
&& !altKey
&& !ctrlKey
&& !metaKey
&& !shiftKey
&& !isComposing;
}
export function shouldBlockTerminalReconnectForTarget({
isWithinXterm,
hasInteractiveAncestor,
}: {
isWithinXterm: boolean;
hasInteractiveAncestor: boolean;
}): boolean {
return !isWithinXterm && hasInteractiveAncestor;
}
function isTerminalReconnectControlTarget(target: EventTarget | null): boolean {
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
return shouldBlockTerminalReconnectForTarget({
isWithinXterm: target.classList.contains("xterm-helper-textarea") || Boolean(target.closest(".xterm")),
hasInteractiveAncestor: Boolean(target.closest("button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='menuitem'], [role='textbox']")),
});
}
type TerminalTitleAddressHost = {
id?: string;
protocol?: string;
@@ -142,6 +204,50 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
const titleConnectionAddress = formatTerminalTitleConnectionAddress(host);
const hasBlockingReconnectOverlay = Boolean(osc52ReadPromptVisible || osc7SetupOpen || scriptExecutionOverlay || zmodem.active || zmodem.overwriteRequest);
const showEnterReconnectHint = shouldReconnectTerminalOnEnterKey({
key: "Enter",
status,
hasRetryHandler: Boolean(handleRetry),
isSearchOpen,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
});
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectTerminalOnEnterKey({
key: event.key,
status,
hasRetryHandler: Boolean(handleRetry),
isSearchOpen,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
isComposing: event.nativeEvent.isComposing,
})) {
return;
}
if (isTerminalReconnectControlTarget(event.target)) return;
event.preventDefault();
event.stopPropagation();
handleRetry();
}, [
auth.needsAuth,
handleRetry,
hasBlockingReconnectOverlay,
isComposeBarOpen,
isSearchOpen,
needsHostKeyVerification,
status,
]);
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -180,6 +286,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDownCapture={handleTerminalKeyDownCapture}
>
{/* Drag and drop overlay */}
{isDraggingOver && (
@@ -564,6 +671,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
showEnterReconnectHint={showEnterReconnectHint}
hostKeyVerification={needsHostKeyVerification && pendingHostKeyInfo ? {
hostKeyInfo: pendingHostKeyInfo,
onClose: handleHostKeyClose,
@@ -1,20 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import xterm from "@xterm/xterm";
import {
clearTerminalViewport,
installEraseInDisplayHandlers,
isEraseBelowSequence,
preserveTerminalViewportInScrollback,
shouldPreserveViewportBeforeEraseBelow,
shouldPreserveViewportBeforeFullErase,
shouldScrollOnEraseInDisplay,
shouldWipeScrollbackAfterFullErase,
} from "./clearTerminalViewport.ts";
const createMockTerm = (bufferType: "normal" | "alternate"): { buffer: { active: { type: "normal" | "alternate" } } } => ({
const { Terminal } = xterm;
const createMockTerm = (
bufferType: "normal" | "alternate",
cursor: { cursorX?: number; cursorY?: number } = {},
): { buffer: { active: { type: "normal" | "alternate"; cursorX: number; cursorY: number } } } => ({
buffer: {
active: {
type: bufferType,
cursorX: cursor.cursorX ?? 0,
cursorY: cursor.cursorY ?? 0,
},
},
});
type RegisteredCsiHandler = {
id: { prefix?: string; final: string };
callback: (params: Array<number | number[]>) => boolean | Promise<boolean>;
disposed: boolean;
};
const createEraseHandlerHarness = (
options: {
bufferType?: "normal" | "alternate";
clearWipesScrollback?: boolean;
cursorX?: number;
cursorY?: number;
inDec2026SyncBlock?: boolean;
scrollTop?: number;
scrollBottom?: number;
} = {},
) => {
const handlers: RegisteredCsiHandler[] = [];
const microtasks: Array<() => void> = [];
const trimStartCalls: number[] = [];
const onScrollPositions: number[] = [];
const scrollRegion = {
lines: {
length: options.clearWipesScrollback ? 9 : 5,
trimStart: (count: number) => {
trimStartCalls.push(count);
scrollRegion.lines.length -= count;
},
},
scrollTop: options.scrollTop ?? 0,
scrollBottom: options.scrollBottom ?? 4,
ybase: options.clearWipesScrollback ? 4 : 0,
ydisp: options.clearWipesScrollback ? 4 : 0,
};
const observedScrollRegions: Array<[number, number]> = [];
const term = {
rows: 5,
options: {
scrollOnEraseInDisplay: false,
},
parser: {
registerCsiHandler: (id: { prefix?: string; final: string }, callback: RegisteredCsiHandler["callback"]) => {
const handler = {
id,
callback,
disposed: false,
};
handlers.push(handler);
return {
dispose: () => {
handler.disposed = true;
},
};
},
},
buffer: {
active: {
type: options.bufferType ?? "normal",
baseY: 0,
cursorX: options.cursorX ?? 0,
cursorY: options.cursorY ?? 0,
getLine: (line: number) => {
if (line < 0 || line >= 5) {
return undefined;
}
return {
translateToString: () => `row-${line}`,
};
},
},
},
_core: {
buffer: scrollRegion,
scroll: () => {
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
},
_inputHandler: {
_onScroll: {
fire: (position: number) => {
onScrollPositions.push(position);
},
},
_eraseAttrData: () => ({}),
},
},
};
const disposable = installEraseInDisplayHandlers(term as never, {
getClearWipesScrollback: () => options.clearWipesScrollback ?? false,
isInDec2026SyncBlock: () => options.inDec2026SyncBlock ?? false,
scheduleMicrotask: (callback) => {
microtasks.push(callback);
},
});
const erase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === undefined);
const selectiveErase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === "?");
if (!erase || !selectiveErase) {
throw new Error("erase handlers were not registered");
}
return {
disposable,
erase: erase.callback,
flushMicrotasks: () => {
while (microtasks.length > 0) {
microtasks.shift()?.();
}
},
handlers,
observedScrollRegions,
onScrollPositions,
scrollRegion,
selectiveErase: selectiveErase.callback,
term,
trimStartCalls,
};
};
const writeTerminal = (term: InstanceType<typeof Terminal>, data: string): Promise<void> =>
new Promise((resolve) => term.write(data, resolve));
test("preserves viewport before full erase on the normal screen outside sync blocks", () => {
const term = createMockTerm("normal");
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false), true);
@@ -42,6 +175,192 @@ test("wipes scrollback after full erase only on the normal screen outside sync b
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, false, false), false);
});
test("native erase-in-display scrollback preservation follows the clear history setting", () => {
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, false), true);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, true), false);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, true, false), false);
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("alternate") as never, false, false), false);
});
test("native erase-in-display scrollback preservation is skipped with active scroll margins", () => {
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
cursorX: 0,
cursorY: 0,
},
},
_core: {
buffer: {
scrollTop: 1,
scrollBottom: 3,
},
},
};
assert.equal(shouldScrollOnEraseInDisplay(term as never, false, false), false);
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false, false), true);
assert.equal(shouldPreserveViewportBeforeEraseBelow(term as never, false, false), true);
});
test("erase-below is treated as viewport clear only from the home position", () => {
assert.equal(isEraseBelowSequence([]), true);
assert.equal(isEraseBelowSequence([0]), true);
assert.equal(isEraseBelowSequence([2]), false);
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal") as never, false, false), true);
assert.equal(
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorX: 1 }) as never, false, false),
false,
);
assert.equal(
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorY: 1 }) as never, false, false),
false,
);
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("alternate") as never, false, false), false);
});
test("viewport preservation temporarily uses the full scroll region", () => {
const scrollRegion = {
scrollTop: 1,
scrollBottom: 3,
};
const observedScrollRegions: Array<[number, number]> = [];
const term = {
rows: 5,
buffer: {
active: {
type: "normal",
baseY: 0,
getLine: (line: number) => {
if (line < 0 || line >= 5) {
return undefined;
}
return {
translateToString: () => `row-${line}`,
};
},
},
},
_core: {
buffer: scrollRegion,
scroll: () => {
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
},
_inputHandler: {
_eraseAttrData: () => ({}),
},
},
};
preserveTerminalViewportInScrollback(term as never);
assert.equal(observedScrollRegions.length, 5);
assert.deepEqual(observedScrollRegions, [
[0, 4],
[0, 4],
[0, 4],
[0, 4],
[0, 4],
]);
assert.deepEqual(scrollRegion, {
scrollTop: 1,
scrollBottom: 3,
});
});
test("installed erase handlers preserve scrollback by behavior", () => {
const fullClear = createEraseHandlerHarness();
assert.equal(fullClear.erase([2]), false);
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, true);
assert.deepEqual(fullClear.observedScrollRegions, []);
fullClear.flushMicrotasks();
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, false);
const marginFullClear = createEraseHandlerHarness({ scrollTop: 1, scrollBottom: 3 });
assert.equal(marginFullClear.erase([2]), false);
assert.equal(marginFullClear.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(marginFullClear.observedScrollRegions, [
[0, 4],
[0, 4],
[0, 4],
[0, 4],
[0, 4],
]);
assert.equal(marginFullClear.scrollRegion.scrollTop, 1);
assert.equal(marginFullClear.scrollRegion.scrollBottom, 3);
const eraseBelow = createEraseHandlerHarness();
assert.equal(eraseBelow.erase([]), false);
assert.equal(eraseBelow.observedScrollRegions.length, 5);
const eraseBelowAwayFromHome = createEraseHandlerHarness({ cursorY: 1 });
assert.equal(eraseBelowAwayFromHome.erase([]), false);
assert.equal(eraseBelowAwayFromHome.observedScrollRegions.length, 0);
const wipeEraseBelow = createEraseHandlerHarness({ clearWipesScrollback: true });
assert.equal(wipeEraseBelow.erase([]), false);
assert.deepEqual(wipeEraseBelow.trimStartCalls, [4]);
assert.equal(wipeEraseBelow.scrollRegion.ybase, 0);
assert.equal(wipeEraseBelow.scrollRegion.ydisp, 0);
assert.deepEqual(wipeEraseBelow.onScrollPositions, [0]);
const wipeEraseBelowAwayFromHome = createEraseHandlerHarness({
clearWipesScrollback: true,
cursorY: 1,
});
assert.equal(wipeEraseBelowAwayFromHome.erase([]), false);
assert.deepEqual(wipeEraseBelowAwayFromHome.trimStartCalls, []);
});
test("erase-below wipe preserves later output from the same write batch", async () => {
const term = new Terminal({ cols: 20, rows: 5, scrollback: 100 });
const disposable = installEraseInDisplayHandlers(term as never, {
getClearWipesScrollback: () => true,
isInDec2026SyncBlock: () => false,
});
await writeTerminal(term, "old1\r\nold2\r\nold3\r\nold4\r\nold5\r\nold6\r\nold7\r\nold8");
await writeTerminal(term, "\x1b[H\x1b[Jnew1\r\nnew2\r\nnew3\r\nnew4\r\nnew5\r\nnew6\r\nnew7\r\nnew8");
const scrollback = Array.from({ length: term.buffer.active.baseY }, (_, row) =>
term.buffer.active.getLine(row)?.translateToString(true) ?? ""
);
assert.equal(scrollback.some((line) => line.startsWith("old")), false);
assert.equal(scrollback.some((line) => line.startsWith("new")), true);
disposable.dispose();
term.dispose();
});
test("installed erase handlers honor wipe, sync, alternate, and selective clears", () => {
const preserveHistory = createEraseHandlerHarness({ clearWipesScrollback: false });
assert.equal(preserveHistory.erase([3]), true);
const wipeHistory = createEraseHandlerHarness({ clearWipesScrollback: true });
assert.equal(wipeHistory.erase([3]), false);
const syncBlock = createEraseHandlerHarness({ inDec2026SyncBlock: true });
assert.equal(syncBlock.erase([2]), false);
assert.equal(syncBlock.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(syncBlock.observedScrollRegions, []);
const alternateScreen = createEraseHandlerHarness({ bufferType: "alternate" });
assert.equal(alternateScreen.erase([2]), false);
assert.equal(alternateScreen.term.options.scrollOnEraseInDisplay, false);
assert.deepEqual(alternateScreen.observedScrollRegions, []);
const selectiveClear = createEraseHandlerHarness();
selectiveClear.term.options.scrollOnEraseInDisplay = true;
assert.equal(selectiveClear.selectiveErase([2]), false);
assert.equal(selectiveClear.term.options.scrollOnEraseInDisplay, false);
selectiveClear.disposable.dispose();
assert.equal(selectiveClear.handlers.every((handler) => handler.disposed), true);
});
test("local clear writes erase-scrollback when requested", () => {
const writes: string[] = [];
const term = {
+183 -7
View File
@@ -1,10 +1,26 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { IDisposable, IParser, Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type EraseInDisplayTerminal = XTerm & {
parser: Pick<IParser, "registerCsiHandler">;
};
type InternalTerminal = XTerm & {
_core?: {
buffer?: {
lines?: {
length: number;
trimStart?: (count: number) => void;
};
scrollTop: number;
scrollBottom: number;
ybase?: number;
ydisp?: number;
};
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_onScroll?: {
fire?: (position: number) => void;
};
_eraseAttrData?: () => unknown;
};
};
@@ -19,6 +35,12 @@ type AppendEraseScrollbackOptions = {
normalScreen: boolean;
};
type EraseInDisplayHandlerOptions = {
getClearWipesScrollback: () => boolean;
isInDec2026SyncBlock: () => boolean;
scheduleMicrotask?: (callback: () => void) => void;
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
@@ -39,6 +61,25 @@ const getVisibleContentRowCount = (term: XTerm): number => {
return 0;
};
const getInternalScrollRegion = (term: XTerm): { scrollTop: number; scrollBottom: number } | undefined => {
const internalBuffer = (term as InternalTerminal)._core?.buffer;
if (
typeof internalBuffer?.scrollTop !== "number"
|| typeof internalBuffer.scrollBottom !== "number"
) {
return undefined;
}
return internalBuffer;
};
const hasDefaultScrollRegion = (term: XTerm): boolean => {
const scrollRegion = getInternalScrollRegion(term);
if (!scrollRegion) {
return true;
}
return scrollRegion.scrollTop === 0 && scrollRegion.scrollBottom === term.rows - 1;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
@@ -53,8 +94,28 @@ export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
return;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
const scrollRegion = getInternalScrollRegion(term);
const previousScrollTop = scrollRegion?.scrollTop;
const previousScrollBottom = scrollRegion?.scrollBottom;
try {
// xterm scrolls inside active DECSTBM margins; widen them while preserving.
if (scrollRegion) {
scrollRegion.scrollTop = 0;
scrollRegion.scrollBottom = term.rows - 1;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
} finally {
if (
scrollRegion
&& previousScrollTop !== undefined
&& previousScrollBottom !== undefined
) {
scrollRegion.scrollTop = previousScrollTop;
scrollRegion.scrollBottom = previousScrollBottom;
}
}
};
@@ -97,6 +158,20 @@ export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;
export const isEraseBelowSequence = (params: CsiParam[]): boolean =>
params.length === 0 || params[0] === 0;
export const shouldScrollOnEraseInDisplay = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback: boolean,
): boolean => {
if (clearWipesScrollback || inDec2026SyncBlock) {
return false;
}
return term.buffer.active.type === "normal" && hasDefaultScrollRegion(term);
};
/**
* Netcatty preserves visible rows in scrollback before CSI 2 J so shell `clear`
* does not discard history. TUIs inside DEC 2026 sync blocks or the alternate
@@ -107,15 +182,51 @@ export const shouldPreserveViewportBeforeFullErase = (
inDec2026SyncBlock: boolean,
clearWipesScrollback = false,
): boolean => {
if (inDec2026SyncBlock) {
return false;
}
if (clearWipesScrollback) {
if (inDec2026SyncBlock || clearWipesScrollback) {
return false;
}
return term.buffer.active.type === "normal";
};
export const shouldPreserveViewportBeforeEraseBelow = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback = false,
): boolean => {
if (!shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
return false;
}
const buffer = term.buffer.active;
return buffer.cursorX === 0 && buffer.cursorY === 0;
};
export const shouldWipeScrollbackAfterEraseBelow = (
term: XTerm,
inDec2026SyncBlock: boolean,
clearWipesScrollback: boolean,
): boolean => {
if (!shouldWipeScrollbackAfterFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
return false;
}
const buffer = term.buffer.active;
return buffer.cursorX === 0 && buffer.cursorY === 0;
};
const wipeTerminalScrollback = (term: XTerm): void => {
const internal = term as InternalTerminal;
const buffer = internal._core?.buffer;
const lines = buffer?.lines;
const scrollBackSize = (lines?.length ?? 0) - term.rows;
if (!buffer || !lines || scrollBackSize <= 0 || typeof lines.trimStart !== "function") {
return;
}
lines.trimStart(scrollBackSize);
buffer.ybase = Math.max((buffer.ybase ?? 0) - scrollBackSize, 0);
buffer.ydisp = Math.max((buffer.ydisp ?? 0) - scrollBackSize, 0);
internal._core?._inputHandler?._onScroll?.fire?.(0);
};
export const shouldWipeScrollbackAfterFullErase = (
term: XTerm,
inDec2026SyncBlock: boolean,
@@ -127,6 +238,71 @@ export const shouldWipeScrollbackAfterFullErase = (
return term.buffer.active.type === "normal";
};
export const installEraseInDisplayHandlers = (
term: EraseInDisplayTerminal,
{
getClearWipesScrollback,
isInDec2026SyncBlock,
scheduleMicrotask = queueMicrotask,
}: EraseInDisplayHandlerOptions,
): IDisposable => {
const setScrollOnEraseInDisplayOnce = (enabled: boolean): void => {
term.options.scrollOnEraseInDisplay = enabled;
if (enabled) {
scheduleMicrotask(() => {
term.options.scrollOnEraseInDisplay = false;
});
}
};
const eraseDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
const wipeAllowed = getClearWipesScrollback();
const inDec2026SyncBlock = isInDec2026SyncBlock();
// Scope xterm's native preservation to shell clears, not TUI redraws.
if (isEraseViewportSequence(params)) {
const useNativeScrollPreservation = shouldScrollOnEraseInDisplay(
term,
inDec2026SyncBlock,
wipeAllowed,
);
setScrollOnEraseInDisplayOnce(useNativeScrollPreservation);
if (
!useNativeScrollPreservation
&& shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, wipeAllowed)
) {
preserveTerminalViewportInScrollback(term);
}
return false;
}
setScrollOnEraseInDisplayOnce(false);
if (isEraseBelowSequence(params)) {
if (shouldPreserveViewportBeforeEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
preserveTerminalViewportInScrollback(term);
} else if (shouldWipeScrollbackAfterEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
wipeTerminalScrollback(term);
}
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
// Honor it unless the user opts into the legacy "preserve history" behavior.
return !wipeAllowed;
});
const selectiveEraseDisposable = term.parser.registerCsiHandler({ prefix: "?", final: "J" }, () => {
setScrollOnEraseInDisplayOnce(false);
return false;
});
return {
dispose: () => {
eraseDisposable.dispose();
selectiveEraseDisposable.dispose();
},
};
};
export const appendEraseScrollbackAfterFullErases = (
data: string,
{ wipeScrollback, normalScreen }: AppendEraseScrollbackOptions,
-1
View File
@@ -1,6 +1,5 @@
import type { Snippet } from '../../types';
export const COMPOSE_BAR_DEFAULT_HEIGHT = 120;
export const COMPOSE_BAR_MIN_HEIGHT = 72;
export const COMPOSE_BAR_MAX_HEIGHT = 360;
+330 -202
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
@@ -23,6 +23,11 @@ export interface ProcessInfo {
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
kernelRelease?: string; // Remote kernel release
uptimeSeconds?: number | null;// Remote uptime in seconds
loadAverage?: number[]; // 1/5/15 minute load average
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
@@ -54,15 +59,51 @@ interface UseServerStatsOptions {
isVisible: boolean; // Pause background polling for hidden terminals
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
interface ServerStatsState {
stats: ServerStats;
isLoading: boolean;
error: string | null;
}
interface ServerStatsClient {
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
isVisible: boolean;
}
type ServerStatsListener = (state: ServerStatsState) => void;
interface SharedServerStatsSession {
sessionId: string;
state: ServerStatsState;
listeners: Set<ServerStatsListener>;
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
consecutiveFailures: number;
givenUp: boolean;
pollIntervalMs: number | null;
pollingActive: boolean;
inflight: Promise<void> | null;
inflightGeneration: number | null;
}
const CONSECUTIVE_FAILURE_LIMIT = 3;
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
let nextClientId = 1;
function createEmptyServerStats(): ServerStats {
return {
hostname: undefined,
osName: undefined,
kernelRelease: undefined,
uptimeSeconds: null,
loadAverage: [],
cpu: null,
cpuCores: null,
cpuPerCore: [],
@@ -83,238 +124,325 @@ export function useServerStats({
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const hasFetchedRef = useRef(false);
const connectedAtRef = useRef(0);
const fetchGenerationRef = useRef(0);
// Auto-disable polling after a few consecutive failures. This covers
// hosts the banner classifier could not identify (e.g. Juniper JUNOS,
// Arista EOS, Cisco NX-OS — all of which advertise themselves as
// OpenSSH but do not support the POSIX stats shell command). Without
// this, the hook would keep retrying forever and generate an AAA
// session log every refresh interval.
const CONSECUTIVE_FAILURE_LIMIT = 3;
const consecutiveFailuresRef = useRef(0);
const givenUpRef = useRef(false);
};
}
const fetchStats = useCallback(async () => {
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
return;
}
if (givenUpRef.current) {
return;
}
function createInitialState(): ServerStatsState {
return {
stats: createEmptyServerStats(),
isLoading: false,
error: null,
};
}
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) {
return;
}
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
const existing = serverStatsSessions.get(sessionId);
if (existing) return existing;
const generation = ++fetchGenerationRef.current;
setIsLoading(true);
setError(null);
const session: SharedServerStatsSession = {
sessionId,
state: createInitialState(),
listeners: new Set(),
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
consecutiveFailures: 0,
givenUp: false,
pollIntervalMs: null,
pollingActive: false,
inflight: null,
inflightGeneration: null,
};
serverStatsSessions.set(sessionId, session);
return session;
}
const markFailure = (message: string) => {
consecutiveFailuresRef.current += 1;
setError(message);
if (consecutiveFailuresRef.current >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. The caller's useEffect sees the
// givenUp flag via the next render cycle and we also clear the
// interval locally so no further ticks fire.
givenUpRef.current = true;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
};
function emitServerStatsState(session: SharedServerStatsSession): void {
for (const listener of session.listeners) {
listener(session.state);
}
}
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
session.state = { ...session.state, ...patch };
emitServerStatsState(session);
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
}
if (session.intervalRef) {
clearInterval(session.intervalRef);
session.intervalRef = null;
}
session.pollingActive = false;
session.pollIntervalMs = null;
}
function resetServerStatsSession(session: SharedServerStatsSession): void {
clearServerStatsTimers(session);
session.connectedAt = 0;
session.hasFetched = false;
session.fetchGeneration += 1;
session.consecutiveFailures = 0;
session.givenUp = false;
updateServerStatsState(session, createInitialState());
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
));
}
function getVisibleServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return getActiveServerStatsClients(session).filter((client) => client.isVisible);
}
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
return {
hostname: stats.hostname,
osName: stats.osName,
kernelRelease: stats.kernelRelease,
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
memBuffers: stats.memBuffers ?? null,
memCached: stats.memCached ?? null,
swapTotal: stats.swapTotal ?? null,
swapUsed: stats.swapUsed ?? null,
topProcesses: stats.topProcesses || [],
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
netInterfaces: stats.netInterfaces || [],
lastUpdated: Date.now(),
};
}
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
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.
session.givenUp = true;
clearServerStatsTimers(session);
}
}
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
if (!session.sessionId) return;
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
await session.inflight;
return;
}
if (session.givenUp && !force) return;
if (getActiveServerStatsClients(session).length === 0) return;
if (!force && getVisibleServerStatsClients(session).length === 0) return;
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return;
if (force) {
session.givenUp = false;
session.consecutiveFailures = 0;
}
const generation = session.fetchGeneration;
session.inflightGeneration = generation;
updateServerStatsState(session, { isLoading: true, error: null });
const request = (async () => {
try {
const result = await bridge.getServerStats(sessionId);
const result = await bridge.getServerStats(session.sessionId);
// Discard stale responses from before a hide/show cycle or reconnect
if (!isMountedRef.current || generation !== fetchGenerationRef.current) return;
// Discard stale responses from before a hide/show cycle or reconnect.
if (generation !== session.fetchGeneration) return;
if (result.pending) {
// Transient "not ready yet" — e.g. a Mosh session whose SSH handshake
// hasn't finished, so the stats companion connection can't exist yet
// (issue #1198). Do NOT count this toward the consecutive-failure
// give-up, or a slow/manual handshake would permanently disable stats
// before the credentials become available. Just wait for the next poll.
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
// has not finished. Do not count this toward the hard-failure cutoff.
return;
}
if (result.success && result.stats) {
hasFetchedRef.current = true;
consecutiveFailuresRef.current = 0;
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
cpuPerCore: result.stats.cpuPerCore || [],
memTotal: result.stats.memTotal,
memUsed: result.stats.memUsed,
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
swapTotal: result.stats.swapTotal ?? null,
swapUsed: result.stats.swapUsed ?? null,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
diskTotal: result.stats.diskTotal,
disks: result.stats.disks || [],
netRxSpeed: result.stats.netRxSpeed || 0,
netTxSpeed: result.stats.netTxSpeed || 0,
latencyMs: Number.isFinite(result.stats.latencyMs) ? result.stats.latencyMs : null,
netInterfaces: result.stats.netInterfaces || [],
lastUpdated: Date.now(),
session.hasFetched = true;
session.consecutiveFailures = 0;
updateServerStatsState(session, {
stats: normalizeServerStats(result.stats),
error: null,
});
} else if (result.error) {
markFailure(result.error);
markServerStatsFailure(session, result.error);
} else {
// Response was not marked as success but has no error — treat as
// a soft failure. This happens e.g. when the stats shell pipeline
// returns a parse failure on a host that isn't a typical Linux
// distro (JUNOS, NX-OS, EOS).
markFailure('No stats returned');
markServerStatsFailure(session, 'No stats returned');
}
} catch (err) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
markFailure(err instanceof Error ? err.message : 'Unknown error');
if (generation === session.fetchGeneration) {
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setIsLoading(false);
if (generation === session.fetchGeneration) {
updateServerStatsState(session, { isLoading: false });
}
}
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
})();
// When the session changes (e.g., same tab reconnects to a different host
// while staying connected), reset the failure counter. Without this, a
// JUNOS session that tripped the counter would permanently suppress
// polling even after the tab reconnects to a Linux host.
useEffect(() => {
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
}, [sessionId]);
// Initial fetch and periodic refresh
useEffect(() => {
isMountedRef.current = true;
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
session.inflight = request;
try {
await request;
} finally {
if (session.inflight === request) {
session.inflight = null;
session.inflightGeneration = null;
}
}
}
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats and fetch state when disabled or not connected.
// Also reset the give-up flag so that a reconnect (possibly to a
// different host at the same sessionId slot) gets a fresh chance.
hasFetchedRef.current = false;
connectedAtRef.current = 0;
consecutiveFailuresRef.current = 0;
givenUpRef.current = false;
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
const activeClients = getActiveServerStatsClients(session);
setStats({
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
if (activeClients.length === 0) {
resetServerStatsSession(session);
return;
}
if (session.connectedAt === 0) {
session.connectedAt = Date.now();
}
const visibleClients = activeClients.filter((client) => client.isVisible);
if (visibleClients.length === 0) {
clearServerStatsTimers(session);
session.fetchGeneration += 1;
return;
}
const intervalMs = Math.max(5, Math.min(...visibleClients.map((client) => client.refreshInterval))) * 1000;
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
if (!shouldRestartPolling) return;
if (session.givenUp) return;
const wasPolling = session.pollingActive;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
session.pollingActive = true;
session.pollIntervalMs = intervalMs;
// When resuming from hidden, reset delta-based network stats so the first
// visible sample does not show throughput averaged across the hidden time.
if (session.hasFetched && !wasPolling) {
updateServerStatsState(session, {
stats: {
...session.state.stats,
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
});
return;
}
netInterfaces: session.state.stats.netInterfaces.map((iface) => ({
...iface,
rxSpeed: 0,
txSpeed: 0,
})),
},
});
}
// Track when the connection became available for delay calculation
// (must be before the isVisible check so hidden tabs record connection time)
if (connectedAtRef.current === 0) {
connectedAtRef.current = Date.now();
}
const connectionAge = Date.now() - session.connectedAt;
const needsWarmup = !session.hasFetched && connectionAge < 2000;
if (!isVisible) {
return () => {
isMountedRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}
session.initialTimerRef = setTimeout(() => {
session.initialTimerRef = null;
void fetchSharedServerStats(session);
}, needsWarmup ? 2000 : 0);
session.intervalRef = setInterval(() => {
void fetchSharedServerStats(session);
}, intervalMs);
}
// Invalidate any in-flight request from a previous visible/hidden cycle
// so stale responses don't overwrite the reset network stats below.
fetchGenerationRef.current++;
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
if (session.clients.size > 0 || session.listeners.size > 0) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
serverStatsSessions.delete(session.sessionId);
}
// Fetch immediately when resuming from hidden, or with a delay on first connect.
// When resuming, reset delta-based network stats (both aggregate and per-interface)
// so the first sample doesn't show averaged-over-hidden-interval throughput.
if (hasFetchedRef.current) {
setStats(prev => ({
...prev,
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: prev.netInterfaces.map(iface => ({ ...iface, rxSpeed: 0, txSpeed: 0 })),
}));
}
// Skip the warmup delay if the connection has been established long enough
// (e.g., tab was hidden while connected and is now becoming visible).
const connectionAge = Date.now() - connectedAtRef.current;
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
// If we already gave up on this session (exceeded the consecutive
// failure limit), don't even schedule new timers on effect reruns
// such as visibility/tab-focus/settings changes. The cleanup at
// disconnect/sessionId change clears the flag for a fresh attempt.
const initialTimer = givenUpRef.current
? null
: setTimeout(fetchStats, needsWarmup ? 2000 : 0);
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
}: UseServerStatsOptions) {
const clientIdRef = useRef<number | null>(null);
if (clientIdRef.current === null) {
clientIdRef.current = nextClientId;
nextClientId += 1;
}
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
if (!givenUpRef.current) {
intervalRef.current = setInterval(fetchStats, intervalMs);
}
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
const listener: ServerStatsListener = (nextState) => {
setState(nextState);
};
session.listeners.add(listener);
setState(session.state);
return () => {
isMountedRef.current = false;
if (initialTimer) clearTimeout(initialTimer);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
session.listeners.delete(listener);
session.clients.delete(clientId);
reconcileSharedServerStatsSession(session);
maybeDisposeSharedServerStatsSession(session);
};
}, [enabled, isSupportedOs, isConnected, isVisible, refreshInterval, fetchStats]);
}, [sessionId]);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
session.clients.set(clientId, {
enabled,
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
});
setState(session.state);
reconcileSharedServerStatsSession(session);
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected, isVisible]);
// Manual refresh function
const refresh = useCallback(() => {
fetchStats();
}, [fetchStats]);
const session = getSharedServerStatsSession(sessionId);
void fetchSharedServerStats(session, true).finally(() => {
reconcileSharedServerStatsSession(session);
});
}, [sessionId]);
return {
stats,
isLoading,
error,
stats: state.stats,
isLoading: state.isLoading,
error: state.error,
refresh,
};
}
@@ -56,10 +56,11 @@ test("manual reconnect captures restore cwd intent before clearing restored stat
const prepareDefinitionIndex = source.indexOf("const prepareRestoredReconnect = useCallback");
const captureAssignIndex = source.indexOf("restoreCwdIntentRef.current =", prepareDefinitionIndex);
const captureCallIndex = source.indexOf("resolveRestoreCwdIntent", captureAssignIndex);
const manualRetryIndex = source.indexOf("const handleRetry = () =>");
const manualPrepareIndex = source.indexOf("prepareRestoredReconnect();", manualRetryIndex);
const reconnectIndex = source.indexOf("const startReconnect = ");
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const manualPrepareIndex = source.indexOf("prepareRestoredReconnect();", manualBranchIndex);
const bootActiveIndex = source.indexOf("isBootActiveRef.current = true", manualPrepareIndex);
const connectingIndex = source.indexOf('updateStatus("connecting")');
const connectingIndex = source.indexOf('updateStatus("connecting")', manualPrepareIndex);
const startNewSessionIndex = source.indexOf("const startNewSession = () =>", connectingIndex);
assert.notEqual(importIndex, -1);
@@ -68,7 +69,8 @@ test("manual reconnect captures restore cwd intent before clearing restored stat
assert.notEqual(prepareDefinitionIndex, -1);
assert.notEqual(captureCallIndex, -1);
assert.notEqual(captureAssignIndex, -1);
assert.notEqual(manualRetryIndex, -1);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(manualPrepareIndex, -1);
assert.notEqual(bootActiveIndex, -1);
assert.notEqual(connectingIndex, -1);
@@ -83,6 +85,68 @@ test("manual reconnect captures restore cwd intent before clearing restored stat
);
});
test("auto reconnect connected history ref is initialized after status state exists", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const statusStateIndex = source.indexOf('const [status, setStatus] = useState<TerminalSession["status"]>');
const hasEverConnectedIndex = source.indexOf("const hasEverConnectedRef = useRef");
assert.notEqual(statusStateIndex, -1);
assert.notEqual(hasEverConnectedIndex, -1);
assert.ok(
statusStateIndex < hasEverConnectedIndex,
"auto reconnect refs must not read status before the status state is initialized",
);
});
test("auto reconnect wakes a hibernated terminal before requiring a terminal instance", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const hibernatedAutoBranchIndex = source.indexOf('mode === "auto" && hibernatedRef.current', reconnectIndex);
const wakeCallIndex = source.indexOf("wakeHibernatedRuntimeForReconnectRef.current", hibernatedAutoBranchIndex);
const missingTermReturnIndex = source.indexOf("if (!termRef.current) return;", reconnectIndex);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(hibernatedAutoBranchIndex, -1);
assert.notEqual(wakeCallIndex, -1);
assert.notEqual(missingTermReturnIndex, -1);
assert.ok(
hibernatedAutoBranchIndex < missingTermReturnIndex && wakeCallIndex < missingTermReturnIndex,
"auto reconnect must wake fully hibernated SSH sessions before the terminal guard can stop the retry",
);
});
test("dismissing the disconnected dialog returns focus to the terminal for enter reconnect", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const dismissIndex = source.indexOf("const handleDismissDisconnectedDialog = () =>");
const dismissedIndex = source.indexOf("setIsDisconnectedDialogDismissed(true)", dismissIndex);
const focusIndex = source.indexOf("queueMicrotask(() => termRef.current?.focus())", dismissIndex);
const closeSessionIndex = source.indexOf("const handleCloseDisconnectedSession = () =>", dismissIndex);
assert.notEqual(dismissIndex, -1);
assert.notEqual(dismissedIndex, -1);
assert.notEqual(focusIndex, -1);
assert.notEqual(closeSessionIndex, -1);
assert.ok(
dismissedIndex < focusIndex && focusIndex < closeSessionIndex,
"dismissing the disconnected dialog should leave Enter routed back through the terminal",
);
});
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
const viewIndex = source.indexOf("<TerminalView ctx={{");
const passEffectiveIndex = source.indexOf("isComposeBarOpen: effectiveComposeBarOpen", viewIndex);
assert.notEqual(effectiveDefinitionIndex, -1);
assert.notEqual(viewIndex, -1);
assert.notEqual(passEffectiveIndex, -1);
assert.ok(
effectiveDefinitionIndex < passEffectiveIndex,
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
);
});
test("startup and attach cwd cache clears preserve restore cwd metadata", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
@@ -15,6 +15,7 @@ import {
closeOrphanBackendSession,
getFlowController,
isTerminalBootActive,
notePendingOutputScrollIfEnabled,
resetTerminalLineTimestampState,
tryAttachSessionToTerminal,
writeSessionData,
@@ -1303,6 +1304,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
setTimeout(() => {
if (ctx.isVisibleRef?.current === false) {
notePendingOutputScrollIfEnabled(ctx);
return;
}
if (!ctx.fitAddonRef.current) return;
try {
ctx.fitAddonRef.current.fit();
@@ -36,10 +36,7 @@ import { isMacPlatform } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
isEraseScrollbackSequence,
isEraseViewportSequence,
preserveTerminalViewportInScrollback,
shouldPreserveViewportBeforeFullErase,
installEraseInDisplayHandlers,
} from "../clearTerminalViewport";
import {
createKittyKeyboardModeState,
@@ -1206,20 +1203,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
},
);
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
if (isEraseViewportSequence(params)) {
if (shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, wipeAllowed)) {
preserveTerminalViewportInScrollback(term);
}
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
// Honor it unless the user opts into the legacy "preserve history" behavior.
return !wipeAllowed;
const eraseScrollbackDisposable = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true,
isInDec2026SyncBlock: () => inDec2026SyncBlock,
});
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
@@ -3,10 +3,14 @@ import test from "node:test";
import {
isMiddleClickContextMenuEvent,
isShiftSelectionReplayMouseEvent,
markMiddleClickContextMenuEvent,
markShiftSelectionReplayMouseEvent,
captureMiddleClickTerminalMouseEvent,
resolveMiddleClickBehavior,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopShiftRightClickMouseTrackingMouseDown,
} from "./middleClickBehavior";
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
@@ -53,6 +57,187 @@ test("mouse-tracking context menu capture lets middle-click menu events pass thr
);
});
test("mouse-tracking context menu capture lets Shift-modified mouse events pass through", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
});
test("Shift selection replay events are identifiable", () => {
const event = {} as MouseEvent;
assert.equal(isShiftSelectionReplayMouseEvent(event), false);
assert.equal(isShiftSelectionReplayMouseEvent(markShiftSelectionReplayMouseEvent(event)), true);
});
test("macOS mouse tracking replays plain Shift left-click as xterm option selection", () => {
const event = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
true,
);
});
test("Shift selection replay is limited to the macOS connected mouse-tracking case", () => {
const baseEvent = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: false,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: false,
status: "connected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: true,
status: "disconnected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: { ...baseEvent, button: 2 } as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: { ...baseEvent, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
});
test("Shift selection replay ignores modified and already replayed mouse events", () => {
const baseEvent = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
for (const event of [
{ ...baseEvent, altKey: true },
{ ...baseEvent, ctrlKey: true },
{ ...baseEvent, metaKey: true },
markShiftSelectionReplayMouseEvent({ ...baseEvent } as MouseEvent),
]) {
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: event as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
}
});
test("Shift right-click mousedown is stopped while connected mouse tracking is active", () => {
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 2,
shiftKey: true,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
});
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
const baseEvent = {
button: 2,
shiftKey: true,
} as MouseEvent;
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: baseEvent,
mouseTracking: false,
status: "connected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: baseEvent,
mouseTracking: true,
status: "disconnected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 2,
shiftKey: false,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 0,
shiftKey: true,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
});
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
const calls: string[] = [];
const middleClickEvent = {
@@ -7,12 +7,31 @@ type MiddleClickContextMenuEvent = MouseEvent & {
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
};
const SHIFT_SELECTION_REPLAY_EVENT_KEY = "__netcattyShiftSelectionReplay";
type ShiftSelectionReplayMouseEvent = MouseEvent & {
[SHIFT_SELECTION_REPLAY_EVENT_KEY]?: boolean;
};
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
}
export interface ShiftMouseSelectionReplayState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
isMacPlatform: boolean;
}
export interface ShiftRightClickMouseDownCaptureState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
}
export const resolveMiddleClickBehavior = (
settings?: MiddleClickSettings | null,
): MiddleClickBehavior => {
@@ -39,12 +58,72 @@ export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent =
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
export const markShiftSelectionReplayMouseEvent = (event: MouseEvent): MouseEvent => {
Object.defineProperty(event, SHIFT_SELECTION_REPLAY_EVENT_KEY, {
value: true,
configurable: true,
});
return event;
};
export const isShiftSelectionReplayMouseEvent = (event: MouseEvent): boolean =>
(event as ShiftSelectionReplayMouseEvent)[SHIFT_SELECTION_REPLAY_EVENT_KEY] === true;
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
status,
}: MouseTrackingContextMenuCaptureState): boolean =>
mouseTracking && status === "connected" && !isMiddleClickContextMenuEvent(event);
mouseTracking
&& status === "connected"
&& !event.shiftKey
&& !isMiddleClickContextMenuEvent(event);
export const shouldReplayShiftMouseSelectionAsMacOption = ({
event,
mouseTracking,
status,
isMacPlatform,
}: ShiftMouseSelectionReplayState): boolean =>
isMacPlatform
&& mouseTracking
&& status === "connected"
&& event.button === 0
&& event.shiftKey
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !isShiftSelectionReplayMouseEvent(event);
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
event,
mouseTracking,
status,
}: ShiftRightClickMouseDownCaptureState): boolean =>
mouseTracking
&& status === "connected"
&& event.button === 2
&& event.shiftKey;
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
bubbles: event.bubbles,
cancelable: event.cancelable,
composed: event.composed,
detail: event.detail,
view: event.view,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
altKey: true,
shiftKey: false,
metaKey: event.metaKey,
button: event.button,
buttons: event.buttons,
relatedTarget: event.relatedTarget,
}));
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
if (event.button !== 1) return false;
@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { forceXTermFontRemeasure } from "./terminalFontRemeasure";
test("forceXTermFontRemeasure uses xterm char size service when available", () => {
let measured = 0;
const term = {
_core: {
_charSizeService: {
measure() {
measured += 1;
},
},
},
options: {
fontSize: 14,
},
};
assert.equal(forceXTermFontRemeasure(term), true);
assert.equal(measured, 1);
assert.equal(term.options.fontSize, 14);
});
test("forceXTermFontRemeasure falls back to a restored font size nudge", () => {
let fontSize = 14;
const writes: number[] = [];
const term = {
options: {},
} as { options: { fontSize: number } };
Object.defineProperty(term.options, "fontSize", {
get: () => fontSize,
set: (next: number) => {
writes.push(next);
fontSize = next;
},
});
assert.equal(forceXTermFontRemeasure(term), true);
assert.equal(writes.length, 2);
assert.ok(writes[0] > 14);
assert.equal(writes[1], 14);
assert.equal(term.options.fontSize, 14);
});
test("forceXTermFontRemeasure reports unavailable when no measurement path exists", () => {
assert.equal(forceXTermFontRemeasure({}), false);
assert.equal(forceXTermFontRemeasure({ options: { fontSize: Number.NaN } }), false);
});
@@ -0,0 +1,28 @@
export type XTermFontRemeasureTarget = {
_core?: {
_charSizeService?: {
measure?: () => void;
};
};
options?: {
fontSize?: number;
};
};
export function forceXTermFontRemeasure(term: XTermFontRemeasureTarget): boolean {
const charSizeService = term._core?._charSizeService;
if (typeof charSizeService?.measure === "function") {
charSizeService.measure();
return true;
}
const options = term.options;
const fontSize = options?.fontSize;
if (typeof fontSize !== "number" || !Number.isFinite(fontSize)) return false;
// xterm remeasures fonts when fontSize changes. Nudge and restore the value
// so the measurement path runs without changing the user's effective size.
options.fontSize = fontSize + 0.001;
options.fontSize = fontSize;
return true;
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const attachmentSource = readFileSync(new URL('./terminalSessionAttachment.ts', import.meta.url), 'utf8');
const startersSource = readFileSync(new URL('./createTerminalSessionStarters.ts', import.meta.url), 'utf8');
const hiddenPostConnectFitGuard =
/setTimeout\(\(\) => \{\s*if \(ctx\.isVisibleRef\?\.current === false\) \{\s*notePendingOutputScrollIfEnabled\(ctx\);\s*return;\s*\}\s*if \(!ctx\.fitAddonRef\.current\) return;[\s\S]*ctx\.fitAddonRef\.current\.fit\(\)/;
test('reattached sessions do not fit hidden terminal panes after first output', () => {
assert.match(attachmentSource, hiddenPostConnectFitGuard);
});
test('local sessions do not fit hidden terminal panes after first output', () => {
assert.match(startersSource, hiddenPostConnectFitGuard);
});
test('hidden post-connect scroll recovery respects the scroll-on-output setting', () => {
assert.match(
attachmentSource,
/export const notePendingOutputScrollIfEnabled[\s\S]*shouldScrollOnTerminalOutput\(settings\)[\s\S]*ctx\.pendingOutputScrollRef\.current = true/,
);
});
@@ -6,11 +6,13 @@ import {
FLOW_HIGH_WATER_MARK,
FLOW_CHAR_COUNT_ACK_SIZE,
FLOW_LOW_WATER_MARK,
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
XTERM_WRITE_CALLBACK_BATCH_BYTES,
} from "./terminalFlowConstants.ts";
import {
attachSessionToTerminal,
getFlowController,
notePendingOutputScrollIfEnabled,
tryAttachSessionToTerminal,
writeSessionData,
} from "./terminalSessionAttachment.ts";
@@ -81,6 +83,79 @@ const createContext = (showLineTimestamps: boolean, host: Record<string, unknown
promptLineBreakStateRef: { current: undefined },
});
const withDocumentVisibility = (visibilityState: "visible" | "hidden", run: () => void) => {
const original = Object.getOwnPropertyDescriptor(globalThis, "document");
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
visibilityState,
hasFocus: () => visibilityState === "visible",
},
});
try {
run();
} finally {
if (original) {
Object.defineProperty(globalThis, "document", original);
} else {
Reflect.deleteProperty(globalThis, "document");
}
}
};
const withAnimationFrameQueue = (run: (frames: Array<FrameRequestCallback>) => void) => {
const originalRequest = Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame");
const originalCancel = Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame");
const frames: Array<FrameRequestCallback> = [];
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
},
});
Object.defineProperty(globalThis, "cancelAnimationFrame", {
configurable: true,
value: () => {},
});
try {
run(frames);
} finally {
if (originalRequest) {
Object.defineProperty(globalThis, "requestAnimationFrame", originalRequest);
} else {
Reflect.deleteProperty(globalThis, "requestAnimationFrame");
}
if (originalCancel) {
Object.defineProperty(globalThis, "cancelAnimationFrame", originalCancel);
} else {
Reflect.deleteProperty(globalThis, "cancelAnimationFrame");
}
}
};
test("notePendingOutputScrollIfEnabled leaves hidden output unmarked when scroll-on-output is disabled", () => {
const pendingOutputScrollRef = { current: false };
notePendingOutputScrollIfEnabled({
terminalSettingsRef: { current: { scrollOnOutput: false } },
pendingOutputScrollRef,
} as never);
assert.equal(pendingOutputScrollRef.current, false);
});
test("notePendingOutputScrollIfEnabled marks hidden output when scroll-on-output is enabled", () => {
const pendingOutputScrollRef = { current: false };
notePendingOutputScrollIfEnabled({
terminalSettingsRef: { current: { scrollOnOutput: true } },
pendingOutputScrollRef,
} as never);
assert.equal(pendingOutputScrollRef.current, true);
});
test("writeSessionData clears renderer backlog while deferring IPC ack", () => {
const term = {
buffer: { active: { type: "normal" } },
@@ -104,6 +179,120 @@ test("writeSessionData clears renderer backlog while deferring IPC ack", () => {
clearDeferredTerminalWriteAck(term);
});
test("writeSessionData flushes xterm writes while the page is hidden", () => {
clearTerminalSessionFlowAck("session-1");
const payload = "x".repeat(FLOW_CHAR_COUNT_ACK_SIZE + 1);
const writes: string[] = [];
const pendingCallbacks: Array<() => void> = [];
const writeBuffer = {
flushSync() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.();
}
},
};
const term = {
buffer: { active: { type: "normal" } },
_core: { _writeBuffer: writeBuffer },
write(data: string, callback?: () => void) {
writes.push(data);
if (callback) pendingCallbacks.push(callback);
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
isVisibleRef: { current: true },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
withDocumentVisibility("hidden", () => {
writeSessionData(ctx as never, term, payload);
});
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), payload);
assert.deepEqual(writes.map((write) => write.length), [FLOW_CHAR_COUNT_ACK_SIZE, 1]);
assert.equal(pendingCallbacks.length, 0);
assert.equal(getFlowController(ctx as never, term).pendingBytes(), 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
assert.equal(acked.reduce((total, bytes) => total + bytes, 0), payload.length);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData flushes pending coalesced output with the hidden-page fast path", () => {
clearTerminalSessionFlowAck("session-1");
const pendingPayload = `${Array.from({ length: 20 }, () => "x".repeat(1000)).join("\n")}\n`;
assert.ok(pendingPayload.length > MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES);
const currentPayload = "current\n";
const writes: string[] = [];
const pendingCallbacks: Array<() => void> = [];
const writeBuffer = {
flushSync() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.();
}
},
};
const term = {
buffer: { active: { type: "normal" } },
_core: { _writeBuffer: writeBuffer },
write(data: string, callback?: () => void) {
writes.push(data);
if (callback) pendingCallbacks.push(callback);
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
isVisibleRef: { current: true },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
withAnimationFrameQueue((frames) => {
withDocumentVisibility("visible", () => {
writeSessionData(ctx as never, term, pendingPayload);
});
assert.equal(frames.length, 1);
assert.deepEqual(writes, []);
withDocumentVisibility("hidden", () => {
writeSessionData(ctx as never, term, currentPayload);
});
});
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), `${pendingPayload}${currentPayload}`);
assert.deepEqual(
writes.map((write) => write.length),
[
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
pendingPayload.length - MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
currentPayload.length,
],
);
assert.equal(pendingCallbacks.length, 0);
assert.equal(getFlowController(ctx as never, term).pendingBytes(), 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
assert.equal(
acked.reduce((total, bytes) => total + bytes, 0),
pendingPayload.length + currentPayload.length,
);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData flushes deferred IPC acks before small output can leave the source paused", async () => {
clearTerminalSessionFlowAck("session-1");
const term = {
@@ -55,6 +55,7 @@ import {
} from "./terminalFlowAckBuffer";
import {
enqueueTerminalWrite,
flushTerminalWriteQueueBypassingTimers,
isTerminalWriteQueueInFloodMode,
setTerminalWriteQueueDropHandler,
} from "./terminalWriteQueue";
@@ -64,8 +65,10 @@ import {
teardownTerminalOutputPipeline,
} from "./terminalOutputPipeline";
import {
flushTerminalWriteBufferBypassingTimers,
maybeFlushTerminalWriteCoalescerWhenUnfocused,
scheduleTerminalRepaintWhenUnfocused,
shouldFlushTerminalWritesForHiddenPage,
} from "./terminalUnfocusedRepaint";
export { FLOW_HIGH_WATER_MARK, FLOW_LOW_WATER_MARK };
@@ -94,17 +97,41 @@ const handleTerminalOutputAutoScroll = (
}
if (ctx.isVisibleRef?.current === false) {
if (ctx.pendingOutputScrollRef) {
ctx.pendingOutputScrollRef.current = true;
}
notePendingOutputScrollIfEnabled(ctx);
return;
}
term.scrollToBottom();
};
export const notePendingOutputScrollIfEnabled = (
ctx: TerminalSessionStartersContext,
): void => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
if (!shouldScrollOnTerminalOutput(settings)) return;
if (ctx.pendingOutputScrollRef) {
ctx.pendingOutputScrollRef.current = true;
}
};
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
type TerminalSessionWriteOptions = CoalescedTerminalWriteOptions & {
flushXtermWriteBuffer?: boolean;
};
const HIDDEN_PAGE_FLUSH_MAX_PASSES = 64;
const flushTerminalWritesForHiddenPage = (term: XTerm): void => {
flushTerminalWriteBufferBypassingTimers(term);
for (let pass = 0; pass < HIDDEN_PAGE_FLUSH_MAX_PASSES; pass += 1) {
if (!flushTerminalWriteQueueBypassingTimers(term)) {
return;
}
flushTerminalWriteBufferBypassingTimers(term);
}
};
export const getFlowControllerForTerm = (term: XTerm): OutputFlowController | undefined =>
terminalFlowControllers.get(term);
@@ -180,15 +207,33 @@ export const writeSessionData = (
ingressBytes: number = data.length,
) => {
const flow = getFlowController(ctx, term);
const isPaneVisible = ctx.isVisibleRef?.current !== false;
flow.received(ingressBytes);
setTerminalOutputPressureVisibility(term, ctx.isVisibleRef?.current !== false);
setTerminalOutputPressureVisibility(term, isPaneVisible);
noteTerminalOutputPressureData(term, data);
if (shouldFlushTerminalWritesForHiddenPage(isPaneVisible)) {
const writeHiddenPageData = (
batch: string,
batchIngress: number,
): void => {
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
flushXtermWriteBuffer: true,
});
flushTerminalWritesForHiddenPage(term);
};
flushTerminalWriteCoalescer(term, writeHiddenPageData);
flushTerminalWritesForHiddenPage(term);
enqueueCoalescedTerminalWrite(term, data, writeHiddenPageData, ingressBytes);
flushTerminalWriteCoalescer(term, writeHiddenPageData);
flushTerminalWritesForHiddenPage(term);
return;
}
enqueueCoalescedTerminalWrite(term, data, (batch, batchIngress, writeOptions) => {
writeSessionDataImmediate(ctx, term, batch, batchIngress, writeOptions);
}, ingressBytes);
maybeFlushTerminalWriteCoalescerWhenUnfocused(
term,
ctx.isVisibleRef?.current !== false,
isPaneVisible,
);
};
@@ -197,7 +242,7 @@ const writeSessionDataImmediate = (
term: XTerm,
data: string,
ingressBytes: number = data.length,
writeOptions: CoalescedTerminalWriteOptions = {},
writeOptions: TerminalSessionWriteOptions = {},
) => {
const flow = getFlowController(ctx, term);
enqueueTerminalWrite(term, ingressBytes, (done) => {
@@ -254,7 +299,8 @@ const writeSessionDataImmediate = (
flushIpcAck(clearDeferredTerminalWriteAck(term));
};
const deferredBeforeWrite = getDeferredTerminalWriteAckBytes(term);
const deferFlowAck = !forcePromptNewLine
const deferFlowAck = !writeOptions.flushXtermWriteBuffer
&& !forcePromptNewLine
&& shouldDeferTerminalWriteCallback(
preparedDisplayData.length,
deferredBeforeWrite,
@@ -263,8 +309,15 @@ const writeSessionDataImmediate = (
XTERM_WRITE_CALLBACK_BATCH_BYTES,
);
const writePreparedDisplayData = (callback: () => void): void => {
writeTerminalDataWithLineTimestamps(term, preparedDisplayData, callback);
if (writeOptions.flushXtermWriteBuffer) {
flushTerminalWriteBufferBypassingTimers(term);
}
};
if (deferFlowAck) {
writeTerminalDataWithLineTimestamps(term, preparedDisplayData, () => {
writePreparedDisplayData(() => {
finishQueueItem();
flow.written(ingressBytes);
const deferredTotal = accumulateDeferredTerminalWriteAck(term, ingressBytes);
@@ -279,7 +332,7 @@ const writeSessionDataImmediate = (
const deferredBeforeCallback = clearDeferredTerminalWriteAck(term);
const ackOnCallback = deferredBeforeCallback + ingressBytes;
writeTerminalDataWithLineTimestamps(term, preparedDisplayData, () => {
writePreparedDisplayData(() => {
finishQueueItem();
flow.written(ingressBytes);
if (deferredBeforeCallback > 0) {
@@ -409,6 +462,10 @@ export const attachSessionToTerminal = (
ctx.updateStatus("connected");
opts?.onConnected?.();
setTimeout(() => {
if (ctx.isVisibleRef?.current === false) {
notePendingOutputScrollIfEnabled(ctx);
return;
}
if (!ctx.fitAddonRef.current) return;
try {
ctx.fitAddonRef.current.fit();
@@ -3,9 +3,31 @@ import test from "node:test";
import { readFileSync } from "node:fs";
import {
flushTerminalWriteBufferBypassingTimers,
forceTerminalRepaintBypassingAnimationFrame,
shouldFlushTerminalWritesForHiddenPage,
} from "./terminalUnfocusedRepaint.ts";
const withDocumentVisibility = (visibilityState: "visible" | "hidden", run: () => void) => {
const original = Object.getOwnPropertyDescriptor(globalThis, "document");
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
visibilityState,
hasFocus: () => visibilityState === "visible",
},
});
try {
run();
} finally {
if (original) {
Object.defineProperty(globalThis, "document", original);
} else {
Reflect.deleteProperty(globalThis, "document");
}
}
};
test("isTerminalWindowUnfocusedButVisible checks visible page without focus", () => {
const source = readFileSync(
new URL("./terminalUnfocusedRepaint.ts", import.meta.url),
@@ -38,6 +60,69 @@ test("forceTerminalRepaintBypassingAnimationFrame refreshes alternate-screen vie
assert.equal(renderRowsCalled, true);
});
test("shouldFlushTerminalWritesForHiddenPage only flushes visible panes on hidden pages", () => {
withDocumentVisibility("hidden", () => {
assert.equal(shouldFlushTerminalWritesForHiddenPage(true), true);
assert.equal(shouldFlushTerminalWritesForHiddenPage(false), false);
});
withDocumentVisibility("visible", () => {
assert.equal(shouldFlushTerminalWritesForHiddenPage(true), false);
});
});
test("flushTerminalWriteBufferBypassingTimers drains xterm's internal write buffer", () => {
let flushed = false;
const writeBuffer = {
flushSync() {
flushed = this === writeBuffer;
},
};
const term = {
_core: {
_writeBuffer: writeBuffer,
},
};
flushTerminalWriteBufferBypassingTimers(term as never);
assert.equal(flushed, true);
});
test("flushTerminalWriteBufferBypassingTimers skips already parsed xterm chunks", () => {
const processed: string[] = [];
let oldCallbackCalled = false;
let pendingCallbackCalled = false;
const writeBuffer = {
_bufferOffset: 1,
_callbacks: [
() => { oldCallbackCalled = true; },
() => { pendingCallbackCalled = true; },
] as Array<() => void>,
_pendingData: "pending".length,
_writeBuffer: ["already-parsed", "pending"],
flushSync() {
while (this._writeBuffer.length > 0) {
processed.push(this._writeBuffer.shift()!);
this._callbacks.shift()?.();
}
this._pendingData = 0;
this._bufferOffset = 0;
},
};
const term = {
_core: {
_writeBuffer: writeBuffer,
},
};
flushTerminalWriteBufferBypassingTimers(term as never);
assert.deepEqual(processed, ["pending"]);
assert.equal(oldCallbackCalled, false);
assert.equal(pendingCallbackCalled, true);
assert.equal(writeBuffer._pendingData, 0);
});
test("maybeFlushTerminalWriteCoalescerWhenUnfocused throttles coalescer flushes", () => {
const source = readFileSync(
new URL("./terminalUnfocusedRepaint.ts", import.meta.url),
@@ -63,10 +148,22 @@ test("writeSessionData schedules a throttled coalescer flush when unfocused", ()
);
assert.match(
source,
/maybeFlushTerminalWriteCoalescerWhenUnfocused\(\s*term,\s*ctx\.isVisibleRef\?\.current !== false,\s*\)/,
/maybeFlushTerminalWriteCoalescerWhenUnfocused\(\s*term,\s*isPaneVisible,\s*\)/,
);
});
test("writeSessionData bypasses animation-frame coalescing on hidden pages", () => {
const source = readFileSync(
new URL("./terminalSessionAttachment.ts", import.meta.url),
"utf8",
);
assert.match(source, /shouldFlushTerminalWritesForHiddenPage\(isPaneVisible\)/);
assert.match(source, /flushTerminalWriteCoalescer\(term, writeHiddenPageData\)/);
assert.match(source, /enqueueCoalescedTerminalWrite\(term, data, writeHiddenPageData, ingressBytes\)/);
assert.match(source, /flushTerminalWriteQueueBypassingTimers\(term\)/);
assert.match(source, /const deferFlowAck = !writeOptions\.flushXtermWriteBuffer/);
});
test("writeSessionDataImmediate schedules unfocused repaint only for visible panes", () => {
const source = readFileSync(
new URL("./terminalSessionAttachment.ts", import.meta.url),
@@ -12,11 +12,67 @@ const UNFOCUSED_FLUSH_DEBOUNCE_MS = 67;
const unfocusedRepaintTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
const unfocusedFlushTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
type XTermWithPrivateWriteBuffer = XTerm & {
_core?: {
_writeBuffer?: {
flushSync?: () => void;
_bufferOffset?: number;
_callbacks?: Array<(() => void) | undefined>;
_pendingData?: number;
_writeBuffer?: Array<string | Uint8Array>;
};
};
};
type XTermPrivateWriteBuffer = NonNullable<
NonNullable<XTermWithPrivateWriteBuffer["_core"]>["_writeBuffer"]
>;
export function isTerminalWindowUnfocusedButVisible(): boolean {
if (typeof document === "undefined") return false;
return document.visibilityState === "visible" && !document.hasFocus();
}
export function isTerminalPageHidden(): boolean {
if (typeof document === "undefined") return false;
return document.visibilityState !== "visible";
}
export function shouldFlushTerminalWritesForHiddenPage(isPaneVisible: boolean): boolean {
return isPaneVisible && isTerminalPageHidden();
}
function normalizeXtermWriteBufferOffset(writeBuffer: XTermPrivateWriteBuffer): void {
const buffer = writeBuffer._writeBuffer;
const callbacks = writeBuffer._callbacks;
const offset = writeBuffer._bufferOffset;
if (!Array.isArray(buffer) || !Array.isArray(callbacks) || typeof offset !== "number") {
return;
}
if (offset <= 0) return;
if (offset >= buffer.length) {
buffer.length = 0;
callbacks.length = 0;
writeBuffer._pendingData = 0;
writeBuffer._bufferOffset = 0;
return;
}
writeBuffer._writeBuffer = buffer.slice(offset);
writeBuffer._callbacks = callbacks.slice(offset);
writeBuffer._bufferOffset = 0;
}
export function flushTerminalWriteBufferBypassingTimers(term: XTerm): void {
const writeBuffer = (term as XTermWithPrivateWriteBuffer)._core?._writeBuffer;
if (typeof writeBuffer?.flushSync !== "function") return;
try {
normalizeXtermWriteBufferOffset(writeBuffer);
writeBuffer.flushSync();
} catch {
// Best-effort private xterm recovery; normal async writes will continue.
}
}
export function forceTerminalRepaintBypassingAnimationFrame(term: XTerm): void {
if (isTerminalAlternateScreenActive(term)) {
refreshTerminalViewport(term);
@@ -16,6 +16,32 @@ import {
} from "./terminalFlowConstants.ts";
const createFakeTerm = () => ({}) as XTerm;
const withAnimationFrameQueue = (run: () => void) => {
const originalRequest = Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame");
const originalCancel = Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame");
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: () => 1,
});
Object.defineProperty(globalThis, "cancelAnimationFrame", {
configurable: true,
value: () => {},
});
try {
run();
} finally {
if (originalRequest) {
Object.defineProperty(globalThis, "requestAnimationFrame", originalRequest);
} else {
Reflect.deleteProperty(globalThis, "requestAnimationFrame");
}
if (originalCancel) {
Object.defineProperty(globalThis, "cancelAnimationFrame", originalCancel);
} else {
Reflect.deleteProperty(globalThis, "cancelAnimationFrame");
}
}
};
test("splits a single flood-sized terminal batch before it reaches xterm", () => {
const term = createFakeTerm();
@@ -174,3 +200,35 @@ test("keeps control-sequence terminal batches intact up to the coalescing cap",
resetTerminalWriteCoalescer(term);
});
test("uses the latest coalesced writer when pending output is flushed", () => {
const term = createFakeTerm();
const firstWriter: string[] = [];
const secondWriter: string[] = [];
setTerminalWriteCoalescerByteCapResolver(term, () => 100);
withAnimationFrameQueue(() => {
enqueueCoalescedTerminalWrite(
term,
"pending",
(data) => {
firstWriter.push(data);
},
"pending".length,
);
enqueueCoalescedTerminalWrite(
term,
" output",
(data) => {
secondWriter.push(data);
},
" output".length,
);
flushTerminalWriteCoalescer(term);
});
assert.deepEqual(firstWriter, []);
assert.deepEqual(secondWriter, ["pending output"]);
resetTerminalWriteCoalescer(term);
});
@@ -13,10 +13,16 @@ export type CoalescedTerminalWriteOptions = {
deferStart?: boolean;
yieldAfter?: boolean;
};
type CoalescedTerminalWriteNow = (
data: string,
ingressBytes: number,
options?: CoalescedTerminalWriteOptions,
) => void;
const terminalWriteCoalescers = new WeakMap<XTerm, WriteCoalescer>();
const terminalWriteCoalescerIngress = new WeakMap<XTerm, number>();
const terminalWriteCoalescerByteCapResolvers = new WeakMap<XTerm, CoalescerByteCapResolver>();
const terminalWriteCoalescerWriters = new WeakMap<XTerm, CoalescedTerminalWriteNow>();
const defaultCoalescerByteCap = (): number => MAX_PENDING_WRITE_COALESCE_BYTES;
@@ -129,13 +135,10 @@ const writeLargeTerminalBatch = (
export const enqueueCoalescedTerminalWrite = (
term: XTerm,
data: string,
writeNow: (
data: string,
ingressBytes: number,
options?: CoalescedTerminalWriteOptions,
) => void,
writeNow: CoalescedTerminalWriteNow,
ingressBytes: number = data.length,
): void => {
terminalWriteCoalescerWriters.set(term, writeNow);
const maxPendingBytes = resolveCoalescerByteCap(term);
if (getPendingCoalescedBytes(term) + data.length > maxPendingBytes) {
flushTerminalWriteCoalescer(term);
@@ -159,11 +162,12 @@ export const enqueueCoalescedTerminalWrite = (
if (!coalescer) {
coalescer = createWriteCoalescer((batch) => {
const batchIngress = takePendingIngressBytes(term, batch.length);
const activeWriteNow = terminalWriteCoalescerWriters.get(term) ?? writeNow;
writeLargeTerminalBatch(
batch,
batchIngress,
resolveTerminalWriteBatchBytes(batch, resolveCoalescerByteCap(term)),
writeNow,
activeWriteNow,
);
}, {
getMaxPendingBytes: () => resolveCoalescerByteCap(term),
@@ -173,8 +177,25 @@ export const enqueueCoalescedTerminalWrite = (
coalescer.push(data);
};
export const flushTerminalWriteCoalescer = (term: XTerm): void => {
terminalWriteCoalescers.get(term)?.flushSync();
export const flushTerminalWriteCoalescer = (
term: XTerm,
writeNow?: CoalescedTerminalWriteNow,
): void => {
const coalescer = terminalWriteCoalescers.get(term);
if (!coalescer) return;
if (!writeNow) {
coalescer.flushSync();
return;
}
coalescer.flushSync((batch) => {
const batchIngress = takePendingIngressBytes(term, batch.length);
writeLargeTerminalBatch(
batch,
batchIngress,
resolveTerminalWriteBatchBytes(batch, resolveCoalescerByteCap(term)),
writeNow,
);
});
};
export const resetTerminalWriteCoalescer = (term: XTerm): void => {
@@ -182,6 +203,7 @@ export const resetTerminalWriteCoalescer = (term: XTerm): void => {
terminalWriteCoalescers.delete(term);
terminalWriteCoalescerIngress.delete(term);
terminalWriteCoalescerByteCapResolvers.delete(term);
terminalWriteCoalescerWriters.delete(term);
};
export const getTerminalWriteCoalescerPendingBytes = (term: XTerm): number =>
@@ -7,6 +7,7 @@ import {
MAX_WRITE_QUEUE_ITEMS,
abortTerminalWriteQueue,
enqueueTerminalWrite,
flushTerminalWriteQueueBypassingTimers,
getTerminalWriteQueueDepth,
isTerminalWriteQueueInFloodMode,
setTerminalWriteQueueDropHandler,
@@ -71,6 +72,25 @@ test("yieldAfter lets the event loop run between queued write chunks", async ()
assert.deepEqual(order, [1, 2]);
});
test("flushTerminalWriteQueueBypassingTimers drains deferred queue steps immediately", () => {
const term = createFakeTerm();
const order: number[] = [];
enqueueTerminalWrite(term, 1, (done) => {
order.push(1);
done();
}, { deferStart: true, yieldAfter: true });
enqueueTerminalWrite(term, 1, (done) => {
order.push(2);
done();
});
assert.deepEqual(order, []);
assert.equal(flushTerminalWriteQueueBypassingTimers(term), true);
assert.deepEqual(order, [1, 2]);
assert.equal(flushTerminalWriteQueueBypassingTimers(term), false);
});
test("marks flood mode and coalesces queued writes when item cap is exceeded", async () => {
const term = createFakeTerm();
const dropped: number[] = [];
@@ -245,6 +265,27 @@ test("merged flood backlog still yields between synchronous write steps", async
assert.deepEqual(order, [0, 1]);
});
test("flushTerminalWriteQueueBypassingTimers drains merged flood backlog without timer yields", () => {
const term = createFakeTerm();
const order: number[] = [];
for (let index = 0; index < MAX_WRITE_QUEUE_ITEMS + 1; index += 1) {
enqueueTerminalWrite(
term,
10,
(done) => {
order.push(index);
done();
},
{ deferStart: true, yieldAfter: true },
);
}
assert.equal(getTerminalWriteQueueDepth(term), 1);
assert.equal(flushTerminalWriteQueueBypassingTimers(term), true);
assert.deepEqual(order, Array.from({ length: MAX_WRITE_QUEUE_ITEMS + 1 }, (_, index) => index));
});
test("write queue yields when a drain reaches its byte budget", async () => {
const term = createFakeTerm();
const order: number[] = [];
@@ -36,6 +36,8 @@ type TerminalWriteQueue = {
floodMode: boolean;
onDropped?: (bytes: number) => void;
drainTimer?: ReturnType<typeof setTimeout>;
stepTimer?: ReturnType<typeof setTimeout>;
stepContinuation?: () => void;
};
const terminalWriteQueues = new WeakMap<XTerm, TerminalWriteQueue>();
@@ -110,6 +112,17 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
queue.drainBytes = 0;
}
scheduleQueueDrain(term, queue, next.yieldAfter);
}, (continuation) => {
const timer = setTimeout(() => {
if (terminalWriteQueues.get(term) !== queue || queue.stepTimer !== timer) {
return;
}
queue.stepTimer = undefined;
queue.stepContinuation = undefined;
continuation();
}, 0);
queue.stepTimer = timer;
queue.stepContinuation = continuation;
});
};
@@ -119,7 +132,11 @@ const resolveMaxDrainBytes = (value?: number): number => (
: MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES
);
const runQueuedWrite = (item: QueuedWrite, done: () => void): void => {
const runQueuedWrite = (
item: QueuedWrite,
done: () => void,
deferStep: (continuation: () => void) => void,
): void => {
let index = 0;
let completed = false;
let currentDrainBytes = 0;
@@ -144,7 +161,7 @@ const runQueuedWrite = (item: QueuedWrite, done: () => void): void => {
&& currentDrainBytes + step.bytes > item.maxDrainBytes
) {
currentDrainBytes = 0;
setTimeout(runNext, 0);
deferStep(runNext);
return;
}
index += 1;
@@ -156,7 +173,7 @@ const runQueuedWrite = (item: QueuedWrite, done: () => void): void => {
currentDrainBytes += step.bytes;
if ((step.yieldAfter || item.yieldAfter) && index < item.steps.length) {
currentDrainBytes = 0;
setTimeout(runNext, 0);
deferStep(runNext);
return;
}
runNext();
@@ -177,6 +194,29 @@ const runQueuedWrite = (item: QueuedWrite, done: () => void): void => {
runNext();
};
const runDeferredQueueStepNow = (queue: TerminalWriteQueue): boolean => {
const continuation = queue.stepContinuation;
if (!continuation) return false;
if (queue.stepTimer !== undefined) {
clearTimeout(queue.stepTimer);
}
queue.stepTimer = undefined;
queue.stepContinuation = undefined;
continuation();
return true;
};
const runDeferredQueueDrainNow = (
term: XTerm,
queue: TerminalWriteQueue,
): boolean => {
if (queue.drainTimer === undefined) return false;
clearTimeout(queue.drainTimer);
queue.drainTimer = undefined;
scheduleNextTerminalWrite(term, queue);
return true;
};
const mergePendingWrites = (queue: TerminalWriteQueue): void => {
if (queue.pending.length <= 1) return;
@@ -232,6 +272,29 @@ export const getTerminalWriteQueueDepth = (term: XTerm): number =>
export const isTerminalWriteQueueInFloodMode = (term: XTerm): boolean =>
terminalWriteQueues.get(term)?.floodMode ?? false;
export const flushTerminalWriteQueueBypassingTimers = (term: XTerm): boolean => {
let flushed = false;
for (let guard = 0; guard < 4096; guard += 1) {
const queue = terminalWriteQueues.get(term);
if (!queue) return flushed;
if (runDeferredQueueStepNow(queue)) {
flushed = true;
continue;
}
if (runDeferredQueueDrainNow(term, queue)) {
flushed = true;
continue;
}
if (!queue.writing && queue.pending.length > 0) {
scheduleNextTerminalWrite(term, queue);
flushed = true;
continue;
}
return flushed;
}
return flushed;
};
export const enqueueTerminalWrite = (
term: XTerm,
bytes: number,
@@ -294,6 +357,11 @@ export const abortTerminalWriteQueue = (
clearTimeout(queue.drainTimer);
queue.drainTimer = undefined;
}
if (queue.stepTimer !== undefined) {
clearTimeout(queue.stepTimer);
queue.stepTimer = undefined;
}
queue.stepContinuation = undefined;
terminalWriteQueues.delete(term);
if (droppedBytes > 0) {
@@ -23,7 +23,7 @@ export {
export type WriteCoalescer = {
push(chunk: string): void;
/** Flush pending bytes synchronously before ordered writes (exit notices). */
flushSync(): void;
flushSync(writeOverride?: (data: string) => void): void;
/** Drop pending bytes without writing (flood recovery / teardown). */
abort(onDropped?: (bytes: number) => void): void;
pendingBytes(): number;
@@ -69,7 +69,7 @@ export const createWriteCoalescer = (
}
};
const flushSync = (): void => {
const flushSync = (writeOverride?: (data: string) => void): void => {
cancelScheduledFrame();
if (pendingBytes === 0) {
return;
@@ -77,7 +77,7 @@ export const createWriteCoalescer = (
const batch = pending.length === 1 ? pending[0]! : pending.join("");
pending = [];
pendingBytes = 0;
write(batch);
(writeOverride ?? write)(batch);
};
const abort = (onDropped?: (bytes: number) => void): void => {
@@ -127,4 +127,4 @@ export const createWriteCoalescer = (
disposed = true;
},
};
};
};
+1 -9
View File
@@ -2,7 +2,7 @@ import type { DragEvent, PointerEvent } from "react";
import { Terminal as XTerm } from "@xterm/xterm";
import type { TerminalContextReader } from "../../domain/terminalContextRead";
import { getSessionConnectionLabel, resolveSessionTabTitle } from "../../domain/sessionTabTitle";
import { resolveSessionTabTitle } from "../../domain/sessionTabTitle";
import { logger } from "../../lib/logger";
import { getPathForFile, type DropEntry } from "../../lib/sftpFileUtils";
import { normalizeLineEndings } from "../../lib/utils";
@@ -28,14 +28,6 @@ export interface TerminalBroadcastInputOptions {
lineDelayMs?: number;
}
/**
* Get the static connection label for a terminal session.
* Uses customName if set, otherwise falls back to hostLabel.
*/
export function getSessionDisplayName(session: TerminalSession): string {
return getSessionConnectionLabel(session);
}
export { resolveSessionTabTitle };
/**
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('../Terminal.tsx', import.meta.url), 'utf8');
test('safeFit skips hidden terminal panes unless explicitly allowed', () => {
assert.match(
source,
/if \(!isVisibleRef\.current && !options\?\.allowHidden\) \{\s*lastFittedSizeRef\.current = null;\s*return;\s*\}/,
);
});
test('safeFit can run synchronously for first-frame tab recovery', () => {
assert.match(source, /immediate\?: boolean/);
assert.match(
source,
/XTERM_PERFORMANCE_CONFIG\.resize\.useRAF &&\s*typeof requestAnimationFrame === "function" &&\s*!options\?\.immediate/,
);
});
@@ -20,3 +20,71 @@ test('layout recovery refit also syncs PTY size for full-screen TUIs', () => {
assert.match(source, /runImmediateRefit\(\{ force: true, repeatOnNextFrame: false \}\);\s*finishLayoutRecoveryAfterFit\(\)/);
assert.match(source, /finishLayoutRecoveryAfterFit/);
});
test('tab-switch suppression does not consume the visible recovery pass', () => {
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
assert.match(
source,
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
);
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
});
test('immediate visibility recovery does not wait for the next animation frame', () => {
assert.match(source, /safeFit\(\{ force, requireVisible: true, immediate: true \}\)/);
assert.match(source, /safeFit\(\{ force: true, requireVisible: true, immediate: true \}\)/);
});
test('visible tab recovery reuses a cached fit when the container size is unchanged', () => {
assert.match(source, /const currentContainerSizeAlreadyFit = \(\) => \{/);
assert.match(
source,
/if \(currentContainerSizeAlreadyFit\(\)\) \{\s*finishLayoutRecovery\(\);\s*flushPendingOutputScroll\(\);\s*commitVisibleLayout\(\);\s*return;\s*\}/,
);
});
test('short unchanged tab reveals skip the recovery work entirely', () => {
const recoverIndex = source.indexOf('const recoverTerminalAfterBecomeVisible = () => {');
const fastPathIndex = source.indexOf('getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS', recoverIndex);
const webglRecoveryIndex = source.indexOf('xtermRuntimeRef.current?.ensureWebglRenderer();', recoverIndex);
assert.ok(recoverIndex >= 0);
assert.ok(fastPathIndex > recoverIndex);
assert.ok(webglRecoveryIndex > fastPathIndex);
assert.match(
source.slice(fastPathIndex, webglRecoveryIndex),
/currentContainerSizeAlreadyFit\(\)[\s\S]*lastWebglRecoveryLayoutKeyRef\.current = paneLayoutKey;[\s\S]*commitVisibleLayout\(\);[\s\S]*return;/,
);
});
test('short unchanged tab reveals still flush pending hidden-output scroll', () => {
const recoverIndex = source.indexOf('const recoverTerminalAfterBecomeVisible = () => {');
const fastPathIndex = source.indexOf('getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS', recoverIndex);
const webglRecoveryIndex = source.indexOf('xtermRuntimeRef.current?.ensureWebglRenderer();', recoverIndex);
const delayedSkipIndex = source.indexOf('lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey', webglRecoveryIndex);
const delayedTimerIndex = source.indexOf('const timer = setTimeout', delayedSkipIndex);
assert.match(
source,
/const flushPendingOutputScroll = \(\) => \{[\s\S]*pendingOutputScrollRef\.current[\s\S]*scrollToBottom\(\)[\s\S]*pendingOutputScrollRef\.current = false;/,
);
assert.match(
source.slice(fastPathIndex, webglRecoveryIndex),
/flushPendingOutputScroll\(\);[\s\S]*commitVisibleLayout\(\);[\s\S]*return;/,
);
assert.match(
source.slice(delayedSkipIndex, delayedTimerIndex),
/flushPendingOutputScroll\(\);\s*return;/,
);
});
test('immediate tab recovery marks webgl recovery to skip the delayed duplicate pass', () => {
assert.match(
source,
/const recoverTerminalAfterBecomeVisible = \(\) => \{[\s\S]*xtermRuntimeRef\.current\?\.clearTextureAtlas\(\);\s*lastWebglRecoveryLayoutKeyRef\.current = paneLayoutKey;/,
);
assert.match(
source,
/lastWebglRecoveryLayoutKeyRef\.current === paneLayoutKey\s*&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS/,
);
});
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("server stats retry can restart polling after give-up", () => {
const source = readFileSync(new URL("./hooks/useServerStats.ts", import.meta.url), "utf8");
const reconcileStart = source.indexOf("function reconcileSharedServerStatsSession");
const shouldRestartCheck = source.indexOf("const shouldRestartPolling", reconcileStart);
const givenUpCheck = source.indexOf("if (session.givenUp) return;", shouldRestartCheck);
const markPollingActive = source.indexOf("session.pollingActive = true", shouldRestartCheck);
assert.notEqual(reconcileStart, -1);
assert.notEqual(shouldRestartCheck, -1);
assert.notEqual(givenUpCheck, -1);
assert.notEqual(markPollingActive, -1);
assert.ok(givenUpCheck < markPollingActive);
});
test("server stats stale requests do not block a fresh visible fetch", () => {
const source = readFileSync(new URL("./hooks/useServerStats.ts", import.meta.url), "utf8");
assert.match(source, /inflightGeneration: number \| null/);
assert.match(source, /session\.inflight && session\.inflightGeneration === session\.fetchGeneration/);
assert.match(source, /session\.inflightGeneration = generation/);
});
+127 -30
View File
@@ -2,8 +2,14 @@
import { useRef } from 'react';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
import { bundledFamiliesInStack } from '../../lib/fontAvailability';
import { isMacPlatform } from '../../lib/utils';
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
import {
createMacOptionForcedSelectionMouseEvent,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopShiftRightClickMouseTrackingMouseDown,
} from './runtime/middleClickBehavior';
import {
hasOpenAppDialog,
TERMINAL_SESSION_RESTORE_FOCUS_EVENT,
@@ -26,6 +32,10 @@ import {
cancelScheduledUnfocusedRepaint,
forceTerminalRepaintBypassingAnimationFrame,
} from './runtime/terminalUnfocusedRepaint';
import {
forceXTermFontRemeasure,
type XTermFontRemeasureTarget,
} from './runtime/terminalFontRemeasure';
import {
isTerminalCloseGenerationCurrent,
resolveConnectionLogCapturePayload,
@@ -59,6 +69,8 @@ type ZmodemToastApi = {
error: (message: string, title?: string) => void;
};
const CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS = 3000;
export function resolveZmodemTransferToast(zmodem: ZmodemToastInput): ZmodemToast {
if (zmodem.active) return null;
if (zmodem.error) {
@@ -143,7 +155,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, 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, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, 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, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState } = ctx;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
@@ -712,10 +724,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (force) {
lastFittedSizeRef.current = null;
}
safeFit({ force, requireVisible: true });
safeFit({ force, requireVisible: true, immediate: true });
if (force && repeatOnNextFrame && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
safeFit({ force: true, requireVisible: true });
safeFit({ force: true, requireVisible: true, immediate: true });
});
}
};
@@ -811,28 +823,81 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
lastCommittedVisibleLayoutKeyRef.current = paneLayoutKey;
};
const currentContainerSizeAlreadyFit = () => {
const container = containerRef.current;
const lastSize = lastFittedSizeRef.current;
if (!container || !lastSize) return false;
const width = container.clientWidth;
const height = container.clientHeight;
return width > 0 && height > 0 && lastSize.width === width && lastSize.height === height;
};
const getHiddenDurationMs = () => (
hiddenAtRef.current !== null
? Date.now() - hiddenAtRef.current
: Number.POSITIVE_INFINITY
);
const flushPendingOutputScroll = () => {
if (!pendingOutputScrollRef.current) return;
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
termRef.current?.scrollToBottom();
});
}
pendingOutputScrollRef.current = false;
};
const recoverTerminalAfterBecomeVisible = () => {
lastCommittedVisibleLayoutKeyRef.current = null;
lastFittedSizeRef.current = null;
if (
getHiddenDurationMs() < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
&& currentContainerSizeAlreadyFit()
) {
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
flushPendingOutputScroll();
commitVisibleLayout();
return;
}
xtermRuntimeRef.current?.ensureWebglRenderer();
xtermRuntimeRef.current?.clearTextureAtlas();
lastWebglRecoveryLayoutKeyRef.current = paneLayoutKey;
if (currentContainerSizeAlreadyFit()) {
finishLayoutRecovery();
flushPendingOutputScroll();
commitVisibleLayout();
return;
}
lastFittedSizeRef.current = null;
runImmediateRefit({ force: true, repeatOnNextFrame: false });
finishLayoutRecoveryAfterFit();
flushPendingOutputScroll();
commitVisibleLayout();
scheduleLayoutRecoveryRefit([100, 350]);
};
// Refit synchronously when a split pane becomes visible or its bounds change.
// Tab switches move hidden panes off-screen without resizing xterm; becoming
// visible again does not always fire ResizeObserver, leaving a gap below content.
// Skip during split-divider drag — refit runs once when isResizing becomes false.
// Tab switches hide inactive panes without resizing xterm; becoming visible
// again does not always fire ResizeObserver, leaving a gap below content.
// Skip during split-divider drag — refit runs once when split resizing ends.
// In multi-split workspaces only the focused pane refits on tab switch; other
// visible panes defer so N terminals don't block the main thread together.
useLayoutEffect(() => {
const becameVisible = isVisible && !wasVisibleRef.current;
wasVisibleRef.current = isVisible;
if (!isVisible || isResizing) return;
if (!isVisible) {
wasVisibleRef.current = false;
return;
}
if (splitResizeActive) return;
wasVisibleRef.current = true;
if (becameVisible) {
if (shouldRefitImmediatelyOnShow()) {
@@ -854,7 +919,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
commitVisibleLayout();
runImmediateRefit({ force: true, repeatOnNextFrame: false });
finishLayoutRecoveryAfterFit();
}, [isVisible, paneLayoutKey, isResizing, inWorkspace, isFocusMode, isFocused]);
}, [isVisible, paneLayoutKey, splitResizeActive, inWorkspace, isFocusMode, isFocused]);
// Defer refit for non-focused split panes that became visible on a tab switch.
useEffect(() => {
@@ -904,8 +969,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (
lastWebglRecoveryLayoutKeyRef.current === paneLayoutKey
&& hiddenMs < 3000
&& hiddenMs < CSS_ONLY_TAB_REVEAL_MAX_HIDDEN_MS
) {
flushPendingOutputScroll();
return;
}
@@ -926,15 +992,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
xtermRuntimeRef.current?.clearTextureAtlas();
runImmediateRefit({ force: true });
finishLayoutRecoveryAfterFit();
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
termRef.current?.scrollToBottom();
});
}
pendingOutputScrollRef.current = false;
}
flushPendingOutputScroll();
}, 50);
return () => clearTimeout(timer);
}, [isVisible, paneLayoutKey, inWorkspace, isFocusMode, isFocused]);
@@ -952,17 +1010,22 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const term = termRef.current as {
cols: number;
rows: number;
renderer?: { remeasureFont?: () => void };
} | null;
} & XTermFontRemeasureTarget | null;
if (cancelled || !term) return;
if (!isVisibleRef.current) {
lastFittedSizeRef.current = null;
return;
}
const fitAddon = fitAddonRef.current;
try {
term.renderer?.remeasureFont?.();
if (!forceXTermFontRemeasure(term)) {
logger.warn("Font remeasure skipped: xterm measurement hook unavailable");
}
} catch (err) {
logger.warn("Font remeasure failed", err);
}
// remeasureFont does not invalidate cells rasterized before fonts were ready.
// Font remeasurement does not invalidate cells rasterized before fonts were ready.
xtermRuntimeRef.current?.clearTextureAtlas();
const visibleTerm = termRef.current;
if (visibleTerm) {
@@ -1066,7 +1129,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (remeasureTimer) clearTimeout(remeasureTimer);
fontFaceSet?.removeEventListener?.("loadingdone", onLoadingDone);
};
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resizeSession, resolvedFontFamily, terminalSettings]);
useEffect(() => {
@@ -1093,15 +1156,15 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}, [isVisible, isResizing]);
useLayoutEffect(() => {
if (isResizing) {
if (splitResizeActive) {
prevIsResizingRef.current = true;
return;
}
if (!prevIsResizingRef.current || !isVisible) {
prevIsResizingRef.current = isResizing;
prevIsResizingRef.current = splitResizeActive;
return;
}
prevIsResizingRef.current = isResizing;
prevIsResizingRef.current = splitResizeActive;
lastFittedSizeRef.current = null;
safeFit({ force: true, requireVisible: true });
finishLayoutRecoveryAfterFit();
@@ -1115,7 +1178,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}
});
}
}, [isResizing, isVisible]);
}, [splitResizeActive, isVisible]);
useEffect(() => {
@@ -1308,6 +1371,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
// from interfering with tmux/vim popup menus when mouse tracking is active.
// On macOS, xterm forces selection with Option, while most terminal users
// expect Shift to bypass mouse reporting. Replay Shift+left-click as that
// native xterm force-selection gesture before xterm receives the original.
// - mousedown (button 2 + Shift): keep Shift+right-click local so the
// terminal app does not also receive the right-button press
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): tmux interprets the right-button release as a
// dismiss action, closing the popup menu immediately after it appears
@@ -1346,15 +1414,44 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
// does not intercept.
};
const handleMouseDownCapture = (e: MouseEvent) => {
if (shouldStopShiftRightClickMouseTrackingMouseDown({
event: e,
mouseTracking: mouseTrackingRef.current,
status: statusRef.current,
})) {
e.stopImmediatePropagation();
return;
}
if (!shouldReplayShiftMouseSelectionAsMacOption({
event: e,
mouseTracking: mouseTrackingRef.current,
status: statusRef.current,
isMacPlatform: isMacPlatform(),
})) {
return;
}
const target = e.target as EventTarget | null;
if (!target || typeof target.dispatchEvent !== 'function') return;
e.preventDefault();
e.stopImmediatePropagation();
target.dispatchEvent(createMacOptionForcedSelectionMouseEvent(e));
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
e.stopImmediatePropagation();
}
};
el.addEventListener('mousedown', handleMouseDownCapture, true);
el.addEventListener('contextmenu', handleContextMenuCapture, true);
el.addEventListener('mouseup', handleMouseUpCapture, true);
return () => {
el.removeEventListener('mousedown', handleMouseDownCapture, true);
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
el.removeEventListener('mouseup', handleMouseUpCapture, true);
};
@@ -1,4 +1,4 @@
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { activeTabStore } from '../../application/state/activeTabStore';
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
@@ -14,7 +14,7 @@ import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastruct
import { cn } from '../../lib/utils';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, VaultNote, Workspace } from '../../types';
import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, VaultNote, Workspace } from '../../types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import Terminal from '../Terminal';
import { removePaneVisible, setPaneVisible } from '../terminal/paneVisibilityStore';
@@ -23,6 +23,8 @@ import type { TerminalContextReader } from '../../domain/terminalContextRead';
import {
getTerminalPaneRenderSnapshot,
parseTerminalPaneRenderSnapshot,
resolveHiddenTerminalPaneStyle,
type TerminalPaneHiddenSize,
} from '../terminalPaneVisibility';
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
@@ -139,12 +141,6 @@ export const clearTerminalPreviewVars = (sessionId: string | null) => {
pane.style.removeProperty('--terminal-preview-toolbar-btn-active');
};
export const clearTerminalPreviewVarsForSessions = (sessionIds: Iterable<string>) => {
for (const sessionId of sessionIds) {
clearTerminalPreviewVars(sessionId);
}
};
export const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
@@ -173,29 +169,6 @@ const getHostTreePreviewRoots = (): HTMLElement[] => {
));
};
export const applyHostTreePreviewThemeVars = (theme: TerminalTheme) => {
const roots = getHostTreePreviewRoots();
if (roots.length === 0) return;
const bg = theme.colors.background;
const fg = theme.colors.foreground;
const values = {
'--terminal-host-tree-bg': bg,
'--terminal-host-tree-fg': fg,
'--terminal-host-tree-muted': `color-mix(in srgb, ${fg} 55%, ${bg} 45%)`,
'--terminal-host-tree-separator': `color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
'--terminal-host-tree-hover-bg': `color-mix(in srgb, ${fg} 8%, transparent)`,
'--terminal-host-tree-active-bg': `color-mix(in srgb, ${fg} 14%, transparent)`,
'--terminal-host-tree-drop-bg': `color-mix(in srgb, ${fg} 20%, transparent)`,
'--terminal-host-tree-folder-fg': `color-mix(in srgb, ${fg} 75%, ${bg} 25%)`,
} satisfies Record<(typeof HOST_TREE_PREVIEW_PROPERTIES)[number], string>;
for (const root of roots) {
for (const property of HOST_TREE_PREVIEW_PROPERTIES) {
setStylePropertyIfChanged(root, property, values[property]);
}
}
};
export const clearHostTreePreviewVars = () => {
const roots = getHostTreePreviewRoots();
for (const root of roots) {
@@ -1000,6 +973,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
const { paneState, isFocusedPane } = parseTerminalPaneRenderSnapshot(renderSnapshot);
const activeWorkspaceId = paneState.workspaceId;
const isVisible = paneState.isVisible;
const paneElementRef = useRef<HTMLDivElement | null>(null);
const lastVisiblePaneSizeRef = useRef<TerminalPaneHiddenSize | null>(null);
// Publish visibility to the per-session store so TerminalServerStats /
// TerminalAutocomplete can self-subscribe — keeping isVisible out of the
@@ -1062,12 +1037,24 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
const paneLayoutKey = paneLayoutKeyRef.current;
const style: React.CSSProperties = { ...layoutStyle };
useLayoutEffect(() => {
if (!isVisible) return;
const element = paneElementRef.current;
if (!element) return;
const width = element.clientWidth;
const height = element.clientHeight;
if (width > 0 && height > 0) {
lastVisiblePaneSizeRef.current = { width, height };
}
}, [
isVisible,
layoutStyle.height,
layoutStyle.width,
activeWorkspaceId,
]);
if (!isVisible) {
style.visibility = 'hidden';
style.pointerEvents = 'none';
// Preserve xterm state while keeping hidden terminals out of layout.
style.left = '-9999px';
style.top = '-9999px';
Object.assign(style, resolveHiddenTerminalPaneStyle(style, lastVisiblePaneSizeRef.current));
}
const workspaceFocusHandler = activeWorkspaceId
@@ -1287,6 +1274,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
return (
<div
ref={paneElementRef}
data-session-id={session.id}
data-section="terminal-split-pane"
data-focused={isFocusedPane ? 'true' : undefined}
@@ -31,3 +31,12 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
assert.match(source, /onOpenVaultHostFromChat: s\.onOpenVaultHostFromChat/);
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
});
test('terminal layer bridge updates side panel live state after render', () => {
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
assert.match(
source,
/useLayoutEffect\(\(\) => \{\s*sidePanelLiveStore\.update\(sidePanelLiveSnapshot\);\s*\}, \[sidePanelLiveSnapshot\]\);/,
);
assert.doesNotMatch(source, /sidePanelLiveStore\.update\(\{\s*sftpActiveHost/);
});
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useActiveTabId } from '../../application/state/activeTabStore';
import { sessionCapabilitiesStore } from '../../application/state/sessionCapabilitiesStore';
@@ -15,7 +15,7 @@ import { useTerminalAiContexts } from './useTerminalAiContexts';
import { useTerminalLayerEffects } from './useTerminalLayerEffects';
import { useTerminalThemePanelState } from './useTerminalThemePanelState';
import { useManualTerminalChromeSurfaceInjection } from '../../application/state/useManualTerminalChromeSurfaceInjection';
import { sidePanelLiveStore } from '../../application/state/sidePanelLiveStore';
import { sidePanelLiveStore, type SidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useTerminalWorkspaceLayout } from './useTerminalWorkspaceLayout';
import type { SidePanelTab } from './TerminalLayerSupport';
@@ -215,7 +215,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
!s.followAppTerminalTheme && isTerminalLayerVisible,
);
sidePanelLiveStore.update({
const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>(() => ({
sftpActiveHost,
activeTerminalSessionIdForSftp,
activeTerminalCwd,
@@ -234,7 +234,30 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
focusedFontWeight: themeState.focusedFontWeight,
focusedFontWeightOverridden: themeState.focusedFontWeightOverridden,
focusedThemeOverridden: themeState.focusedThemeOverridden,
});
}), [
activeSystemSessionHost,
activeTerminalCwd,
activeTerminalSessionForSystem,
activeTerminalSessionIdForSftp,
activeWorkspace,
effectiveFocusedSessionId,
focusedHost,
historySessionId,
sftpActiveHost,
themeState.focusedFontFamilyId,
themeState.focusedFontFamilyOverridden,
themeState.focusedFontSize,
themeState.focusedFontSizeOverridden,
themeState.focusedFontWeight,
themeState.focusedFontWeightOverridden,
themeState.focusedThemeOverridden,
themeState.previewedOrVisibleThemeId,
themeState.resolvedPreviewTheme,
]);
useLayoutEffect(() => {
sidePanelLiveStore.update(sidePanelLiveSnapshot);
}, [sidePanelLiveSnapshot]);
const { aiContextsByTabId, resolveAIExecutorContext } = useTerminalAiContexts({
hosts: s.hosts,
@@ -1,188 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type React from 'react';
import type { DropEntry } from '../../lib/sftpFileUtils';
import type { ProgrammaticCommandLogRewrite } from '../terminal/programmaticCommandLog';
import type {
GroupConfig,
Host,
Identity,
KnownHost,
ProxyProfile,
SSHKey,
Snippet,
TerminalSession,
TerminalTheme,
VaultNote,
Workspace,
} from '../../types';
import type {
PendingSftpUpload,
PendingTerminalSelectionForAI,
SidePanelTab,
SnippetExecutor,
TerminalLayerProps,
} from './TerminalLayerSupport';
export type TerminalLayerStableSnapshot = {
props: TerminalLayerProps;
t: (key: string) => string;
terminalBackend: ReturnType<typeof import('../../application/state/useTerminalBackend').useTerminalBackend>;
terminalRendererCwdBySessionRef: React.MutableRefObject<Map<string, string>>;
hosts: Host[];
customGroups: string[];
sessions: TerminalSession[];
workspaces: Workspace[];
draggingSessionId: string | null;
hostMap: Map<string, Host>;
hostMapRef: React.MutableRefObject<Map<string, Host>>;
effectiveHosts: Host[];
sessionHostsMap: Map<string, Host>;
sessionHostsMapRef: React.MutableRefObject<Map<string, Host>>;
resolvedSessionHostIds: Set<string>;
sessionChainHostsMap: Map<string, Host[]>;
sessionSudoAutofillPasswordsMap: Map<string, string | undefined>;
workspaceById: Map<string, Workspace>;
snippetExecutorsRef: React.MutableRefObject<Map<string, SnippetExecutor>>;
programmaticCommandLogRewriteHandlersRef: React.MutableRefObject<Map<string, (rewrite: ProgrammaticCommandLogRewrite) => void>>;
activeTabIdRef: React.MutableRefObject<string>;
activeWorkspaceRef: React.MutableRefObject<Workspace | undefined>;
activeSessionRef: React.MutableRefObject<TerminalSession | undefined>;
focusedSessionIdRef: React.MutableRefObject<string | undefined>;
sessionsRef: React.MutableRefObject<TerminalSession[]>;
workspacesRef: React.MutableRefObject<Workspace[]>;
hostsRef: React.MutableRefObject<Host[]>;
portForwardingRules: import('../../domain/models').PortForwardingRule[];
portForwardingRulesRef: React.MutableRefObject<import('../../domain/models').PortForwardingRule[]>;
onSetWorkspaceFocusedSessionRef: React.MutableRefObject<TerminalLayerProps['onSetWorkspaceFocusedSession']>;
sidePanelOpenTabs: Map<string, SidePanelTab>;
setSidePanelOpenTabs: React.Dispatch<React.SetStateAction<Map<string, SidePanelTab>>>;
sidePanelOpenTabsRef: React.MutableRefObject<Map<string, SidePanelTab>>;
sidePanelWidth: number;
setSidePanelWidth: (value: number) => void;
sidePanelPosition: 'left' | 'right';
setSidePanelPosition: (value: 'left' | 'right') => void;
sftpHostForTab: Map<string, Host>;
setSftpHostForTab: React.Dispatch<React.SetStateAction<Map<string, Host>>>;
sftpHostForTabRef: React.MutableRefObject<Map<string, Host>>;
sftpInitialLocationForTab: Map<string, { hostId: string; path: string }>;
setSftpInitialLocationForTab: React.Dispatch<React.SetStateAction<Map<string, { hostId: string; path: string }>>>;
sftpPendingUploadsForTab: Map<string, PendingSftpUpload>;
setSftpPendingUploadsForTab: React.Dispatch<React.SetStateAction<Map<string, PendingSftpUpload>>>;
pendingTerminalSelectionForAI: PendingTerminalSelectionForAI | null;
setPendingTerminalSelectionForAI: React.Dispatch<React.SetStateAction<PendingTerminalSelectionForAI | null>>;
lastSidePanelTabRef: React.MutableRefObject<Map<string, SidePanelTab>>;
notesMountedTabIds: string[];
setNotesMountedTabIds: React.Dispatch<React.SetStateAction<string[]>>;
notesOpenNoteByTab: Map<string, { noteId: string; requestId: number }>;
setNotesOpenNoteByTab: React.Dispatch<React.SetStateAction<Map<string, { noteId: string; requestId: number }>>>;
isComposeBarOpen: boolean;
setIsComposeBarOpen: React.Dispatch<React.SetStateAction<boolean>>;
splitHorizontalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
splitVerticalHandlersRef: React.MutableRefObject<Map<string, () => void>>;
onSplitSessionRef: React.MutableRefObject<TerminalLayerProps['onSplitSession']>;
workspaceFocusHandlersRef: React.MutableRefObject<Map<string, () => void>>;
workspaceBroadcastHandlersRef: React.MutableRefObject<Map<string, () => void>>;
onToggleWorkspaceViewModeRef: React.MutableRefObject<TerminalLayerProps['onToggleWorkspaceViewMode']>;
onToggleBroadcastRef: React.MutableRefObject<TerminalLayerProps['onToggleBroadcast']>;
validAIScopeTargetIds: Set<string>;
validSessionActivityIds: Set<string>;
activityTrackedSessions: TerminalSession[];
handleCloseSession: (sessionId: string) => void;
handleStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
handleSessionExit: (sessionId: string, evt: any) => void;
handleOsDetected: (hostId: string, distro: string) => void;
handleUpdateHost: (host: Host) => void;
handleAddKnownHost: (knownHost: KnownHost) => void;
handleCommandExecuted: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
handleTerminalDataCapture: (sessionId: string, data: string) => void;
handleBroadcastInput: (data: string, sourceSessionId: string) => void;
handleBroadcastInterruptPriorityChange: (sessionId: string, prioritize: (() => void) | null) => void;
handleSnippetExecutorChange: (sessionId: string, executor: SnippetExecutor | null) => void;
handleProgrammaticCommandLogRewriteChange: (
sessionId: string,
queueRewrite: ((rewrite: ProgrammaticCommandLogRewrite) => void) | null,
) => void;
handleTerminalFontSizeChange: (sessionId: string, nextFontSize: number) => void;
handleOpenSftp: (host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], sourceSessionId?: string) => void;
handlePendingUploadHandled: (tabId: string, requestId: string) => void;
handleSftpInitialLocationApplied: (tabId: string, location: { hostId: string; path: string }) => void;
handleSftpCurrentPathChange: (memoryKey: string, location: { hostId: string; path: string }) => void;
handleSidePanelResizeStart: (e: React.MouseEvent) => void;
handleToggleWorkspaceComposeBar: () => void;
handleSwitchSidePanelTab: (tab: SidePanelTab) => void;
handleToggleSftpFromBar: () => void;
handleOpenScripts: () => void;
handleOpenHistory: () => void;
handleHistoryPaste: (command: string) => void;
handleHistoryRun: (command: string) => void;
handleToggleScriptsSidePanel: () => void;
handleToggleSidePanel: () => void;
handleOpenTheme: () => void;
handleOpenAI: () => void;
handleOpenSystem: () => void;
handleOpenNotes: () => void;
handleBackFromNotes: () => void;
handleOpenHostFromNotes: (host: Host, source?: { noteId?: string }) => void;
handleAddSelectionToAI: (sourceSessionId: string, selection: string) => void;
handlePendingTerminalSelectionConsumed: (requestId: string) => void;
handleToggleAiFromTopBar: () => void;
resolveSftpHostForTab: (tabId: string) => Host | null;
handleCloseSidePanel: () => void;
getTerminalCwd: () => Promise<string | null>;
refocusTerminalSession: (sessionId?: string | null) => void;
refocusActiveTerminalSession: () => void;
onSessionData: ReturnType<typeof import('../../application/state/useTerminalBackend').useTerminalBackend>['onSessionData'];
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
accentMode: 'theme' | 'custom';
customAccent: string;
followAppTerminalTheme: boolean;
terminalTheme: TerminalTheme;
terminalFontFamilyId: string;
fontSize: number;
terminalSettings: TerminalLayerProps['terminalSettings'];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
snippetPackages: string[];
notes: VaultNote[];
noteGroups: string[];
knownHosts: KnownHost[];
hotkeyScheme: TerminalLayerProps['hotkeyScheme'];
disableTerminalFontZoom: TerminalLayerProps['disableTerminalFontZoom'];
restoreTerminalCwd: TerminalLayerProps['restoreTerminalCwd'];
keyBindings: TerminalLayerProps['keyBindings'];
onHotkeyAction: TerminalLayerProps['onHotkeyAction'];
onConnectToHost: TerminalLayerProps['onConnectToHost'];
onCreateLocalTerminal: TerminalLayerProps['onCreateLocalTerminal'];
onReorderWorkspaceSessions: TerminalLayerProps['onReorderWorkspaceSessions'];
onReorderTabs: TerminalLayerProps['onReorderTabs'];
onCopySession: TerminalLayerProps['onCopySession'];
onCopySessionToNewWindow: TerminalLayerProps['onCopySessionToNewWindow'];
onUpdateSessionRestoreCwd: TerminalLayerProps['onUpdateSessionRestoreCwd'];
onUpdateSessionDynamicTitle: TerminalLayerProps['onUpdateSessionDynamicTitle'];
onUpdateSessionCodingCliProvider: TerminalLayerProps['onUpdateSessionCodingCliProvider'];
onRequestAddToWorkspace: TerminalLayerProps['onRequestAddToWorkspace'];
onSetWorkspaceFocusedSession: TerminalLayerProps['onSetWorkspaceFocusedSession'];
onToggleWorkspaceViewMode: TerminalLayerProps['onToggleWorkspaceViewMode'];
onSplitSession: TerminalLayerProps['onSplitSession'];
isBroadcastEnabled: TerminalLayerProps['isBroadcastEnabled'];
updateHosts: TerminalLayerProps['updateHosts'];
updateSnippets: TerminalLayerProps['updateSnippets'];
updateSnippetPackages: TerminalLayerProps['updateSnippetPackages'];
updateNotes: TerminalLayerProps['updateNotes'];
updateNoteGroups: TerminalLayerProps['updateNoteGroups'];
sftpDefaultViewMode: TerminalLayerProps['sftpDefaultViewMode'];
sftpDoubleClickBehavior: TerminalLayerProps['sftpDoubleClickBehavior'];
sftpAutoSync: TerminalLayerProps['sftpAutoSync'];
sftpShowHiddenFiles: TerminalLayerProps['sftpShowHiddenFiles'];
sftpUseCompressedUpload: TerminalLayerProps['sftpUseCompressedUpload'];
sessionLogConfig: { enabled: true; directory: string; format: string; timestampsEnabled?: boolean } | undefined;
sshDebugLogsEnabled: boolean;
groupConfigs: GroupConfig[];
proxyProfiles: ProxyProfile[];
shellHistory: import('../../types').ShellHistoryEntry[];
};
@@ -1,56 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { emptyTerminalThemePreview } from "./terminalThemePreview";
import {
resolveActiveThemePreviewId,
resolveThemeListSelectionId,
} from "./terminalThemeSelection";
const preview = {
targetSessionId: "session-a",
targetHostId: "host-1",
globalPreview: false,
themeId: "dracula",
};
test("active preview id follows host scope when focus moves within the same host", () => {
assert.equal(
resolveActiveThemePreviewId(preview, "session-b", "host-1"),
"dracula",
);
assert.equal(
resolveActiveThemePreviewId(preview, "session-b", "host-2"),
null,
);
});
test("follow-app list selection prefers pending pick over resolved terminal theme", () => {
assert.equal(
resolveThemeListSelectionId({
followAppTerminalTheme: true,
followAppPendingThemeId: "system-flexoki-light",
terminalThemeId: "system-github-dark",
themePreview: emptyTerminalThemePreview(),
previewTargetSessionId: "session-a",
previewTargetHostId: "host-1",
focusedThemeId: "system-github-dark",
}),
"system-flexoki-light",
);
});
test("manual list selection prefers in-scope preview over focused theme", () => {
assert.equal(
resolveThemeListSelectionId({
followAppTerminalTheme: false,
followAppPendingThemeId: null,
terminalThemeId: "netcatty-dark",
themePreview: preview,
previewTargetSessionId: "session-b",
previewTargetHostId: "host-1",
focusedThemeId: "solarized-light",
}),
"dracula",
);
});
@@ -1,40 +0,0 @@
import type { TerminalThemePreviewState } from './terminalThemePreview';
/** Which preview theme id applies to the current focus scope (list highlight in manual mode). */
export function resolveActiveThemePreviewId(
themePreview: TerminalThemePreviewState,
previewTargetSessionId: string | null,
previewTargetHostId: string | null,
): string | null {
if (!themePreview.themeId) return null;
if (themePreview.globalPreview) return themePreview.themeId;
if (themePreview.targetSessionId === previewTargetSessionId) return themePreview.themeId;
if (
themePreview.targetHostId
&& previewTargetHostId
&& themePreview.targetHostId === previewTargetHostId
) {
return themePreview.themeId;
}
return null;
}
/** Single source of truth for the theme list checkmark / footer label. */
export function resolveThemeListSelectionId(options: {
followAppTerminalTheme: boolean;
followAppPendingThemeId: string | null | undefined;
terminalThemeId: string;
themePreview: TerminalThemePreviewState;
previewTargetSessionId: string | null;
previewTargetHostId: string | null;
focusedThemeId: string;
}): string {
if (options.followAppTerminalTheme) {
return options.followAppPendingThemeId ?? options.terminalThemeId;
}
return resolveActiveThemePreviewId(
options.themePreview,
options.previewTargetSessionId,
options.previewTargetHostId,
) ?? options.focusedThemeId;
}
@@ -25,3 +25,16 @@ test("terminal activity filter stays in sync before notification guards", () =>
assert.ok(filterIndex < visibleGuardIndex);
assert.ok(filterIndex < alreadyActiveGuardIndex);
});
test("side panel layout changes remeasure workspace before paint", () => {
assert.match(source, /import \{ useCallback, useEffect, useLayoutEffect, useRef \} from 'react';/);
const commentIndex = source.indexOf("Discrete layout changes (side panel toggle");
const layoutEffectIndex = source.indexOf("useLayoutEffect(() => {", commentIndex);
const shellWidthDependencyIndex = source.indexOf("sidePanelShellWidth,", layoutEffectIndex);
assert.notEqual(commentIndex, -1);
assert.notEqual(layoutEffectIndex, -1);
assert.notEqual(shellWidthDependencyIndex, -1);
assert.ok(commentIndex < layoutEffectIndex);
});
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
@@ -179,43 +179,43 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
// Discrete layout changes (side panel toggle, compose bar, workspace tab/view mode)
// can miss a ResizeObserver tick; host-tree width is handled by the observer
// because it updates continuously during drag.
useEffect(() => {
if (!isTerminalLayerVisible) return;
const el = workspaceInnerRef.current;
const width = el?.clientWidth ?? 0;
const height = el?.clientHeight ?? 0;
const prev = layoutEffectSnapshotRef.current;
const dimensionsUnchanged = width > 0
&& height > 0
&& prev.width === width
&& prev.height === height
&& prev.shellWidth === sidePanelShellWidth;
if (
dimensionsUnchanged
&& prev.workspaceId === activeWorkspaceId
&& prev.viewMode === activeWorkspaceViewMode
&& prev.composeBarOpen === isComposeBarOpen
) {
return;
}
layoutEffectSnapshotRef.current = {
workspaceId: activeWorkspaceId,
viewMode: activeWorkspaceViewMode,
composeBarOpen: isComposeBarOpen,
shellWidth: sidePanelShellWidth,
width,
height,
};
scheduleWorkspaceAreaRemeasure();
}, [
activeWorkspaceId,
activeWorkspaceViewMode,
isComposeBarOpen,
isTerminalLayerVisible,
scheduleWorkspaceAreaRemeasure,
sidePanelPosition,
sidePanelShellWidth,
]);
useLayoutEffect(() => {
if (!isTerminalLayerVisible) return;
const el = workspaceInnerRef.current;
const width = el?.clientWidth ?? 0;
const height = el?.clientHeight ?? 0;
const prev = layoutEffectSnapshotRef.current;
const dimensionsUnchanged = width > 0
&& height > 0
&& prev.width === width
&& prev.height === height
&& prev.shellWidth === sidePanelShellWidth;
if (
dimensionsUnchanged
&& prev.workspaceId === activeWorkspaceId
&& prev.viewMode === activeWorkspaceViewMode
&& prev.composeBarOpen === isComposeBarOpen
) {
return;
}
layoutEffectSnapshotRef.current = {
workspaceId: activeWorkspaceId,
viewMode: activeWorkspaceViewMode,
composeBarOpen: isComposeBarOpen,
shellWidth: sidePanelShellWidth,
width,
height,
};
scheduleWorkspaceAreaRemeasure();
}, [
activeWorkspaceId,
activeWorkspaceViewMode,
isComposeBarOpen,
isTerminalLayerVisible,
scheduleWorkspaceAreaRemeasure,
sidePanelPosition,
sidePanelShellWidth,
]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.
@@ -6,6 +6,7 @@ import {
getTerminalPaneSnapshot,
HIDDEN_TERMINAL_PANE_SNAPSHOT,
parseTerminalPaneRenderSnapshot,
resolveHiddenTerminalPaneStyle,
} from "./terminalPaneVisibility";
import type { Workspace } from "../types";
@@ -137,3 +138,17 @@ test("terminal pane render snapshot combines visibility and focus in one token",
assert.equal(parsed.paneState.mode, "split");
assert.equal(parsed.isFocusedPane, true);
});
test("hidden terminal pane keeps its last visible size without moving offscreen", () => {
const hiddenStyle = resolveHiddenTerminalPaneStyle(
{ left: 0, top: 0, width: "100%", height: "100%" },
{ width: 1180, height: 720 },
);
assert.equal(hiddenStyle.left, 0);
assert.equal(hiddenStyle.top, 0);
assert.equal(hiddenStyle.visibility, "hidden");
assert.equal(hiddenStyle.pointerEvents, "none");
assert.equal(hiddenStyle.width, "1180px");
assert.equal(hiddenStyle.height, "720px");
});
+31
View File
@@ -11,6 +11,37 @@ export type TerminalPaneSnapshot =
export type TerminalPaneFocusSnapshot = "na" | "focused" | "unfocused";
export type TerminalPaneHiddenSize = {
width: number;
height: number;
};
export type TerminalPaneStyle = {
left?: string | number;
top?: string | number;
width?: string | number;
height?: string | number;
visibility?: string;
pointerEvents?: string;
};
export function resolveHiddenTerminalPaneStyle<T extends TerminalPaneStyle>(
layoutStyle: T,
lastVisibleSize: TerminalPaneHiddenSize | null,
): T {
return {
...layoutStyle,
visibility: "hidden",
pointerEvents: "none",
...(lastVisibleSize
? {
width: `${lastVisibleSize.width}px`,
height: `${lastVisibleSize.height}px`,
}
: {}),
};
}
interface GetTerminalPaneSnapshotOptions {
activeTabId: string | null;
sessionId: string;
+18 -1
View File
@@ -1,5 +1,5 @@
import { Copy, FileCode, FileText, LayoutGrid, Minus, Server, Square, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsTabActive } from '../../application/state/activeTabStore';
import type { EditorTab } from '../../application/state/editorTabStore';
import type { LogView } from '../../application/state/logViewState';
@@ -198,6 +198,17 @@ export const formatSessionTopTabLabel = (
return resolveSessionTabTitle(session, dynamicTabTitleMode);
};
export const createSessionTopTabDoubleClickHandler = (
onCopySession: (sessionId: string) => void,
sessionId: string,
): React.MouseEventHandler<HTMLDivElement> => () => onCopySession(sessionId);
export const stopCloseButtonDoubleClickPropagation = (
event: Pick<React.MouseEvent, 'stopPropagation'>,
): void => {
event.stopPropagation();
};
// Custom window controls for Windows/Linux (frameless window)
export const WindowControls: React.FC = memo(() => {
const { minimize, maximize, close, isMaximized: fetchIsMaximized } = useWindowControls();
@@ -569,6 +580,10 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
const handleClick = useCallback(() => {
activeTabStore.setActiveTabId(session.id);
}, [session.id]);
const handleDoubleClick = useMemo(
() => createSessionTopTabDoubleClickHandler(onCopySession, session.id),
[onCopySession, session.id],
);
const addressTooltip = formatSessionTopTabTooltip(session);
const tabTitle = formatSessionTopTabLabel(session, dynamicTabTitleMode);
@@ -578,6 +593,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-tab-type="session"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
draggable
@@ -633,6 +649,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
</div>
<button
onClick={(e) => onCloseSession(session.id, e)}
onDoubleClick={stopCloseButtonDoubleClickPropagation}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label={t('tabs.closeSessionAria')}
>
+10 -7
View File
@@ -7,6 +7,7 @@ import {
getVaultDropIntent,
getVaultDropPosition,
hasVaultDragType,
handleVaultRootDrop,
markVaultDropIndicator,
useVaultGridLayoutAnimation,
} from "./vaultReorderDrag";
@@ -141,7 +142,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
}}
>
{viewMode !== "tree" && (
<section className="space-y-2">
<section className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className={cn(
@@ -163,12 +164,14 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
);
}}
onDrop={(e) => {
e.preventDefault();
setDragOverDropTarget(null);
const groupPath = e.dataTransfer.getData("group-path");
const hostId = e.dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (hostId) moveHostToGroup(hostId, null);
handleVaultRootDrop({
dataTransfer: e.dataTransfer,
preventDefault: () => e.preventDefault(),
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
resetHostDragState,
});
}}
>
{t("vault.hosts.allHosts")}
+52
View File
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
getVaultDropIntent,
getVaultDropPosition,
handleVaultRootDrop,
} from "./vaultReorderDrag.ts";
const makeElement = (rect: Partial<DOMRect>): HTMLElement => ({
@@ -40,3 +41,54 @@ test("vault drop intent uses edges for sorting and middle for nesting", () => {
assert.equal(getVaultDropIntent(element, 150, 95, false), "after");
assert.equal(getVaultDropIntent(element, 150, 70, false), "inside");
});
test("root host drop resets host drag state after moving the host to all hosts", () => {
const calls: string[] = [];
handleVaultRootDrop({
dataTransfer: {
getData: (type: string) => (type === "host-id" ? "host-1" : ""),
},
preventDefault: () => calls.push("preventDefault"),
setDragOverDropTarget: (target) => calls.push(`drop:${String(target)}`),
moveGroup: (groupPath, targetPath) => {
calls.push(`group:${groupPath}:${String(targetPath)}`);
},
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.deepEqual(calls, [
"preventDefault",
"drop:null",
"host:host-1:null",
"resetHostDragState",
]);
});
test("root group drop moves the group to all hosts without resetting host drag state", () => {
const calls: string[] = [];
handleVaultRootDrop({
dataTransfer: {
getData: (type: string) => (type === "group-path" ? "team/prod" : ""),
},
preventDefault: () => calls.push("preventDefault"),
setDragOverDropTarget: (target) => calls.push(`drop:${String(target)}`),
moveGroup: (groupPath, targetPath) => {
calls.push(`group:${groupPath}:${String(targetPath)}`);
},
moveHostToGroup: (hostId, targetPath) => {
calls.push(`host:${hostId}:${String(targetPath)}`);
},
resetHostDragState: () => calls.push("resetHostDragState"),
});
assert.deepEqual(calls, [
"preventDefault",
"drop:null",
"group:team/prod:null",
]);
});
+26
View File
@@ -41,6 +41,32 @@ export const getVaultDropIntent = (
export const hasVaultDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export const handleVaultRootDrop = ({
dataTransfer,
preventDefault,
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
resetHostDragState,
}: {
dataTransfer: Pick<DataTransfer, "getData">;
preventDefault: () => void;
setDragOverDropTarget: (target: null) => void;
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
resetHostDragState: () => void;
}) => {
preventDefault();
setDragOverDropTarget(null);
const groupPath = dataTransfer.getData("group-path");
const hostId = dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (hostId) {
moveHostToGroup(hostId, null);
resetHostDragState();
}
};
let activeVaultDropIndicator: HTMLElement | null = null;
export const clearVaultDropIndicator = () => {
+231
View File
@@ -0,0 +1,231 @@
# Script Dialog Form Examples
These examples can be pasted into a Netcatty automation script to test dialog form controls.
## 1. All Controls Smoke Test
Tests `select`, `radio`, `checkbox`, `textarea`, `number`, defaults, and returned values.
```javascript
const values = await nct.dialog.form({
title: 'Dialog controls smoke test',
message: 'Change a few values and submit.',
fields: [
{
type: 'select',
name: 'env',
label: 'Environment',
options: [
{ label: 'Development', value: 'dev' },
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'prod', description: 'Use carefully' },
],
defaultValue: 'staging',
},
{
type: 'radio',
name: 'mode',
label: 'Run mode',
options: [
{ label: 'Dry run', value: 'dry-run', description: 'Preview only' },
{ label: 'Execute', value: 'execute' },
],
defaultValue: 'dry-run',
},
{
type: 'checkbox',
name: 'verbose',
label: 'Verbose output',
defaultValue: true,
},
{
type: 'textarea',
name: 'notes',
label: 'Notes',
placeholder: 'Optional notes for this run',
defaultValue: 'Testing script dialog controls.',
required: false,
},
{
type: 'number',
name: 'retries',
label: 'Retries',
defaultValue: 3,
min: 0,
max: 10,
step: 1,
},
],
});
nct.log(JSON.stringify(values, null, 2));
await nct.dialog.alert(`Submitted:\n${JSON.stringify(values, null, 2)}`);
```
## 2. Required Validation Test
Submit with the fields empty first. The dialog should show required errors and remain open.
```javascript
const values = await nct.dialog.form({
title: 'Required validation',
message: 'Try submitting before filling the required fields.',
fields: [
{
type: 'textarea',
name: 'reason',
label: 'Reason',
placeholder: 'This field is required',
defaultValue: '',
},
{
type: 'number',
name: 'ticket',
label: 'Ticket number',
placeholder: 'Required number',
},
{
type: 'textarea',
name: 'optionalNote',
label: 'Optional note',
required: false,
},
],
});
nct.log(`reason=${values.reason}`);
nct.log(`ticket=${values.ticket}`);
nct.log(`optionalNote=${values.optionalNote ?? ''}`);
await nct.dialog.alert('Required validation passed.');
```
## 3. Conditional Display
Tests `visibleWhen`. Select `local` first: the remote fields should be hidden and omitted from the returned object. Select `remote`: the host field appears and becomes required because it is visible.
`visibleWhen.field` must reference a field defined earlier in the form.
```javascript
const values = await nct.dialog.form({
title: 'Conditional display',
message: 'Switch the target type and watch the visible fields change.',
fields: [
{
type: 'select',
name: 'target',
label: 'Target',
options: [
{ label: 'Local machine', value: 'local' },
{ label: 'Remote host', value: 'remote' },
],
defaultValue: 'local',
},
{
type: 'textarea',
name: 'host',
label: 'Remote host',
placeholder: 'example.com',
defaultValue: '',
visibleWhen: { field: 'target', equals: 'remote' },
},
{
type: 'number',
name: 'sshPort',
label: 'SSH port',
defaultValue: 22,
min: 1,
max: 65535,
step: 1,
visibleWhen: { field: 'target', equals: 'remote' },
},
{
type: 'checkbox',
name: 'confirmRemote',
label: 'I know this will target a remote host',
defaultValue: false,
visibleWhen: { field: 'target', equals: 'remote' },
},
],
});
nct.log(JSON.stringify(values, null, 2));
await nct.dialog.alert(`Visible values only:\n${JSON.stringify(values, null, 2)}`);
```
## 4. Convenience Controls
Tests `select`, `radio`, and `checkbox` helper APIs.
```javascript
const env = await nct.dialog.select(
'Pick environment',
[
{ label: 'Development', value: 'dev' },
{ label: 'Production', value: 'prod' },
],
'dev',
);
const mode = await nct.dialog.radio(
'Pick mode',
[
{ label: 'Dry run', value: 'dry-run' },
{ label: 'Execute', value: 'execute' },
],
'dry-run',
);
const verbose = await nct.dialog.checkbox('Verbose output', true);
nct.log(`env=${env}, mode=${mode}, verbose=${verbose}`);
await nct.dialog.alert(`env=${env}\nmode=${mode}\nverbose=${verbose}`);
```
## 5. Safe Real Command Test
Uses form values to run a harmless command in the current terminal.
```javascript
const values = await nct.dialog.form({
title: 'Safe command test',
message: 'Choose a harmless command to run in this terminal.',
fields: [
{
type: 'select',
name: 'command',
label: 'Command',
options: [
{ label: 'Print working directory', value: 'pwd' },
{ label: 'Show current user', value: 'whoami' },
{ label: 'Show date', value: 'date' },
],
defaultValue: 'pwd',
},
{
type: 'number',
name: 'delayMs',
label: 'Delay before running (ms)',
defaultValue: 500,
min: 0,
max: 5000,
step: 100,
required: false,
},
{
type: 'textarea',
name: 'prefix',
label: 'Log prefix',
defaultValue: 'Running command',
required: false,
},
],
});
if (values.delayMs) {
await nct.sleep(values.delayMs);
}
nct.log(`${values.prefix || 'Running'}: ${values.command}`);
await nct.screen.sendLine(values.command);
await nct.screen.waitForPrompt(30000);
await nct.dialog.alert(`Command finished: ${values.command}`);
```
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more