mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e359f6d7 | ||
|
|
101ff24a1f | ||
|
|
9fe1fc7aa4 | ||
|
|
1dbf46e32f | ||
|
|
6e3401f9dc | ||
|
|
6944b77ec7 | ||
|
|
3f2e33f6e0 | ||
|
|
2b9846d1d2 | ||
|
|
b6a7f40175 | ||
|
|
aa4b7f46e5 | ||
|
|
c79d51858a | ||
|
|
e430a42dbe | ||
|
|
5dec129088 | ||
|
|
f299271b1b | ||
|
|
49787215d2 | ||
|
|
5e34538c55 | ||
|
|
862c3f6fb5 | ||
|
|
2edbeae02c | ||
|
|
02ada0c61d | ||
|
|
98e95c36dc | ||
|
|
a1b1c37f36 | ||
|
|
45905f8f34 | ||
|
|
6d83b32815 | ||
|
|
a366cf81d9 | ||
|
|
3a8bd7eea7 | ||
|
|
a46c3321db | ||
|
|
eedb123b3a | ||
|
|
1c7dc33852 | ||
|
|
49a3c31de8 | ||
|
|
82e318c17f | ||
|
|
920e3cf8ec | ||
|
|
2fe7de6cd7 | ||
|
|
f3ac99e587 | ||
|
|
fe7a867f38 | ||
|
|
7d70d85d23 | ||
|
|
5a70e9d6b8 | ||
|
|
65b00ae12f | ||
|
|
c36ff6bd49 | ||
|
|
269b4d7a7a |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
|
||||
---
|
||||
|
||||
[](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 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||

|
||||
<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" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||

|
||||
<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>
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
<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" />
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
<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
@@ -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 @@
|
||||
|
||||
---
|
||||
|
||||
[](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 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||

|
||||
<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" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||

|
||||
<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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Пользователь',
|
||||
|
||||
@@ -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': 'Показать журналы',
|
||||
|
||||
@@ -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': '最大无响应保活次数',
|
||||
|
||||
@@ -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': '最大無響應保活次數',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,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), " ");
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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); }}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user