Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5feb888d2 | ||
|
|
62d19974c9 | ||
|
|
932bb5032d | ||
|
|
3020d422fe | ||
|
|
bb526601bb | ||
|
|
d349c31cd6 | ||
|
|
8313cf780d | ||
|
|
29c0cc30a4 | ||
|
|
ee80048ece | ||
|
|
ec5dfcf1fa | ||
|
|
ab3b2c2055 | ||
|
|
ca8691d53d | ||
|
|
dddaf1a1cd | ||
|
|
d2469f93c8 | ||
|
|
521c9141e9 | ||
|
|
d068cc99c8 | ||
|
|
a89130d732 | ||
|
|
dcea13172d | ||
|
|
0c420d99ed | ||
|
|
a3ffefa067 | ||
|
|
80b6485d02 | ||
|
|
884d643150 | ||
|
|
96fde364df | ||
|
|
920d829299 | ||
|
|
4f7d3bccc8 | ||
|
|
a3eb6ab26c | ||
|
|
c402c45e1d | ||
|
|
d4978f267a | ||
|
|
41ee8e7160 | ||
|
|
3d9f153fe9 | ||
|
|
4332537da0 | ||
|
|
4284b9a9dc | ||
|
|
3afe6ed489 | ||
|
|
1d06e48966 | ||
|
|
3ce2481753 | ||
|
|
ce61c28162 | ||
|
|
55463441f0 | ||
|
|
c17833ae31 | ||
|
|
56bab12b5c | ||
|
|
d1482e47d6 | ||
|
|
4ee3c63768 | ||
|
|
2b067a9aae | ||
|
|
2d4a3a5602 | ||
|
|
6c57ce7b28 | ||
|
|
6a2bd0a6a1 | ||
|
|
0c4900c73d | ||
|
|
3174e9ad27 | ||
|
|
f517c85d07 | ||
|
|
0b9e3c430d | ||
|
|
1c526e6965 | ||
|
|
70ff5299b6 | ||
|
|
3ef53faef5 |
12
.github/scripts/generate-release-note.js
vendored
@@ -46,6 +46,10 @@ const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.G
|
||||
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
|
||||
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
|
||||
// Note: electron-builder uses different arch names for Linux packages:
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -57,16 +61,16 @@ const files = {
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
x64: `Netcatty-${version}-linux-x64.AppImage`,
|
||||
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-x64.deb`,
|
||||
x64: `Netcatty-${version}-linux-amd64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.rpm`
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
42
.github/workflows/sync.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Sync Upstream
|
||||
|
||||
env:
|
||||
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
|
||||
UPSTREAM_BRANCH: "main"
|
||||
TARGET_BRANCH: "main"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge Upstream
|
||||
run: |
|
||||
echo "Adding upstream remote..."
|
||||
git remote add upstream ${{ env.UPSTREAM_URL }}
|
||||
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
|
||||
|
||||
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
|
||||
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
|
||||
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
|
||||
|
||||
echo "Pushing changes..."
|
||||
git push origin ${{ env.TARGET_BRANCH }}
|
||||
164
App.tsx
@@ -261,7 +261,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
@@ -342,6 +342,126 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys: portForwardingKeys,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
};
|
||||
});
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
const rule = portForwardingRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
const handlerJump = (sessionId: string) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerConnect = (hostId: string) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
localHostname: "",
|
||||
saved: false,
|
||||
});
|
||||
|
||||
connectToHost(host);
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -649,11 +769,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -677,6 +801,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
// SFTP shortcuts are handled by SFTP-specific hooks.
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
// Terminal-specific actions should be handled by the terminal
|
||||
// Don't handle them at app level
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
@@ -958,8 +1086,36 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const editableSelector =
|
||||
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
||||
|
||||
const nativeEvent = e.nativeEvent;
|
||||
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
||||
const allowFromPath = path.some(
|
||||
(node) => node instanceof Element && !!node.closest(editableSelector),
|
||||
);
|
||||
|
||||
const target = e.target;
|
||||
const targetElement =
|
||||
target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
||||
|
||||
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
||||
|
||||
if (allowNativeContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={theme}
|
||||
sessions={sessions}
|
||||
@@ -998,6 +1154,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
|
||||
254
README.ja-JP.md
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
|
||||
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目次 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty とは](#netcatty-とは)
|
||||
- [なぜ Netcatty](#なぜ-netcatty)
|
||||
- [機能](#機能)
|
||||
- [デモ](#デモ)
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [ホスト管理](#ホスト管理)
|
||||
- [ターミナル](#ターミナル)
|
||||
- [SFTP](#sftp)
|
||||
- [キーチェーン](#キーチェーン)
|
||||
- [ポートフォワーディング](#ポートフォワーディング)
|
||||
- [クラウド同期](#クラウド同期)
|
||||
- [テーマとカスタマイズ](#テーマとカスタマイズ)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
- [分割ターミナル](#分割ターミナル)
|
||||
- [対応ディストリビューション](#対応ディストリビューション)
|
||||
- [はじめに](#はじめに)
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
@@ -71,190 +69,119 @@
|
||||
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
|
||||
|
||||
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
|
||||
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
|
||||
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
|
||||
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
|
||||
|
||||
---
|
||||
|
||||
<a name="なぜ-netcatty"></a>
|
||||
# なぜ Netcatty
|
||||
|
||||
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
|
||||
|
||||
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
|
||||
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
|
||||
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
|
||||
|
||||
---
|
||||
|
||||
<a name="機能"></a>
|
||||
# 機能
|
||||
|
||||
### 🖥️ ターミナルとセッション
|
||||
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
|
||||
### 🗂️ Vault
|
||||
- **複数ビュー** — グリッド / リスト / ツリー
|
||||
- **高速検索** — ホストやグループを素早く見つける
|
||||
|
||||
### 🖥️ ターミナルワークスペース
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
|
||||
- **セッション永続化** — 再起動後もセッションを復元
|
||||
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
|
||||
### 🔐 SSH クライアント
|
||||
- **SSH2 プロトコル**、完全な認証サポート
|
||||
- **パスワード&キー認証**
|
||||
- **SSH 証明書**サポート
|
||||
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
|
||||
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
|
||||
- **エージェント転送** — OpenSSH Agent および Pageant 対応
|
||||
- **環境変数** — ホストごとにカスタム環境変数を設定
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
|
||||
- **ドラッグ&ドロップ** アップロードおよびダウンロード
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
|
||||
|
||||
### 🔑 キーチェーン
|
||||
- **SSH キー生成** — RSA、ECDSA、ED25519
|
||||
- **既存キーのインポート** — PEM、OpenSSH 形式
|
||||
- **SSH 証明書**サポート
|
||||
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
|
||||
- **公開鍵をエクスポート**してリモートホストへ
|
||||
---
|
||||
|
||||
### 🔌 ポートフォワーディング
|
||||
- **ローカルフォワーディング** — リモートサービスをローカルに公開
|
||||
- **リモートフォワーディング** — ローカルサービスをリモートに公開
|
||||
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
|
||||
- **ビジュアルトンネル管理**
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
### ☁️ クラウド同期
|
||||
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
|
||||
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
|
||||
- **ホスト、キー、スニペット、設定を同期**
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### 🎨 テーマとカスタマイズ
|
||||
- **ライト&ダークモード**
|
||||
- **カスタムアクセントカラー**
|
||||
- **50以上のターミナル配色**
|
||||
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
|
||||
- **多言語対応** — English、简体中文 など
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="スクリーンショット"></a>
|
||||
# スクリーンショット
|
||||
|
||||
<a name="ホスト管理"></a>
|
||||
## ホスト管理
|
||||
<a name="メインウィンドウ"></a>
|
||||
## メインウィンドウ
|
||||
|
||||
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
**ダークモード**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**ネストされたフォルダと整理**
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||

|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
**リストビュー**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||

|
||||
|
||||
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
|
||||

|
||||
|
||||
**分割ウィンドウ**
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
**ブロードキャストモード**
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

|
||||
|
||||
**パフォーマンス情報とカスタマイズ**
|
||||
|
||||
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
|
||||
|
||||

|
||||
|
||||
**転送キュー**
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
|
||||
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
|
||||
|
||||
| キータイプ | アルゴリズム | 推奨用途 |
|
||||
|----------|------------|---------|
|
||||
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
|
||||
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
|
||||
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
|
||||
|
||||
**機能:**
|
||||
- 🔑 カスタマイズ可能なビット長でキーを生成
|
||||
- 📥 PEM/OpenSSH 形式のキーをインポート
|
||||
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
|
||||
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
|
||||
|
||||

|
||||
|
||||
**キー生成**
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
|
||||
|
||||
| タイプ | 方向 | ユースケース | 例 |
|
||||
|-------|-----|------------|---|
|
||||
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306` を `localhost:3306` に転送 |
|
||||
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
|
||||
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
|
||||
|
||||

|
||||
|
||||
<a name="クラウド同期"></a>
|
||||
## クラウド同期
|
||||
|
||||
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
|
||||
|
||||
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|
||||
|------------|----------|-----------------|
|
||||
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
|
||||
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
|
||||
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
|
||||
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
|
||||
|
||||
**同期対象:**
|
||||
- ✅ ホストと接続設定
|
||||
- ✅ SSH キーと証明書
|
||||
- ✅ アイデンティティと認証情報
|
||||
- ✅ スニペットとスクリプト
|
||||
- ✅ カスタムグループとタグ
|
||||
- ✅ ポートフォワーディングルール
|
||||
- ✅ アプリケーション設定
|
||||
|
||||

|
||||
|
||||
<a name="テーマとカスタマイズ"></a>
|
||||
## テーマとカスタマイズ
|
||||
|
||||
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="対応ディストリビューション"></a>
|
||||
# 対応ディストリビューション
|
||||
|
||||
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
|
||||
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -346,7 +273,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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -356,7 +283,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| カテゴリ | テクノロジー |
|
||||
|--------|------------|
|
||||
| フレームワーク | Electron 39 |
|
||||
| フレームワーク | Electron 40 |
|
||||
| フロントエンド | React 19, TypeScript |
|
||||
| ビルドツール | Vite 7 |
|
||||
| ターミナル | xterm.js 5 |
|
||||
@@ -382,17 +309,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくれたすべての人々に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
|
||||
241
README.md
@@ -11,7 +11,7 @@
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
- [Demos](#demos)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Host Management](#host-management)
|
||||
- [Terminal](#terminal)
|
||||
- [SFTP](#sftp)
|
||||
- [Keychain](#keychain)
|
||||
- [Port Forwarding](#port-forwarding)
|
||||
- [Cloud Sync](#cloud-sync)
|
||||
- [Themes & Customization](#themes--customization)
|
||||
- [Main Window](#main-window)
|
||||
- [Vault Views](#vault-views)
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Build & Package](#build--package)
|
||||
@@ -73,180 +71,111 @@
|
||||
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
|
||||
- **Netcatty is** a powerful SFTP client with dual-pane file browser
|
||||
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
|
||||
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
|
||||
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
|
||||
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
|
||||
|
||||
---
|
||||
|
||||
<a name="why-netcatty"></a>
|
||||
# Why Netcatty
|
||||
|
||||
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
|
||||
|
||||
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
|
||||
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
|
||||
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
|
||||
|
||||
---
|
||||
|
||||
<a name="features"></a>
|
||||
# Features
|
||||
|
||||
### 🖥️ Terminal & Sessions
|
||||
- **xterm.js-based terminal** with GPU-accelerated rendering
|
||||
### 🗂️ Vault
|
||||
- **Multiple views** — grid / list / tree
|
||||
- **Fast search** — locate hosts and groups quickly
|
||||
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Tab management** — multiple sessions with drag-to-reorder
|
||||
- **Session persistence** — restore sessions on restart
|
||||
- **Broadcast mode** — type once, send to multiple terminals
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
|
||||
### 🔐 SSH Client
|
||||
- **SSH2 protocol** with full authentication support
|
||||
- **Password & key-based authentication**
|
||||
- **SSH certificates** support
|
||||
- **Jump hosts / Bastion** — chain through multiple hosts
|
||||
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
|
||||
- **Agent forwarding** — including OpenSSH Agent and Pageant
|
||||
- **Environment variables** — set custom env vars per host
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **Edit in place** — built-in editor for quick changes
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
|
||||
- **Drag & Drop** uploads and downloads
|
||||
- **Queue management** for batch transfers
|
||||
- **Progress tracking** with transfer speed
|
||||
### 🎨 Personalization
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
- **Keyword highlighting** — customize highlight rules for terminal output
|
||||
|
||||
### 🔑 Keychain
|
||||
- **Generate SSH keys** — RSA, ECDSA, ED25519
|
||||
- **Import existing keys** — PEM, OpenSSH formats
|
||||
- **SSH certificates** support
|
||||
- **Identity management** — reusable username + auth combinations
|
||||
- **Export public keys** to remote hosts
|
||||
---
|
||||
|
||||
### 🔌 Port Forwarding
|
||||
- **Local forwarding** — expose remote services locally
|
||||
- **Remote forwarding** — expose local services remotely
|
||||
- **Dynamic forwarding** — SOCKS5 proxy
|
||||
- **Visual tunnel management**
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
### ☁️ Cloud Sync
|
||||
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
|
||||
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
|
||||
- **Sync hosts, keys, snippets, and settings**
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### 🎨 Themes & Customization
|
||||
- **Light & Dark mode**
|
||||
- **Custom accent colors**
|
||||
- **50+ terminal color schemes**
|
||||
- **Font customization** — JetBrains Mono, Fira Code, and more
|
||||
- **i18n support** — English, 简体中文, and more
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="screenshots"></a>
|
||||
# Screenshots
|
||||
|
||||
<a name="host-management"></a>
|
||||
## Host Management
|
||||
<a name="main-window"></a>
|
||||
## Main Window
|
||||
|
||||
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
**Dark Mode**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**Nested Folders & Organization**
|
||||
<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.
|
||||
|
||||
**List View**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||

|
||||
|
||||
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
|
||||

|
||||
|
||||
**Split Windows**
|
||||
<a name="split-terminals"></a>
|
||||
## Split Terminals
|
||||
|
||||
**Broadcast Mode**
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
|
||||
|
||||

|
||||
|
||||
**Performance Info & Customization**
|
||||
|
||||
Monitor your connection health and customize every aspect of your terminal.
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
|
||||
|
||||

|
||||
|
||||
**Transfer Queue**
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
|
||||
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
|
||||
|
||||
| Key Type | Algorithm | Recommended Use |
|
||||
|----------|-----------|----------------|
|
||||
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
|
||||
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
|
||||
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
|
||||
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
|
||||
|
||||
**Features:**
|
||||
- 🔑 Generate keys with customizable bit lengths
|
||||
- 📥 Import PEM/OpenSSH format keys
|
||||
- 👤 Create reusable identities (username + auth method)
|
||||
- 📤 One-click export public keys to remote hosts
|
||||
|
||||

|
||||
|
||||
**Key Generator**
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
|
||||
|
||||
| Type | Direction | Use Case | Example |
|
||||
|------|-----------|----------|--------|
|
||||
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
|
||||
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
|
||||
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
|
||||
|
||||

|
||||
|
||||
<a name="cloud-sync"></a>
|
||||
## Cloud Sync
|
||||
|
||||
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
|
||||
|
||||
| Provider | Best For | Setup Complexity |
|
||||
|----------|----------|------------------|
|
||||
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
|
||||
| **Google Drive** | Personal use, large storage | ⭐ Easy |
|
||||
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
|
||||
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
|
||||
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
|
||||
|
||||
**What syncs:**
|
||||
- ✅ Hosts & connection settings
|
||||
- ✅ SSH keys & certificates
|
||||
- ✅ Identities & credentials
|
||||
- ✅ Snippets & scripts
|
||||
- ✅ Custom groups & tags
|
||||
- ✅ Port forwarding rules
|
||||
- ✅ Application preferences
|
||||
|
||||

|
||||
|
||||
<a name="themes--customization"></a>
|
||||
## Themes & Customization
|
||||
|
||||
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -270,8 +199,6 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="getting-started"></a>
|
||||
# Getting Started
|
||||
|
||||
@@ -355,7 +282,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Framework | Electron 39 |
|
||||
| Framework | Electron 40 |
|
||||
| Frontend | React 19, TypeScript |
|
||||
| Build Tool | Vite 7 |
|
||||
| Terminal | xterm.js 5 |
|
||||
@@ -386,9 +313,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
248
README.zh-CN.md
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
|
||||
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目录 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty 是什么](#netcatty-是什么)
|
||||
- [为什么是 Netcatty](#为什么是-netcatty)
|
||||
- [功能特性](#功能特性)
|
||||
- [演示](#演示)
|
||||
- [界面截图](#界面截图)
|
||||
- [主机管理](#主机管理)
|
||||
- [终端](#终端)
|
||||
- [SFTP](#sftp)
|
||||
- [密钥管理](#密钥管理)
|
||||
- [端口转发](#端口转发)
|
||||
- [云同步](#云同步)
|
||||
- [主题与定制](#主题与定制)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
- [分屏终端](#分屏终端)
|
||||
- [支持的发行版](#支持的发行版)
|
||||
- [快速开始](#快速开始)
|
||||
- [构建与打包](#构建与打包)
|
||||
@@ -73,188 +71,118 @@
|
||||
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
|
||||
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
|
||||
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
|
||||
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口(Serial)等连接方式(视环境而定)
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
|
||||
|
||||
---
|
||||
|
||||
<a name="为什么是-netcatty"></a>
|
||||
# 为什么是 Netcatty
|
||||
|
||||
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
|
||||
|
||||
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
|
||||
|
||||
---
|
||||
|
||||
<a name="功能特性"></a>
|
||||
# 功能特性
|
||||
|
||||
### 🖥️ 终端与会话
|
||||
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
|
||||
- **分屏功能** —— 水平和垂直分割,多任务并行
|
||||
- **标签页管理** —— 多会话支持,拖拽排序
|
||||
- **会话持久化** —— 重启后恢复会话
|
||||
- **广播模式** —— 一次输入,发送到多个终端
|
||||
### 🗂️ Vault
|
||||
- **多种视图** —— 网格 / 列表 / 树形
|
||||
- **快速搜索** —— 迅速定位主机与分组
|
||||
|
||||
### 🔐 SSH 客户端
|
||||
- **SSH2 协议**,完整的认证支持
|
||||
- **密码和密钥认证**
|
||||
- **SSH 证书**支持
|
||||
- **跳板机 / 堡垒机** —— 多主机链式连接
|
||||
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
|
||||
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
|
||||
- **环境变量** —— 为每个主机设置自定义环境变量
|
||||
### 🖥️ 终端工作区
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
|
||||
- **拖放操作** —— 支持上传和下载
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
|
||||
### 🔑 密钥管理
|
||||
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
|
||||
- **导入已有密钥** —— PEM、OpenSSH 格式
|
||||
- **SSH 证书**支持
|
||||
- **身份管理** —— 可复用的用户名 + 认证方式组合
|
||||
- **导出公钥**到远程主机
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
- **关键词高亮** —— 自定义终端输出高亮规则
|
||||
|
||||
### 🔌 端口转发
|
||||
- **本地转发** —— 将远程服务暴露到本地
|
||||
- **远程转发** —— 将本地服务暴露到远程
|
||||
- **动态转发** —— SOCKS5 代理
|
||||
- **可视化隧道管理**
|
||||
---
|
||||
|
||||
### ☁️ 云同步
|
||||
- **端到端加密同步** —— 数据在离开设备前加密
|
||||
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
|
||||
- **同步主机、密钥、代码片段和设置**
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
### 🎨 主题与定制
|
||||
- **浅色 & 深色模式**
|
||||
- **自定义强调色**
|
||||
- **50+ 终端配色方案**
|
||||
- **字体自定义** —— JetBrains Mono、Fira Code 等
|
||||
- **多语言支持** —— English、简体中文 等
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="界面截图"></a>
|
||||
# 界面截图
|
||||
|
||||
<a name="主机管理"></a>
|
||||
## 主机管理
|
||||
<a name="主界面"></a>
|
||||
## 主界面
|
||||
|
||||
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
**深色模式**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**层级文件夹与分组**
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||

|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
**列表视图**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||

|
||||
|
||||
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
|
||||

|
||||
|
||||
**分屏窗口**
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
**广播模式**
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

|
||||
|
||||
**性能信息与定制**
|
||||
|
||||
监控连接健康状况,并自定义终端的方方面面。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
|
||||
|
||||

|
||||
|
||||
**传输队列**
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
|
||||
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
|
||||
|
||||
| 密钥类型 | 算法 | 推荐用途 |
|
||||
|---------|------|---------|
|
||||
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
|
||||
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
|
||||
| **证书** | CA 签名 | 企业环境、短期认证 |
|
||||
|
||||
**功能:**
|
||||
- 🔑 生成可自定义位长的密钥
|
||||
- 📥 导入 PEM/OpenSSH 格式密钥
|
||||
- 👤 创建可复用身份(用户名 + 认证方式)
|
||||
- 📤 一键导出公钥到远程主机
|
||||
|
||||

|
||||
|
||||
**密钥生成器**
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
|
||||
|
||||
| 类型 | 方向 | 使用场景 | 示例 |
|
||||
|-----|-----|---------|-----|
|
||||
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
|
||||
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
|
||||
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
|
||||
|
||||

|
||||
|
||||
<a name="云同步"></a>
|
||||
## 云同步
|
||||
|
||||
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
|
||||
|
||||
| 服务商 | 最适合 | 配置复杂度 |
|
||||
|-------|-------|----------|
|
||||
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
|
||||
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
|
||||
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
|
||||
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
|
||||
|
||||
**同步内容:**
|
||||
- ✅ 主机与连接设置
|
||||
- ✅ SSH 密钥与证书
|
||||
- ✅ 身份与凭证
|
||||
- ✅ 代码片段与脚本
|
||||
- ✅ 自定义分组与标签
|
||||
- ✅ 端口转发规则
|
||||
- ✅ 应用程序偏好设置
|
||||
|
||||

|
||||
|
||||
<a name="主题与定制"></a>
|
||||
## 主题与定制
|
||||
|
||||
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="支持的发行版"></a>
|
||||
# 支持的发行版
|
||||
|
||||
Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -271,8 +199,6 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="快速开始"></a>
|
||||
# 快速开始
|
||||
|
||||
@@ -356,7 +282,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| 分类 | 技术 |
|
||||
|-----|-----|
|
||||
| 框架 | Electron 39 |
|
||||
| 框架 | Electron 40 |
|
||||
| 前端 | React 19, TypeScript |
|
||||
| 构建工具 | Vite 7 |
|
||||
| 终端 | xterm.js 5 |
|
||||
@@ -387,9 +313,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,6 +99,34 @@ const en: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Open Main Window',
|
||||
'tray.sessions': 'Sessions',
|
||||
'tray.portForwarding': 'Port Forwarding',
|
||||
'tray.status.connected': 'Connected',
|
||||
'tray.status.connecting': 'Connecting',
|
||||
'tray.status.disconnected': 'Disconnected',
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
'vault.sidebar.expand': 'Expand sidebar',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
'settings.application.reportProblem': 'Report a problem',
|
||||
@@ -253,6 +281,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
@@ -463,6 +492,13 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
'pf.tooltip.noHost': 'No relay host configured',
|
||||
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
|
||||
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
|
||||
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
@@ -677,9 +713,6 @@ const en: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
'qs.createWorkspace': 'Create a workspace',
|
||||
'qs.restore': 'Restore',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
|
||||
@@ -863,6 +896,15 @@ const en: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
@@ -1240,6 +1282,15 @@ const en: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -1304,6 +1355,9 @@ const en: Messages = {
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -84,6 +84,34 @@ const zhCN: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '打开主窗口',
|
||||
'tray.sessions': '会话',
|
||||
'tray.portForwarding': '端口转发',
|
||||
'tray.status.connected': '已连接',
|
||||
'tray.status.connecting': '连接中',
|
||||
'tray.status.disconnected': '已断开',
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
'vault.sidebar.expand': '展开侧边栏',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
'settings.application.reportProblem': '反馈问题',
|
||||
@@ -404,9 +432,6 @@ const zhCN: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.recentConnections': '最近连接',
|
||||
'qs.createWorkspace': '创建工作区',
|
||||
'qs.restore': '恢复',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
|
||||
@@ -557,6 +582,15 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
@@ -807,6 +841,13 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未配置中转主机',
|
||||
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
|
||||
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
|
||||
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
@@ -1041,6 +1082,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
@@ -1226,6 +1268,15 @@ const zhCN: Messages = {
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -1290,6 +1341,9 @@ const zhCN: Messages = {
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -39,6 +39,12 @@ interface UseSftpPaneActionsResult {
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
@@ -452,6 +458,88 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFilesAtPath = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("Source pane is no longer available");
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Netcatty bridge not available");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge.deleteLocalFile) {
|
||||
throw new Error("Local delete unavailable");
|
||||
}
|
||||
await bridge.deleteLocalFile(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
const error = new Error("SFTP session not found");
|
||||
handleSessionError(side, error);
|
||||
throw error;
|
||||
}
|
||||
if (!bridge.deleteSftp) {
|
||||
throw new Error("SFTP delete unavailable");
|
||||
}
|
||||
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
|
||||
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateTab(side, pane.id, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== connectionId) return prev;
|
||||
if (prev.connection.currentPath !== path) return prev;
|
||||
|
||||
const removeSet = new Set(fileNames);
|
||||
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of fileNames) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
files: filteredFiles,
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
leftTabsRef,
|
||||
refresh,
|
||||
rightTabsRef,
|
||||
sftpSessionsRef,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (side: "left" | "right", oldName: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -529,6 +617,7 @@ export const useSftpPaneActions = ({
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,13 @@ interface UseSftpTransfersResult {
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => Promise<void>;
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
@@ -39,6 +45,13 @@ interface UseSftpTransfersResult {
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
refresh,
|
||||
@@ -53,6 +66,7 @@ export const useSftpTransfers = ({
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
@@ -268,6 +282,7 @@ export const useSftpTransfers = ({
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: file.type === "directory",
|
||||
@@ -305,7 +320,7 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
): Promise<TransferStatus> => {
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
@@ -461,7 +476,7 @@ export const useSftpTransfers = ({
|
||||
status: "pending",
|
||||
totalBytes: sourceStat?.size || estimatedSize,
|
||||
});
|
||||
return;
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +522,20 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "completed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
@@ -518,7 +547,20 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (isCancelled) {
|
||||
// Don't update status - cancelTransfer already set it to cancelled
|
||||
return;
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
updateTask({
|
||||
@@ -527,6 +569,20 @@ export const useSftpTransfers = ({
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "failed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -534,23 +590,30 @@ export const useSftpTransfers = ({
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
const sourcePane = getActivePane(sourceSide);
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return;
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = sourcePane.connection.currentPath;
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection.id);
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
@@ -585,9 +648,10 @@ export const useSftpTransfers = ({
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId: sourcePane.connection!.id,
|
||||
sourceConnectionId,
|
||||
targetConnectionId: targetPane.connection!.id,
|
||||
direction,
|
||||
status: "pending" as TransferStatus,
|
||||
@@ -601,9 +665,25 @@ export const useSftpTransfers = ({
|
||||
|
||||
setTransfers((prev) => [...prev, ...newTasks]);
|
||||
|
||||
for (const task of newTasks) {
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
if (options?.onTransferComplete) {
|
||||
for (const task of newTasks) {
|
||||
completionHandlersRef.current.set(task.id, options.onTransferComplete);
|
||||
}
|
||||
}
|
||||
|
||||
const results: TransferResult[] = [];
|
||||
|
||||
for (const task of newTasks) {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
results.push({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, sftpSessionsRef],
|
||||
@@ -715,6 +795,19 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const completionHandler = completionHandlersRef.current.get(conflictId);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(conflictId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
14
application/state/useClipboardBackend.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useClipboardBackend = () => {
|
||||
const readClipboardText = useCallback(async (): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
|
||||
|
||||
const text = await bridge.readClipboardText();
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
};
|
||||
@@ -79,8 +79,8 @@ const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
return rules.map((rule) => {
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
@@ -92,7 +92,7 @@ const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive",
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
@@ -152,6 +152,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for storage events for cross-window sync (main window <-> tray panel)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// Only handle changes from our specific key
|
||||
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
|
||||
|
||||
// Parse the new value
|
||||
if (e.newValue) {
|
||||
try {
|
||||
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
|
||||
if (Array.isArray(newRules)) {
|
||||
// Update global state without triggering another localStorage write
|
||||
globalRules = normalizeRulesWithConnections(newRules);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(
|
||||
(
|
||||
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -52,6 +55,9 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
@@ -87,9 +93,15 @@ const isValidUiFontId = (value: string): boolean => {
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
@@ -167,7 +179,7 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
});
|
||||
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
|
||||
});
|
||||
@@ -206,6 +218,12 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
|
||||
@@ -220,6 +238,50 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
|
||||
if (stored !== null) return stored;
|
||||
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
|
||||
return isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
});
|
||||
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
|
||||
// Default to true (enabled)
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
|
||||
: nextValue;
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
localTerminalSettingsVersionRef.current += 1;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = { ...prev, ...incoming };
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
|
||||
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -289,11 +351,11 @@ export const useSettingsState = () => {
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
@@ -307,8 +369,8 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
@@ -326,6 +388,21 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -351,7 +428,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -429,14 +506,14 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
@@ -476,6 +553,12 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -487,7 +570,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -506,7 +589,17 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
}, [terminalSettings]);
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
|
||||
}, [terminalSettings, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
@@ -578,6 +671,49 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -645,7 +781,7 @@ export const useSettingsState = () => {
|
||||
value: TerminalSettings[K]
|
||||
) => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -694,6 +830,13 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
@@ -702,5 +845,11 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -160,6 +160,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
@@ -269,6 +270,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -313,6 +315,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -361,6 +364,8 @@ export const useSftpState = (
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
|
||||
24
application/state/useStoredBoolean.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredBoolean = (
|
||||
storageKey: string,
|
||||
fallback: boolean = false,
|
||||
) => {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
66
application/state/useTrayPanelBackend.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useTrayPanelBackend = () => {
|
||||
const hideTrayPanel = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.hideTrayPanel?.();
|
||||
}, []);
|
||||
|
||||
const openMainWindow = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelRefresh = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelRefresh?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelMenuData = useCallback(
|
||||
(
|
||||
callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelMenuData?.(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
};
|
||||
};
|
||||
@@ -701,6 +701,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
host={hosts.find((h) => h.id === rule.hostId)}
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedRuleId === rule.id}
|
||||
isPending={pendingOperations.has(rule.id)}
|
||||
|
||||
@@ -17,7 +17,6 @@ type QuickSwitcherItem = {
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
@@ -68,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
|
||||
@@ -83,7 +82,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -132,7 +130,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = query.trim().length > 0;
|
||||
// Always show categorized view (Hosts/Tabs/Quick connect)
|
||||
const showCategorized = true;
|
||||
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
@@ -242,209 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{!showCategorized ? (
|
||||
/* Default view: Recent connections with header */
|
||||
<div>
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.recentConnections")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => onCreateWorkspace?.()}
|
||||
>
|
||||
{t("qs.createWorkspace")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled
|
||||
>
|
||||
{t("qs.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No recent connections
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Focused/searching view: Categorized items */
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
|
||||
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
|
||||
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
|
||||
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
|
||||
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
|
||||
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Dialog, DialogContent } from "./ui/dialog";
|
||||
@@ -85,7 +86,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
@@ -508,6 +517,56 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onNavigateUp: handleUp,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts for modal
|
||||
const handleKeyboardRename = useCallback((file: RemoteFile) => {
|
||||
openRenameDialog(file);
|
||||
}, [openRenameDialog]);
|
||||
|
||||
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = joinPathForSession(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
setSelectedFiles(new Set());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
|
||||
|
||||
const handleKeyboardNewFolder = useCallback(() => {
|
||||
handleCreateFolder();
|
||||
}, [handleCreateFolder]);
|
||||
|
||||
useSftpModalKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles: displayFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh: () => loadFiles(currentPath, { force: true }),
|
||||
onRename: handleKeyboardRename,
|
||||
onDelete: handleKeyboardDelete,
|
||||
onNewFolder: handleKeyboardNewFolder,
|
||||
});
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const fileNames = Array.from(selectedFiles);
|
||||
@@ -684,6 +743,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
fileName={textEditorTarget?.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -211,6 +211,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -37,6 +37,8 @@ import { Loader2 } from "lucide-react";
|
||||
import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -51,7 +53,15 @@ interface SftpViewProps {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -84,6 +94,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
// SFTP keyboard shortcuts handler
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles: sftpShowHiddenFiles,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -200,7 +227,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
|
||||
<div className="relative border-r border-border/70 flex flex-col">
|
||||
<div
|
||||
className="relative border-r border-border/70 flex flex-col"
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "left" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Left side tab bar - only show when there are tabs */}
|
||||
{leftTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -240,7 +283,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "right" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Right side tab bar - only show when there are tabs */}
|
||||
{rightTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -305,6 +364,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { ManagedSource } from '../domain/models';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -25,6 +25,8 @@ interface SnippetsManagerProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
@@ -46,6 +48,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
shellHistory,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
@@ -89,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const historyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Shortkey recording state
|
||||
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
|
||||
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
|
||||
|
||||
const existingShortkeys = useMemo(() => (
|
||||
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
|
||||
), [snippets, editingSnippet.id]);
|
||||
|
||||
const isMac = useMemo(() => (
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Validate shortkey for conflicts (case-insensitive comparison)
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
}
|
||||
|
||||
// Check other snippet shortcuts
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const normalizedKey = normalizeKeyString(key);
|
||||
const conflictingSnippet = existingShortkeys.find(snippet => (
|
||||
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
|
||||
));
|
||||
if (conflictingSnippet) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
isMac,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Handle shortkey recording
|
||||
useEffect(() => {
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Escape cancels recording
|
||||
if (e.key === 'Escape') {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip pure modifier keys
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
|
||||
// Validate the new shortkey
|
||||
const error = validateShortkey(keyString);
|
||||
if (error) {
|
||||
setShortkeyError(error);
|
||||
// Don't stop recording, let user try again
|
||||
return;
|
||||
}
|
||||
|
||||
setShortkeyError(null);
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
|
||||
setIsRecordingShortkey(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
};
|
||||
|
||||
// Delay adding click handler by 100ms to prevent the button click that
|
||||
// initiated recording from immediately triggering the click handler
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isRecordingShortkey, isMac, validateShortkey]);
|
||||
|
||||
const handleEdit = (snippet?: Snippet) => {
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
@@ -114,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -606,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingShortkey(true);
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
|
||||
isRecordingShortkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50 bg-background"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={14} className="text-muted-foreground" />
|
||||
{isRecordingShortkey
|
||||
? t('snippets.shortkey.recording')
|
||||
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
|
||||
</button>
|
||||
{shortkeyError && (
|
||||
<p className="text-xs text-destructive">{shortkeyError}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Targets */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -895,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -216,12 +216,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false
|
||||
);
|
||||
// Merge global rules with host-level rules
|
||||
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
|
||||
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
|
||||
// Check if highlighting is enabled at either global or host level
|
||||
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
// Merge rules: include only rules from enabled sources
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
|
||||
// Enable highlighting if either global or host-level is enabled
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
}
|
||||
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
||||
}, [
|
||||
terminalSettings?.keywordHighlightEnabled,
|
||||
terminalSettings?.keywordHighlightRules,
|
||||
host?.keywordHighlightEnabled,
|
||||
host?.keywordHighlightRules
|
||||
]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
const keyBindingsRef = useRef(keyBindings);
|
||||
@@ -235,6 +255,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
||||
onBroadcastInputRef.current = onBroadcastInput;
|
||||
|
||||
// Snippets ref for shortkey support in terminal
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
|
||||
@@ -425,6 +449,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onHotkeyActionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
snippetsRef,
|
||||
sessionId,
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
@@ -442,6 +467,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serializeAddonRef.current = runtime.serializeAddon;
|
||||
searchAddonRef.current = runtime.searchAddon;
|
||||
|
||||
// Apply merged keyword highlight rules immediately after runtime creation
|
||||
// This fixes a timing issue where the useEffect for keyword highlighting
|
||||
// runs before the runtime is created, causing host-level rules to be missed
|
||||
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
|
||||
const term = runtime.term;
|
||||
|
||||
if (host.protocol === "serial") {
|
||||
@@ -991,7 +1030,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
|
||||
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1082,7 +1121,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitVertical={onSplitVertical}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1095,13 +1134,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{isLocalConnection
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localTitle")
|
||||
: t("terminal.dragDrop.remoteTitle")
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
}
|
||||
@@ -1478,46 +1517,46 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SFTPModal
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
@@ -18,6 +19,7 @@ const monacoBasePath = import.meta.env.DEV
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -30,6 +32,8 @@ interface TextEditorModalProps {
|
||||
fileName: string;
|
||||
initialContent: string;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -132,8 +136,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
fileName,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -143,6 +150,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -229,6 +237,58 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -254,6 +314,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
@@ -299,6 +364,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
@@ -352,6 +428,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
@@ -360,7 +438,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'off',
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
|
||||
394
components/TrayPanel.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
status,
|
||||
spinning,
|
||||
}) => {
|
||||
const color =
|
||||
status === "success"
|
||||
? "bg-emerald-500"
|
||||
: status === "warning"
|
||||
? "bg-amber-500"
|
||||
: status === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-500";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
spinning ? "animate-spin" : "",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Session type for workspace grouping
|
||||
type TraySession = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostLabel: string;
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
workspaceId?: string;
|
||||
workspaceTitle?: string;
|
||||
};
|
||||
|
||||
// Collapsible workspace group component
|
||||
const WorkspaceGroup: React.FC<{
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
sessions: TraySession[];
|
||||
activeTabId: string | null;
|
||||
jumpToSession: (sessionId: string) => Promise<void>;
|
||||
t: (key: string) => string;
|
||||
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
|
||||
isAnyActive ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
||||
<span className="font-medium truncate">{title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
const jumpableSessions = useMemo(
|
||||
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
|
||||
[traySessions],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (!activeTabId) return null;
|
||||
return traySessions.find((s) => s.id === activeTabId) || null;
|
||||
}, [activeTabId, traySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelMenuData?.((data) => {
|
||||
setTraySessions(data.sessions || []);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelMenuData]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelRefresh?.(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (document.body && !document.body.contains(target)) return;
|
||||
// Ignore clicks on interactive elements inside the panel.
|
||||
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
|
||||
return;
|
||||
}
|
||||
// Clicking on background should close panel
|
||||
const root = document.getElementById("tray-panel-root");
|
||||
if (root && !root.contains(target)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelCloseRequest(() => {
|
||||
handleClose();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleClose, onTrayPanelCloseRequest]);
|
||||
|
||||
const handleOpenMain = useCallback(() => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
|
||||
const soloSessions: typeof jumpableSessions = [];
|
||||
|
||||
jumpableSessions.forEach((s) => {
|
||||
if (s.workspaceId) {
|
||||
const existing = workspaceGroups.get(s.workspaceId);
|
||||
if (existing) {
|
||||
existing.sessions.push(s);
|
||||
} else {
|
||||
workspaceGroups.set(s.workspaceId, {
|
||||
title: s.workspaceTitle || "Workspace",
|
||||
sessions: [s],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
soloSessions.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
|
||||
<div className="space-y-1">
|
||||
{/* Workspace groups */}
|
||||
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
|
||||
<WorkspaceGroup
|
||||
key={wsId}
|
||||
workspaceId={wsId}
|
||||
title={group.title}
|
||||
sessions={group.sessions}
|
||||
activeTabId={activeTabId}
|
||||
jumpToSession={jumpToSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portForwardingRules.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
|
||||
<div className="space-y-1">
|
||||
{portForwardingRules.map((rule) => {
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const isActive = rule.status === "active";
|
||||
const label = rule.label || (rule.type === "dynamic"
|
||||
? `SOCKS:${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - show when nothing is active */}
|
||||
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<span className="text-2xl mb-2">😴</span>
|
||||
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
|
||||
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrayPanel;
|
||||
@@ -30,11 +30,12 @@ import {
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from "../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -84,7 +85,9 @@ import { Label } from "./ui/label";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "./ui/tooltip";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -104,6 +107,8 @@ interface VaultViewProps {
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onCreateLocalTerminal: () => void;
|
||||
@@ -144,6 +149,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
@@ -189,6 +196,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
false,
|
||||
);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
if (navigateToSection) {
|
||||
@@ -819,7 +832,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (sortMode !== "group") return null;
|
||||
const groups: { name: string; hosts: Host[] }[] = [];
|
||||
const groupMap = new Map<string, Host[]>();
|
||||
|
||||
|
||||
for (const host of displayedHosts) {
|
||||
const groupName = host.group || "";
|
||||
if (!groupMap.has(groupName)) {
|
||||
@@ -827,7 +840,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
groupMap.get(groupName)!.push(host);
|
||||
}
|
||||
|
||||
|
||||
const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const key of sortedKeys) {
|
||||
groups.push({ name: key, hosts: groupMap.get(key)! });
|
||||
@@ -1225,100 +1238,171 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return (
|
||||
<div className="absolute inset-0 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-52 bg-secondary/80 border-r border-border/60 flex flex-col">
|
||||
<div className="px-4 py-4 flex items-center gap-3">
|
||||
<AppLogo className="h-10 w-10 rounded-xl" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className={cn(
|
||||
"bg-secondary/80 border-r border-border/60 flex flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "w-14" : "w-52"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"py-4 flex items-center",
|
||||
sidebarCollapsed ? "px-2 justify-center" : "px-4"
|
||||
)}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<AppLogo className="h-10 w-10 rounded-xl flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<p className="text-sm font-bold text-foreground">Netcatty</p>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.hosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "keys" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("keys");
|
||||
}}
|
||||
>
|
||||
<Key size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "port" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
>
|
||||
<Plug size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.portForwarding")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "snippets" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("snippets");
|
||||
}}
|
||||
>
|
||||
<FileCode size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.snippets")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.snippets")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "knownhosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
>
|
||||
<BookMarked size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.knownHosts")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "logs" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
>
|
||||
<Activity size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.logs")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.logs")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={cn("mt-auto pb-4 space-y-2", sidebarCollapsed ? "px-1.5" : "px-3")}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3"
|
||||
)}
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Settings size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("common.settings")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("common.settings")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 space-y-1">
|
||||
<Button
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} /> {t("vault.nav.hosts")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "keys" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("keys");
|
||||
}}
|
||||
>
|
||||
<Key size={16} /> {t("vault.nav.keychain")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "port" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("port")}
|
||||
>
|
||||
<Plug size={16} /> {t("vault.nav.portForwarding")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "snippets" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "snippets" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("snippets");
|
||||
}}
|
||||
>
|
||||
<FileCode size={16} /> {t("vault.nav.snippets")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "knownhosts" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "knownhosts" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("knownhosts")}
|
||||
>
|
||||
<BookMarked size={16} /> {t("vault.nav.knownHosts")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 h-10",
|
||||
currentSection === "logs" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("logs")}
|
||||
>
|
||||
<Activity size={16} /> {t("vault.nav.logs")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto px-3 pb-4 space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Settings size={16} /> {t("common.settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
@@ -1586,93 +1670,93 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.hosts.length })}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1690,7 +1774,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -1733,7 +1817,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
@@ -1825,7 +1909,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1964,7 +2048,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2075,6 +2159,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
shellHistory={shellHistory}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPackagesChange={onUpdateSnippetPackages}
|
||||
onSave={(s) =>
|
||||
onUpdateSnippets(
|
||||
@@ -2379,8 +2465,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTargetPath) {
|
||||
const isManaged = managedGroupPaths.has(deleteTargetPath);
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { PortForwardingRule } from '../../domain/models';
|
||||
import { Host, PortForwardingRule } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { getStatusColor,getTypeColor } from './utils';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
export interface RuleCardProps {
|
||||
rule: PortForwardingRule;
|
||||
host?: Host; // The relay host for this rule (for tooltip display)
|
||||
viewMode: ViewMode;
|
||||
isSelected: boolean;
|
||||
isPending: boolean;
|
||||
@@ -28,6 +30,7 @@ export interface RuleCardProps {
|
||||
|
||||
export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
rule,
|
||||
host,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isPending,
|
||||
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate cursor-default">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs">
|
||||
<div className="space-y-1 text-xs">
|
||||
{host ? (
|
||||
<>
|
||||
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
|
||||
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
|
||||
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-1 mt-1">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.tooltip.dynamicDesc')
|
||||
: rule.type === 'local'
|
||||
? t('pf.tooltip.localDesc')
|
||||
: t('pf.tooltip.remoteDesc')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
|
||||
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app"] as const, []);
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="shortcuts">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, and session logs
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat } from "../../../domain/models";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
@@ -31,6 +32,11 @@ interface SettingsSystemTabProps {
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -40,13 +46,21 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -116,6 +130,56 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
// Handle global toggle hotkey recording
|
||||
const cancelHotkeyRecording = useCallback(() => {
|
||||
setIsRecordingHotkey(false);
|
||||
}, []);
|
||||
|
||||
const handleResetHotkey = useCallback(() => {
|
||||
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
|
||||
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
setToggleWindowHotkey(defaultHotkey);
|
||||
setHotkeyError(null);
|
||||
}, [isMac, setToggleWindowHotkey]);
|
||||
|
||||
// Hotkey recording effect
|
||||
useEffect(() => {
|
||||
if (!isRecordingHotkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
cancelHotkeyRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modifier-only keys
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
setToggleWindowHotkey(keyString);
|
||||
setHotkeyError(null);
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener("click", handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
|
||||
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
|
||||
@@ -295,6 +359,68 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global Toggle Window Section (Quake Mode) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.closeToTray")}
|
||||
description={t("settings.globalHotkey.closeToTrayDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={closeToTray}
|
||||
onChange={setCloseToTray}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalHotkey.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
@@ -88,252 +88,268 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
}) => (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
|
||||
if (!tooltipsReady) return;
|
||||
setOpenTooltip(open ? id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
{rootLabel}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
156
components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* useSftpModalKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTPModal operations.
|
||||
* Supports select all, rename, delete, refresh, and new folder.
|
||||
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
// SFTP Modal action names that we handle (subset of main SFTP actions)
|
||||
const SFTP_MODAL_ACTIONS = new Set([
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpModalKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
open: boolean;
|
||||
files: RemoteFile[];
|
||||
visibleFiles: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
setSelectedFiles: (files: Set<string>) => void;
|
||||
onRefresh: () => void;
|
||||
onRename?: (file: RemoteFile) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpModalKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
}: UseSftpModalKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or modal is not open
|
||||
if (hotkeyScheme === "disabled" || !open) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_MODAL_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
switch (action) {
|
||||
case "sftpSelectAll": {
|
||||
// Select all files
|
||||
const allFileNames = new Set(
|
||||
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
|
||||
);
|
||||
setSelectedFiles(allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length !== 1) return;
|
||||
const file = files.find((f) => f.name === selectedArray[0]);
|
||||
if (file && onRename) {
|
||||
onRename(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length === 0) return;
|
||||
onDelete?.(selectedArray);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh file list
|
||||
onRefresh();
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
onNewFolder?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
keyBindings,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
@@ -32,6 +32,8 @@ interface SftpOverlaysProps {
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (content: string) => void;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -63,6 +65,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
handleSaveTextFile,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
@@ -178,6 +182,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
@@ -21,6 +21,7 @@ import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
||||
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -195,6 +196,33 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
sortedDisplayFiles,
|
||||
});
|
||||
|
||||
// Handle keyboard shortcut dialog actions
|
||||
const dialogActionHandlers = useMemo(
|
||||
() => ({
|
||||
onRename: (fileName: string) => openRenameDialog(fileName),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
|
||||
onNewFolder: () => setShowNewFolderDialog(true),
|
||||
onNewFile: () => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
},
|
||||
}),
|
||||
[
|
||||
getNextUntitledName,
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.files,
|
||||
setFileNameError,
|
||||
setNewFileName,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
};
|
||||
|
||||
124
components/sftp/hooks/useSftpClipboard.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SFTP Clipboard Store
|
||||
*
|
||||
* Manages clipboard state for SFTP file operations (copy/cut/paste)
|
||||
* This is a simple store that holds the clipboard state and operation type.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpClipboardOperation = "copy" | "cut";
|
||||
|
||||
export interface SftpClipboardFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export interface SftpClipboardState {
|
||||
files: SftpClipboardFile[];
|
||||
sourcePath: string;
|
||||
sourceConnectionId: string;
|
||||
sourceSide: "left" | "right";
|
||||
operation: SftpClipboardOperation;
|
||||
}
|
||||
|
||||
type ClipboardListener = () => void;
|
||||
|
||||
let clipboardState: SftpClipboardState | null = null;
|
||||
const clipboardListeners = new Set<ClipboardListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
clipboardListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpClipboardStore = {
|
||||
getSnapshot: (): SftpClipboardState | null => clipboardState,
|
||||
|
||||
subscribe: (listener: ClipboardListener) => {
|
||||
clipboardListeners.add(listener);
|
||||
return () => clipboardListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy files to clipboard
|
||||
*/
|
||||
copy: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "copy",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cut files to clipboard
|
||||
*/
|
||||
cut: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "cut",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear clipboard (called after paste for cut operation)
|
||||
*/
|
||||
clear: () => {
|
||||
clipboardState = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update clipboard file list (used for partial cut transfers)
|
||||
*/
|
||||
updateFiles: (files: SftpClipboardFile[]) => {
|
||||
if (!clipboardState) return;
|
||||
if (files.length === 0) {
|
||||
clipboardState = null;
|
||||
} else {
|
||||
clipboardState = {
|
||||
...clipboardState,
|
||||
files,
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are files in the clipboard
|
||||
*/
|
||||
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
|
||||
|
||||
/**
|
||||
* Get the clipboard state
|
||||
*/
|
||||
get: (): SftpClipboardState | null => clipboardState,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to clipboard state changes
|
||||
*/
|
||||
export const useSftpClipboard = (): SftpClipboardState | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpClipboardStore.subscribe,
|
||||
sftpClipboardStore.getSnapshot,
|
||||
sftpClipboardStore.getSnapshot
|
||||
);
|
||||
};
|
||||
120
components/sftp/hooks/useSftpDialogAction.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* SFTP Dialog Action Store
|
||||
*
|
||||
* Manages dialog action triggers for SFTP operations.
|
||||
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
||||
|
||||
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
export interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
|
||||
type ActionListener = () => void;
|
||||
|
||||
let dialogAction: SftpDialogAction | null = null;
|
||||
const actionListeners = new Set<ActionListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
actionListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpDialogActionStore = {
|
||||
getSnapshot: (): SftpDialogAction | null => dialogAction,
|
||||
|
||||
subscribe: (listener: ActionListener) => {
|
||||
actionListeners.add(listener);
|
||||
return () => actionListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current action (called after a pane handles it)
|
||||
*/
|
||||
clear: () => {
|
||||
dialogAction = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current action
|
||||
*/
|
||||
get: (): SftpDialogAction | null => dialogAction,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to dialog action changes
|
||||
*/
|
||||
export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpDialogActionStore.subscribe,
|
||||
sftpDialogActionStore.getSnapshot,
|
||||
sftpDialogActionStore.getSnapshot
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for a pane to respond to dialog actions
|
||||
* Only the pane matching the targetSide will execute the callback
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
}
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
case "rename":
|
||||
if (handlers.onRename && action.targetFiles?.[0]) {
|
||||
handlers.onRename(action.targetFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
if (handlers.onDelete && action.targetFiles) {
|
||||
handlers.onDelete(action.targetFiles);
|
||||
}
|
||||
break;
|
||||
case "newFolder":
|
||||
handlers.onNewFolder?.();
|
||||
break;
|
||||
case "newFile":
|
||||
handlers.onNewFile?.();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, handlers]);
|
||||
};
|
||||
54
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SFTP Focused Pane Store
|
||||
*
|
||||
* Tracks which SFTP pane (left or right) is currently focused.
|
||||
* This is used to determine which pane should receive keyboard shortcut actions.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpFocusedSide = "left" | "right";
|
||||
|
||||
type FocusListener = () => void;
|
||||
|
||||
let focusedSide: SftpFocusedSide = "left";
|
||||
const focusListeners = new Set<FocusListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
focusListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpFocusStore = {
|
||||
getSnapshot: (): SftpFocusedSide => focusedSide,
|
||||
|
||||
subscribe: (listener: FocusListener) => {
|
||||
focusListeners.add(listener);
|
||||
return () => focusListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the focused side
|
||||
*/
|
||||
setFocusedSide: (side: SftpFocusedSide) => {
|
||||
if (focusedSide !== side) {
|
||||
focusedSide = side;
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current focused side
|
||||
*/
|
||||
getFocusedSide: (): SftpFocusedSide => focusedSide,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to focused side changes
|
||||
*/
|
||||
export const useSftpFocusedSide = (): SftpFocusedSide => {
|
||||
return useSyncExternalStore(
|
||||
sftpFocusStore.subscribe,
|
||||
sftpFocusStore.getSnapshot,
|
||||
sftpFocusStore.getSnapshot
|
||||
);
|
||||
};
|
||||
291
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* useSftpKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTP operations.
|
||||
* Supports copy, cut, paste, select all, rename, delete, refresh, and new folder.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
|
||||
import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
// SFTP action names that we handle
|
||||
const SFTP_ACTIONS = new Set([
|
||||
"sftpCopy",
|
||||
"sftpCut",
|
||||
"sftpPaste",
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
isActive: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or SFTP is not active
|
||||
if (hotkeyScheme === "disabled" || !isActive) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sftp = sftpRef.current;
|
||||
const focusedSide = sftpFocusStore.getFocusedSide();
|
||||
|
||||
// Get the active pane for the focused side
|
||||
const pane = focusedSide === "left"
|
||||
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
|
||||
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
|
||||
|
||||
if (!pane || !pane.connection) return;
|
||||
|
||||
switch (action) {
|
||||
case "sftpCopy": {
|
||||
// Copy selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.copy(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpCut": {
|
||||
// Cut selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.cut(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpPaste": {
|
||||
// Paste files from clipboard
|
||||
const clipboard = sftpClipboardStore.get();
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Use startTransfer to paste files from source to current pane
|
||||
// The transfer direction is determined by clipboard sourceSide and current focusedSide
|
||||
if (clipboard.sourceSide !== focusedSide) {
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
if (!sourcePane?.connection) {
|
||||
toast.info("Paste source is no longer available.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-pane paste - use startTransfer
|
||||
try {
|
||||
const isCut = clipboard.operation === "cut";
|
||||
const pendingNames = new Set(clipboard.files.map((file) => file.name));
|
||||
const completedNames = new Set<string>();
|
||||
const failedNames = new Set<string>();
|
||||
|
||||
const updateClipboardAfterCompletion = (showToast: boolean) => {
|
||||
if (!isCut) return;
|
||||
const current = sftpClipboardStore.get();
|
||||
if (
|
||||
!current ||
|
||||
current.operation !== "cut" ||
|
||||
current.sourceConnectionId !== clipboard.sourceConnectionId ||
|
||||
current.sourcePath !== clipboard.sourcePath ||
|
||||
current.sourceSide !== clipboard.sourceSide
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingFiles = current.files.filter((file) => !completedNames.has(file.name));
|
||||
if (remainingFiles.length === 0) {
|
||||
sftpClipboardStore.clear();
|
||||
} else {
|
||||
sftpClipboardStore.updateFiles(remainingFiles);
|
||||
}
|
||||
|
||||
if (showToast && failedNames.size > 0) {
|
||||
toast.info("Some items could not be transferred and were kept in the clipboard.", "SFTP");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransferComplete = async (result: {
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: string;
|
||||
}) => {
|
||||
if (!isCut) return;
|
||||
const sourceFileName = result.originalFileName ?? result.fileName;
|
||||
if (!pendingNames.has(sourceFileName)) return;
|
||||
pendingNames.delete(sourceFileName);
|
||||
|
||||
if (result.status === "completed") {
|
||||
try {
|
||||
await sftp.deleteFilesAtPath(
|
||||
clipboard.sourceSide,
|
||||
clipboard.sourceConnectionId,
|
||||
clipboard.sourcePath,
|
||||
[sourceFileName],
|
||||
);
|
||||
completedNames.add(sourceFileName);
|
||||
} catch {
|
||||
failedNames.add(sourceFileName);
|
||||
}
|
||||
} else {
|
||||
failedNames.add(sourceFileName);
|
||||
}
|
||||
|
||||
updateClipboardAfterCompletion(pendingNames.size === 0);
|
||||
};
|
||||
|
||||
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
|
||||
sourcePane,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
sourceConnectionId: clipboard.sourceConnectionId,
|
||||
onTransferComplete: handleTransferComplete,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Paste failed. Please try again.", "SFTP");
|
||||
}
|
||||
} else {
|
||||
// Same-pane paste is not supported - show info toast
|
||||
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
const allFileNames = visibleFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
sftp.rangeSelect(focusedSide, allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length !== 1) return;
|
||||
sftpDialogActionStore.trigger("rename", selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
sftpDialogActionStore.trigger("delete", selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh the current pane
|
||||
sftp.refresh(focusedSide);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
295
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Host Keyword Highlight Popover
|
||||
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
|
||||
*/
|
||||
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, KeywordHighlightRule } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface HostKeywordHighlightPopoverProps {
|
||||
host?: Host;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
|
||||
host,
|
||||
onUpdateHost,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
buttonClassName = '',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newRuleLabel, setNewRuleLabel] = useState('');
|
||||
const [newRulePattern, setNewRulePattern] = useState('');
|
||||
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
|
||||
const enabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: newRules });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
|
||||
}, [host, onUpdateHost, enabled]);
|
||||
|
||||
const validatePattern = (pattern: string): boolean => {
|
||||
try {
|
||||
new RegExp(pattern, 'gi');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = useCallback(() => {
|
||||
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePattern(newRulePattern)) {
|
||||
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: uuidv4(),
|
||||
label: newRuleLabel.trim(),
|
||||
patterns: [newRulePattern.trim()],
|
||||
color: newRuleColor,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
updateRules([...rules, newRule]);
|
||||
|
||||
// Reset form
|
||||
setNewRuleLabel('');
|
||||
setNewRulePattern('');
|
||||
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
|
||||
setPatternError(null);
|
||||
|
||||
// Auto-enable if adding the first rule and not enabled
|
||||
if (rules.length === 0 && !enabled && host && onUpdateHost) {
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [newRule], keywordHighlightEnabled: true });
|
||||
}
|
||||
}, [newRuleLabel, newRulePattern, newRuleColor, rules, updateRules, enabled, host, onUpdateHost, t]);
|
||||
|
||||
const handleDeleteRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.filter((r) => r.id !== ruleId));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleColorChange = useCallback((ruleId: string, color: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleToggleRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const handlePatternChange = (value: string) => {
|
||||
setNewRulePattern(value);
|
||||
if (patternError && validatePattern(value)) {
|
||||
setPatternError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable if no host (local/serial terminal sessions)
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title={t('terminal.toolbar.hostHighlight.title')}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start" side="top">
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={toggleEnabled}
|
||||
className={`
|
||||
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
|
||||
transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-4' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{rules.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
|
||||
{t('terminal.toolbar.hostHighlight.noRules')}
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
title={rule.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ color: rule.enabled ? rule.color : 'inherit' }}
|
||||
>
|
||||
{rule.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{rule.patterns.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => handleColorChange(rule.id, e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
|
||||
/>
|
||||
<span
|
||||
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add new rule form */}
|
||||
<div className="p-2 border-t bg-muted/20 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('terminal.toolbar.hostHighlight.addRule')}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
|
||||
value={newRuleLabel}
|
||||
onChange={(e) => setNewRuleLabel(e.target.value)}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={newRuleColor}
|
||||
onChange={(e) => setNewRuleColor(e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
|
||||
/>
|
||||
<span
|
||||
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: newRuleColor }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
|
||||
value={newRulePattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value)}
|
||||
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{patternError && (
|
||||
<div className="text-[10px] text-destructive">{patternError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{rules.length > 0 && (
|
||||
<div className="p-2 border-t flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<RotateCcw size={10} className="mr-1" />
|
||||
{t('terminal.toolbar.hostHighlight.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostKeywordHighlightPopover;
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Search buttons and close button in terminal status bar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { FolderInput, X, Zap, Palette, Search } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -10,6 +10,7 @@ import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
|
||||
export interface TerminalToolbarProps {
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
@@ -55,6 +56,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
@@ -163,6 +165,14 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
onUpdateHost={onUpdateHost}
|
||||
isOpen={highlightPopoverOpen}
|
||||
setIsOpen={setHighlightPopoverOpen}
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -33,7 +33,11 @@ export const useTerminalContextActions = ({
|
||||
if (!term) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
|
||||
if (text && sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export type { TerminalConnectionProgressProps } from './TerminalConnectionProgre
|
||||
export { TerminalToolbar } from './TerminalToolbar';
|
||||
export type { TerminalToolbarProps } from './TerminalToolbar';
|
||||
|
||||
export { HostKeywordHighlightPopover } from './HostKeywordHighlightPopover';
|
||||
export type { HostKeywordHighlightPopoverProps } from './HostKeywordHighlightPopover';
|
||||
|
||||
export { TerminalConnectionDialog } from './TerminalConnectionDialog';
|
||||
export type { ChainProgress,TerminalConnectionDialogProps } from './TerminalConnectionDialog';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
TerminalSettings,
|
||||
TerminalTheme,
|
||||
} from "../../../types";
|
||||
import { matchesKeyBinding } from "../../../domain/models";
|
||||
|
||||
type TerminalBackendApi = {
|
||||
openExternalAvailable: () => boolean;
|
||||
@@ -66,6 +67,9 @@ export type CreateXTermRuntimeContext = {
|
||||
((data: string, sourceSessionId: string) => void) | undefined
|
||||
>;
|
||||
|
||||
// Snippets for shortkey support
|
||||
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>;
|
||||
|
||||
sessionId: string;
|
||||
statusRef: RefObject<TerminalSession["status"]>;
|
||||
onCommandExecuted?: (
|
||||
@@ -333,12 +337,41 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const currentScheme = ctx.hotkeySchemeRef.current;
|
||||
// Use shared utility for platform detection when hotkey scheme is disabled
|
||||
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
|
||||
|
||||
// Check snippet shortcuts first (even if hotkeys are disabled)
|
||||
const snippets = ctx.snippetsRef?.current;
|
||||
if (snippets && snippets.length > 0) {
|
||||
for (const snippet of snippets) {
|
||||
if (snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id && ctx.statusRef.current === "connected") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Send the snippet command to the terminal
|
||||
const payload = `${normalizeLineEndings(snippet.command)}\r`;
|
||||
ctx.terminalBackend.writeToSession(id, payload);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
|
||||
}
|
||||
if (ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
ctx.commandBufferRef.current = "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentBindings = ctx.keyBindingsRef.current;
|
||||
if (currentScheme === "disabled" || currentBindings.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMac = currentScheme === "mac";
|
||||
const matched = checkAppShortcut(e, currentBindings, isMac);
|
||||
if (!matched) return true;
|
||||
|
||||
@@ -374,7 +407,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
case "paste": {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
|
||||
if (id) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -406,7 +443,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface Host {
|
||||
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
|
||||
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
|
||||
managedSourceId?: string; // Reference to ManagedSource.id
|
||||
// Host-level keyword highlighting (overrides/extends global settings)
|
||||
keywordHighlightRules?: KeywordHighlightRule[];
|
||||
keywordHighlightEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -136,6 +139,7 @@ export interface Snippet {
|
||||
tags?: string[];
|
||||
package?: string; // package path
|
||||
targets?: string[]; // host ids
|
||||
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
|
||||
}
|
||||
|
||||
export interface TerminalLine {
|
||||
@@ -173,7 +177,7 @@ export interface KeyBinding {
|
||||
label: string;
|
||||
mac: string; // e.g., '⌘+1', '⌘+⌥+arrows'
|
||||
pc: string; // e.g., 'Ctrl+1', 'Ctrl+Alt+arrows'
|
||||
category: 'tabs' | 'terminal' | 'navigation' | 'app';
|
||||
category: 'tabs' | 'terminal' | 'navigation' | 'app' | 'sftp';
|
||||
}
|
||||
|
||||
// User's custom key bindings - only stores overrides from defaults
|
||||
@@ -261,6 +265,12 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if (!parsed) return false;
|
||||
|
||||
const { modifiers, key } = parsed;
|
||||
|
||||
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
|
||||
const hasPcModifiers = modifiers.some((modifier) => ['Ctrl', 'Alt', 'Win'].includes(modifier));
|
||||
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
if (isMac) {
|
||||
@@ -285,18 +295,26 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
}
|
||||
|
||||
// Check key
|
||||
let eventKey = e.key;
|
||||
if (eventKey === ' ') eventKey = 'Space';
|
||||
else if (eventKey === 'ArrowUp') eventKey = '↑';
|
||||
else if (eventKey === 'ArrowDown') eventKey = '↓';
|
||||
else if (eventKey === 'ArrowLeft') eventKey = '←';
|
||||
else if (eventKey === 'ArrowRight') eventKey = '→';
|
||||
else if (eventKey === 'Escape') eventKey = 'Esc';
|
||||
else if (eventKey === '[') eventKey = '[';
|
||||
else if (eventKey === ']') eventKey = ']';
|
||||
|
||||
return eventKey.toLowerCase() === key.toLowerCase();
|
||||
const normalizeKey = (rawKey: string): string => {
|
||||
let normalizedKey = rawKey;
|
||||
if (normalizedKey === ' ') normalizedKey = 'Space';
|
||||
else if (normalizedKey === 'ArrowUp') normalizedKey = '↑';
|
||||
else if (normalizedKey === 'ArrowDown') normalizedKey = '↓';
|
||||
else if (normalizedKey === 'ArrowLeft') normalizedKey = '←';
|
||||
else if (normalizedKey === 'ArrowRight') normalizedKey = '→';
|
||||
else if (normalizedKey === 'Escape') normalizedKey = 'Esc';
|
||||
else if (normalizedKey === 'Backspace') normalizedKey = '⌫';
|
||||
else if (normalizedKey === 'Delete') normalizedKey = 'Del';
|
||||
else if (normalizedKey === '[') normalizedKey = '[';
|
||||
else if (normalizedKey === ']') normalizedKey = ']';
|
||||
else if (normalizedKey === 'Del') normalizedKey = 'Del';
|
||||
return normalizedKey;
|
||||
};
|
||||
|
||||
const eventKey = normalizeKey(e.key);
|
||||
const parsedKey = normalizeKey(key);
|
||||
|
||||
return eventKey.toLowerCase() === parsedKey.toLowerCase();
|
||||
};
|
||||
|
||||
export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
@@ -328,6 +346,16 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
|
||||
// SFTP Operations
|
||||
{ id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' },
|
||||
{ id: 'sftp-cut', action: 'sftpCut', label: 'Cut Files', mac: '⌘ + X', pc: 'Ctrl + X', category: 'sftp' },
|
||||
{ id: 'sftp-paste', action: 'sftpPaste', label: 'Paste Files', mac: '⌘ + V', pc: 'Ctrl + V', category: 'sftp' },
|
||||
{ id: 'sftp-select-all', action: 'sftpSelectAll', label: 'Select All Files', mac: '⌘ + A', pc: 'Ctrl + A', category: 'sftp' },
|
||||
{ id: 'sftp-rename', action: 'sftpRename', label: 'Rename File', mac: 'F2', pc: 'F2', category: 'sftp' },
|
||||
{ id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' },
|
||||
{ id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' },
|
||||
{ id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' },
|
||||
];
|
||||
|
||||
// Terminal appearance settings
|
||||
@@ -553,6 +581,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
|
||||
export interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceConnectionId: string;
|
||||
|
||||
718
electron/bridges/globalShortcutBridge.cjs
Normal file
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Global Shortcut Bridge - Handles global keyboard shortcuts and system tray
|
||||
* Implements the "Quake mode" / drop-down terminal feature
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
let electronModule = null;
|
||||
let tray = null;
|
||||
let closeToTray = false;
|
||||
let currentHotkey = null;
|
||||
let hotkeyEnabled = false;
|
||||
|
||||
const STATUS_TEXT = {
|
||||
session: {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting",
|
||||
disconnected: "Disconnected",
|
||||
},
|
||||
portForward: {
|
||||
active: "Active",
|
||||
connecting: "Connecting",
|
||||
inactive: "Inactive",
|
||||
error: "Error",
|
||||
},
|
||||
};
|
||||
// Dynamic tray menu data (synced from renderer)
|
||||
let trayMenuData = {
|
||||
sessions: [], // { id, label, hostLabel, status }
|
||||
portForwardRules: [], // { id, label, type, localPort, remoteHost, remotePort, status, hostId }
|
||||
};
|
||||
|
||||
let trayPanelWindow = null;
|
||||
|
||||
let trayPanelRefreshTimer = null;
|
||||
|
||||
function openMainWindow() {
|
||||
const { app } = electronModule;
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getTrayPanelUrl() {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (devServerUrl) {
|
||||
return `${devServerUrl.replace(/\/$/, "")}/#/tray`;
|
||||
}
|
||||
return "app://netcatty/index.html#/tray";
|
||||
}
|
||||
|
||||
function ensureTrayPanelWindow() {
|
||||
const { BrowserWindow } = electronModule;
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) return trayPanelWindow;
|
||||
|
||||
trayPanelWindow = new BrowserWindow({
|
||||
width: 360,
|
||||
height: 520,
|
||||
show: false,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
fullscreenable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
hasShadow: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
// Forward renderer logs to main process output for easy debugging.
|
||||
console.log(`[TrayPanel:renderer:${level}] ${message}`);
|
||||
});
|
||||
|
||||
trayPanelWindow.on("blur", () => {
|
||||
try {
|
||||
trayPanelWindow?.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const url = getTrayPanelUrl();
|
||||
console.log("[TrayPanel] loadURL", url);
|
||||
void trayPanelWindow.loadURL(url);
|
||||
|
||||
trayPanelWindow.webContents.on("did-finish-load", () => {
|
||||
try {
|
||||
trayPanelWindow?.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return trayPanelWindow;
|
||||
}
|
||||
|
||||
function showTrayPanel() {
|
||||
if (!tray) return;
|
||||
const { screen } = electronModule;
|
||||
const win = ensureTrayPanelWindow();
|
||||
|
||||
const trayBounds = tray.getBounds();
|
||||
const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y });
|
||||
const workArea = display.workArea;
|
||||
|
||||
const panelBounds = win.getBounds();
|
||||
const x = Math.min(
|
||||
Math.max(trayBounds.x + Math.round(trayBounds.width / 2) - Math.round(panelBounds.width / 2), workArea.x),
|
||||
workArea.x + workArea.width - panelBounds.width,
|
||||
);
|
||||
const y = Math.min(trayBounds.y + trayBounds.height + 6, workArea.y + workArea.height - panelBounds.height);
|
||||
|
||||
win.setBounds({ x, y, width: panelBounds.width, height: panelBounds.height }, false);
|
||||
win.show();
|
||||
win.focus();
|
||||
|
||||
try {
|
||||
win.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (trayPanelRefreshTimer) clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = setInterval(() => {
|
||||
try {
|
||||
if (!trayPanelWindow || trayPanelWindow.isDestroyed() || !trayPanelWindow.isVisible()) return;
|
||||
trayPanelWindow.webContents?.send("netcatty:trayPanel:refresh");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hideTrayPanel() {
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
trayPanelWindow.hide();
|
||||
}
|
||||
|
||||
if (trayPanelRefreshTimer) {
|
||||
clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrayPanel() {
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible()) {
|
||||
hideTrayPanel();
|
||||
} else {
|
||||
showTrayPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTrayIconPath() {
|
||||
const { app } = electronModule;
|
||||
|
||||
// Use different icons for different platforms
|
||||
// macOS: template image (black + transparent, system handles color)
|
||||
// Windows/Linux: colored icon
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconName = isMac ? "tray-iconTemplate.png" : "tray-icon.png";
|
||||
|
||||
// Security: Only use known packaged icon locations, ignore renderer-provided paths
|
||||
const candidates = [
|
||||
path.join(app.getAppPath(), "dist", iconName),
|
||||
path.join(app.getAppPath(), "public", iconName),
|
||||
path.join(__dirname, "../../public", iconName),
|
||||
path.join(__dirname, "../../dist", iconName),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main window reference
|
||||
* Uses windowManager's tracked mainWindow for reliability
|
||||
*/
|
||||
function getMainWindow() {
|
||||
// Prefer the explicitly tracked main window from windowManager
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const tracked = windowManager.getMainWindow?.();
|
||||
if (tracked && !tracked.isDestroyed?.()) {
|
||||
return tracked;
|
||||
}
|
||||
// Fallback: filter out tray panel window from all windows
|
||||
const { BrowserWindow } = electronModule;
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
const mainWins = wins.filter((w) => w !== trayPanelWindow && !w.isDestroyed?.());
|
||||
return mainWins && mainWins.length ? mainWins[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotkey string from frontend format to Electron accelerator format
|
||||
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
|
||||
* "Ctrl + `" -> "CommandOrControl+`"
|
||||
* "Alt + Space" -> "Alt+Space"
|
||||
*/
|
||||
function toElectronAccelerator(hotkeyStr) {
|
||||
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the hotkey string
|
||||
const parts = hotkeyStr.split("+").map((p) => p.trim());
|
||||
|
||||
// Convert each part to Electron accelerator format
|
||||
const acceleratorParts = parts.map((part) => {
|
||||
// Mac symbols to Electron format
|
||||
if (part === "⌘" || part === "Cmd" || part === "Command") {
|
||||
return "CommandOrControl";
|
||||
}
|
||||
if (part === "⌃" || part === "Ctrl" || part === "Control") {
|
||||
return "Control";
|
||||
}
|
||||
if (part === "⌥" || part === "Alt" || part === "Option") {
|
||||
return "Alt";
|
||||
}
|
||||
if (part === "Shift") {
|
||||
return "Shift";
|
||||
}
|
||||
if (part === "Win" || part === "Super" || part === "Meta") {
|
||||
return "Super";
|
||||
}
|
||||
// Arrow symbols
|
||||
if (part === "↑") return "Up";
|
||||
if (part === "↓") return "Down";
|
||||
if (part === "←") return "Left";
|
||||
if (part === "→") return "Right";
|
||||
// Special keys
|
||||
if (part === "↵" || part === "Enter" || part === "Return") return "Return";
|
||||
if (part === "⇥" || part === "Tab") return "Tab";
|
||||
if (part === "⌫" || part === "Backspace") return "Backspace";
|
||||
if (part === "Del" || part === "Delete") return "Delete";
|
||||
if (part === "Esc" || part === "Escape") return "Escape";
|
||||
if (part === "Space") return "Space";
|
||||
// Backtick/grave accent
|
||||
if (part === "`" || part === "~") return "`";
|
||||
// Function keys
|
||||
if (/^F\d+$/i.test(part)) return part.toUpperCase();
|
||||
// Single character - keep as-is
|
||||
return part;
|
||||
});
|
||||
|
||||
return acceleratorParts.join("+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the main window visibility
|
||||
*/
|
||||
function toggleWindowVisibility() {
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
|
||||
try {
|
||||
// Check if window is minimized first - minimized windows may still report isVisible() = true
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (win.isVisible()) {
|
||||
if (win.isFocused()) {
|
||||
// Window is visible and focused - hide it
|
||||
win.hide();
|
||||
} else {
|
||||
// Window is visible but not focused - focus it
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Window is hidden - show and focus it
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error toggling window visibility:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the global toggle hotkey
|
||||
*/
|
||||
function registerGlobalHotkey(hotkeyStr) {
|
||||
const { globalShortcut } = electronModule;
|
||||
|
||||
// Unregister existing hotkey first
|
||||
unregisterGlobalHotkey();
|
||||
|
||||
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
return { success: true, enabled: false };
|
||||
}
|
||||
|
||||
const accelerator = toElectronAccelerator(hotkeyStr);
|
||||
if (!accelerator) {
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
return { success: false, error: "Invalid hotkey format" };
|
||||
}
|
||||
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, toggleWindowVisibility);
|
||||
if (registered) {
|
||||
hotkeyEnabled = true;
|
||||
currentHotkey = hotkeyStr;
|
||||
console.log(`[GlobalShortcut] Registered hotkey: ${accelerator}`);
|
||||
return { success: true, enabled: true, accelerator };
|
||||
} else {
|
||||
console.warn(`[GlobalShortcut] Failed to register hotkey: ${accelerator}`);
|
||||
return { success: false, error: "Hotkey may be in use by another application" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[GlobalShortcut] Error registering hotkey:", err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the global toggle hotkey
|
||||
*/
|
||||
function unregisterGlobalHotkey() {
|
||||
if (!hotkeyEnabled || !currentHotkey) return;
|
||||
|
||||
const { globalShortcut } = electronModule;
|
||||
const accelerator = toElectronAccelerator(currentHotkey);
|
||||
|
||||
if (accelerator) {
|
||||
try {
|
||||
globalShortcut.unregister(accelerator);
|
||||
console.log(`[GlobalShortcut] Unregistered hotkey: ${accelerator}`);
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error unregistering hotkey:", err);
|
||||
}
|
||||
}
|
||||
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the system tray icon
|
||||
*/
|
||||
function createTray() {
|
||||
const { Tray, Menu, app, nativeImage } = electronModule;
|
||||
|
||||
if (tray) {
|
||||
// Tray already exists
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the tray icon
|
||||
let trayIcon;
|
||||
const resolvedIconPath = resolveTrayIconPath();
|
||||
if (resolvedIconPath) {
|
||||
trayIcon = nativeImage.createFromPath(resolvedIconPath);
|
||||
// Resize for tray (16x16 on most platforms, 22x22 on some Linux)
|
||||
if (process.platform === "darwin") {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
trayIcon.setTemplateImage(true);
|
||||
} else {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
}
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon || nativeImage.createEmpty());
|
||||
tray.setToolTip("Netcatty");
|
||||
|
||||
// Build and set initial context menu
|
||||
updateTrayMenu();
|
||||
|
||||
// Click on tray icon toggles tray panel
|
||||
tray.on("click", () => {
|
||||
toggleTrayPanel();
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] System tray created");
|
||||
} catch (err) {
|
||||
console.error("[GlobalShortcut] Error creating tray:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tray context menu with dynamic content
|
||||
*/
|
||||
function buildTrayMenuTemplate() {
|
||||
const { app } = electronModule;
|
||||
const menuTemplate = [];
|
||||
|
||||
// Open Main Window
|
||||
menuTemplate.push({
|
||||
label: "Open Main Window",
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
menuTemplate.push({ type: "separator" });
|
||||
|
||||
// Active Sessions
|
||||
if (trayMenuData.sessions && trayMenuData.sessions.length > 0) {
|
||||
menuTemplate.push({
|
||||
label: "Sessions",
|
||||
enabled: false,
|
||||
});
|
||||
for (const session of trayMenuData.sessions) {
|
||||
const statusText =
|
||||
session.status === "connected"
|
||||
? STATUS_TEXT.session.connected
|
||||
: session.status === "connecting"
|
||||
? STATUS_TEXT.session.connecting
|
||||
: STATUS_TEXT.session.disconnected;
|
||||
menuTemplate.push({
|
||||
label: ` ${session.hostLabel || session.label} (${statusText})`,
|
||||
click: () => {
|
||||
// Focus window and switch to this session
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
// Notify renderer to focus this session
|
||||
win.webContents?.send("netcatty:tray:focusSession", session.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menuTemplate.push({ type: "separator" });
|
||||
}
|
||||
|
||||
// Port Forwarding Rules
|
||||
if (trayMenuData.portForwardRules && trayMenuData.portForwardRules.length > 0) {
|
||||
menuTemplate.push({
|
||||
label: "Port Forwarding",
|
||||
enabled: false,
|
||||
});
|
||||
for (const rule of trayMenuData.portForwardRules) {
|
||||
const isActive = rule.status === "active";
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const statusText =
|
||||
rule.status === "active"
|
||||
? STATUS_TEXT.portForward.active
|
||||
: rule.status === "connecting"
|
||||
? STATUS_TEXT.portForward.connecting
|
||||
: rule.status === "error"
|
||||
? STATUS_TEXT.portForward.error
|
||||
: STATUS_TEXT.portForward.inactive;
|
||||
const typeLabel = rule.type === "local" ? "L" : rule.type === "remote" ? "R" : "D";
|
||||
const portInfo = rule.type === "dynamic"
|
||||
? `${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`;
|
||||
|
||||
menuTemplate.push({
|
||||
label: ` [${typeLabel}] ${rule.label || portInfo} (${statusText})`,
|
||||
enabled: !isConnecting,
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
win.webContents?.send("netcatty:tray:togglePortForward", rule.id, !isActive);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menuTemplate.push({ type: "separator" });
|
||||
}
|
||||
|
||||
// Quit
|
||||
menuTemplate.push({
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
closeToTray = false;
|
||||
app.quit();
|
||||
},
|
||||
});
|
||||
|
||||
return menuTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tray context menu
|
||||
*/
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
// Avoid showing a context menu on left-click; we toggle our custom panel instead.
|
||||
// On macOS, right-click may still show a menu if one is set, so we don't set any.
|
||||
try {
|
||||
tray.setContextMenu(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray menu data from renderer
|
||||
*/
|
||||
function setTrayMenuData(data) {
|
||||
if (data.sessions !== undefined) {
|
||||
trayMenuData.sessions = data.sessions;
|
||||
}
|
||||
if (data.portForwardRules !== undefined) {
|
||||
trayMenuData.portForwardRules = data.portForwardRules;
|
||||
}
|
||||
// Rebuild menu with new data
|
||||
updateTrayMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the system tray icon
|
||||
*/
|
||||
function destroyTray() {
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
console.log("[GlobalShortcut] System tray destroyed");
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error destroying tray:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set close-to-tray behavior
|
||||
*/
|
||||
function setCloseToTray(enabled) {
|
||||
closeToTray = !!enabled;
|
||||
|
||||
if (closeToTray) {
|
||||
// Create tray if it doesn't exist
|
||||
if (!tray) {
|
||||
createTray();
|
||||
}
|
||||
} else {
|
||||
// Destroy tray if it exists
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
return { success: true, enabled: closeToTray };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if close-to-tray is enabled
|
||||
*/
|
||||
function isCloseToTrayEnabled() {
|
||||
return closeToTray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current hotkey status
|
||||
*/
|
||||
function getHotkeyStatus() {
|
||||
return {
|
||||
enabled: hotkeyEnabled,
|
||||
hotkey: currentHotkey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window close event - hide to tray instead of closing
|
||||
*/
|
||||
function handleWindowClose(event, win) {
|
||||
if (closeToTray && tray) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
return true; // Prevented close
|
||||
}
|
||||
return false; // Allow close
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
// Register global toggle hotkey
|
||||
ipcMain.handle("netcatty:globalHotkey:register", async (_event, { hotkey }) => {
|
||||
return registerGlobalHotkey(hotkey);
|
||||
});
|
||||
|
||||
// Unregister global toggle hotkey
|
||||
ipcMain.handle("netcatty:globalHotkey:unregister", async () => {
|
||||
unregisterGlobalHotkey();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Get current hotkey status
|
||||
ipcMain.handle("netcatty:globalHotkey:status", async () => {
|
||||
return getHotkeyStatus();
|
||||
});
|
||||
|
||||
// Set close-to-tray behavior
|
||||
ipcMain.handle("netcatty:tray:setCloseToTray", async (_event, { enabled }) => {
|
||||
return setCloseToTray(enabled);
|
||||
});
|
||||
|
||||
// Get close-to-tray status
|
||||
ipcMain.handle("netcatty:tray:isCloseToTray", async () => {
|
||||
return { enabled: closeToTray };
|
||||
});
|
||||
|
||||
// Update tray menu data
|
||||
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
|
||||
setTrayMenuData(data);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:hide", async () => {
|
||||
hideTrayPanel();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:openMainWindow", async () => {
|
||||
openMainWindow();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:jumpToSession", async (_event, sessionId) => {
|
||||
openMainWindow();
|
||||
try {
|
||||
const win = getMainWindow();
|
||||
win?.webContents?.send("netcatty:trayPanel:jumpToSession", sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:connectToHost", async (_event, hostId) => {
|
||||
openMainWindow();
|
||||
try {
|
||||
const win = getMainWindow();
|
||||
win?.webContents?.send("netcatty:trayPanel:connectToHost", hostId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] IPC handlers registered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on app quit
|
||||
*/
|
||||
function cleanup() {
|
||||
unregisterGlobalHotkey();
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
registerGlobalHotkey,
|
||||
unregisterGlobalHotkey,
|
||||
setCloseToTray,
|
||||
isCloseToTrayEnabled,
|
||||
handleWindowClose,
|
||||
toggleWindowVisibility,
|
||||
getHotkeyStatus,
|
||||
setTrayMenuData,
|
||||
updateTrayMenu,
|
||||
cleanup,
|
||||
};
|
||||
@@ -308,8 +308,19 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,9 +13,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
@@ -279,9 +279,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
// Prioritize faster key exchange
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
@@ -458,9 +467,18 @@ async function startSSHSession(event, options) {
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
// Prioritize faster key exchange
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
@@ -526,7 +544,7 @@ async function startSSHSession(event, options) {
|
||||
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
|
||||
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
|
||||
if (unlockedEncryptedKeys.length > 0) {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
count: unlockedEncryptedKeys.length,
|
||||
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
||||
});
|
||||
@@ -535,15 +553,15 @@ async function startSSHSession(event, options) {
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
connectOpts.agent = sshAgentSocket;
|
||||
}
|
||||
|
||||
|
||||
// Mark that we need to try all default keys (handled in authMethods below)
|
||||
if (allDefaultKeys.length > 0) {
|
||||
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
|
||||
@@ -618,11 +636,11 @@ async function startSSHSession(event, options) {
|
||||
// This is critical because different servers may have different keys in authorized_keys
|
||||
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
|
||||
for (const keyInfo of allDefaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
@@ -632,12 +650,12 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -760,7 +778,7 @@ async function startSSHSession(event, options) {
|
||||
// Check if this method is still available on server
|
||||
// Note: "agent" uses "publickey" as the underlying method type
|
||||
const methodName = method.type === "password" ? "password" :
|
||||
method.type === "publickey" ? "publickey" :
|
||||
method.type === "publickey" ? "publickey" :
|
||||
method.type === "agent" ? "publickey" : "keyboard-interactive";
|
||||
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
|
||||
log("Auth method not available on server, skipping", { method: method.id });
|
||||
@@ -1296,16 +1314,16 @@ async function startSSHSessionWrapper(event, options) {
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
|
||||
|
||||
|
||||
// Request passphrases from user
|
||||
const passphraseResult = await requestPassphrasesForEncryptedKeys(
|
||||
event.sender,
|
||||
options.hostname
|
||||
);
|
||||
|
||||
|
||||
// If user cancelled, don't retry even if some keys were unlocked
|
||||
if (passphraseResult.cancelled) {
|
||||
console.log('[SSH] User cancelled passphrase flow, not retrying');
|
||||
@@ -1314,7 +1332,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
count: passphraseResult.keys.length,
|
||||
keyNames: passphraseResult.keys.map(k => k.keyName)
|
||||
});
|
||||
|
||||
|
||||
// Retry connection with unlocked keys
|
||||
// Wrap in try-catch to ensure consistent error handling for retry failures
|
||||
try {
|
||||
@@ -1327,7 +1345,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
|
||||
retryErr.message?.toLowerCase().includes('auth') ||
|
||||
retryErr.level === 'client-authentication';
|
||||
|
||||
|
||||
if (isRetryAuthError) {
|
||||
const authError = new Error(retryErr.message);
|
||||
authError.level = 'client-authentication';
|
||||
@@ -1341,7 +1359,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
const authError = new Error(err.message);
|
||||
|
||||
@@ -7,6 +7,7 @@ const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
@@ -315,15 +316,30 @@ async function startTelnetSession(event, options) {
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
const charsetToNodeEncoding = (charset) => {
|
||||
if (!charset) return 'utf8';
|
||||
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
|
||||
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
|
||||
if (normalized === 'ascii') return 'ascii';
|
||||
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
|
||||
return 'utf8';
|
||||
};
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const cleanData = handleTelnetNegotiation(data);
|
||||
|
||||
|
||||
if (cleanData.length > 0) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: cleanData.toString('binary') });
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -513,9 +529,14 @@ async function startSerialSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
const serialDecoder = new StringDecoder('latin1');
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
|
||||
const decoded = serialDecoder.write(data);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
}
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||||
|
||||
// Theme colors configuration
|
||||
const THEME_COLORS = {
|
||||
@@ -28,6 +29,7 @@ let currentLanguage = "en";
|
||||
let handlersRegistered = false; // Prevent duplicate IPC handler registration
|
||||
let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
let isQuitting = false;
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
@@ -47,6 +49,10 @@ function debugLog(...args) {
|
||||
}
|
||||
}
|
||||
|
||||
function setIsQuitting(nextValue) {
|
||||
isQuitting = Boolean(nextValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the window state file
|
||||
*/
|
||||
@@ -161,8 +167,8 @@ function getWindowBoundsState(win, overrideBounds) {
|
||||
}
|
||||
|
||||
const MENU_LABELS = {
|
||||
en: { edit: "Edit", view: "View", window: "Window" },
|
||||
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
|
||||
en: { edit: "Edit", view: "View", window: "Window", reload: "Reload" },
|
||||
"zh-CN": { edit: "编辑", view: "视图", window: "窗口", reload: "重新加载" },
|
||||
};
|
||||
|
||||
function tMenu(language, key) {
|
||||
@@ -653,6 +659,15 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (windowStateCloseRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -1064,7 +1079,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
{
|
||||
label: tMenu(language, "view"),
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
@@ -1111,5 +1126,6 @@ module.exports = {
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
setIsQuitting,
|
||||
THEME_COLORS,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ try {
|
||||
electronModule = require("electron");
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
|
||||
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
|
||||
if (!app || !BrowserWindow) {
|
||||
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
||||
}
|
||||
@@ -80,6 +80,7 @@ const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -364,6 +365,7 @@ const registerBridges = (win) => {
|
||||
transferBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
globalShortcutBridge.init(deps);
|
||||
|
||||
// Initialize compress upload bridge with transferBridge dependency
|
||||
compressUploadBridge.init({
|
||||
@@ -390,6 +392,7 @@ const registerBridges = (win) => {
|
||||
tempDirBridge.registerHandlers(ipcMain, shell);
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -450,6 +453,15 @@ const registerBridges = (win) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
try {
|
||||
return clipboard?.readText?.() || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
// Select an application from system file picker
|
||||
ipcMain.handle("netcatty:selectApplication", async () => {
|
||||
const { dialog } = electronModule;
|
||||
@@ -745,6 +757,10 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
@@ -757,6 +773,11 @@ app.on("will-quit", () => {
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
@@ -312,6 +312,13 @@ ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Buffer the latest tray menu data so it can be replayed when the React
|
||||
// component subscribes after lazy-mount (avoiding the first-open race).
|
||||
let _lastTrayMenuData = null;
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
|
||||
_lastTrayMenuData = data;
|
||||
});
|
||||
|
||||
const api = {
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
@@ -752,6 +759,78 @@ const api = {
|
||||
openSessionLogsDir: (directory) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey: (hotkey) =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
|
||||
unregisterGlobalHotkey: () =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:unregister"),
|
||||
getGlobalHotkeyStatus: () =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:status"),
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray: (enabled) =>
|
||||
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
|
||||
isCloseToTray: () =>
|
||||
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
|
||||
updateTrayMenuData: (data) =>
|
||||
ipcRenderer.invoke("netcatty:tray:updateMenuData", data),
|
||||
// Listen for tray menu actions
|
||||
onTrayFocusSession: (callback) => {
|
||||
const handler = (_event, sessionId) => callback(sessionId);
|
||||
ipcRenderer.on("netcatty:tray:focusSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:focusSession", handler);
|
||||
},
|
||||
onTrayTogglePortForward: (callback) => {
|
||||
const handler = (_event, ruleId, start) => callback(ruleId, start);
|
||||
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
},
|
||||
|
||||
// Tray panel actions forwarded to main window
|
||||
onTrayPanelJumpToSession: (callback) => {
|
||||
const handler = (_event, sessionId) => callback(sessionId);
|
||||
ipcRenderer.on("netcatty:trayPanel:jumpToSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:jumpToSession", handler);
|
||||
},
|
||||
onTrayPanelConnectToHost: (callback) => {
|
||||
const handler = (_event, hostId) => callback(hostId);
|
||||
ipcRenderer.on("netcatty:trayPanel:connectToHost", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:connectToHost", handler);
|
||||
},
|
||||
|
||||
// Tray panel window
|
||||
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
|
||||
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
|
||||
jumpToSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
|
||||
onTrayPanelCloseRequest: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:trayPanel:closeRequest", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:closeRequest", handler);
|
||||
},
|
||||
|
||||
onTrayPanelRefresh: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:trayPanel:refresh", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:refresh", handler);
|
||||
},
|
||||
|
||||
onTrayPanelMenuData: (callback) => {
|
||||
// Replay buffered data so late subscribers (e.g. after React lazy-mount) don't miss
|
||||
// the initial payload that was sent before the useEffect listener was registered.
|
||||
if (_lastTrayMenuData) {
|
||||
queueMicrotask(() => callback(_lastTrayMenuData));
|
||||
}
|
||||
const handler = (_event, data) => {
|
||||
_lastTrayMenuData = data;
|
||||
callback(data);
|
||||
};
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
|
||||
},
|
||||
|
||||
// Get file path from File object (for drag-and-drop)
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
@@ -760,6 +839,11 @@ const api = {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
// Clipboard fallback helpers
|
||||
readClipboardText: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readText");
|
||||
},
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
57
global.d.ts
vendored
@@ -293,9 +293,9 @@ declare global {
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
@@ -310,7 +310,7 @@ declare global {
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
|
||||
|
||||
// Compressed folder upload
|
||||
startCompressedUpload?(
|
||||
options: {
|
||||
@@ -331,7 +331,7 @@ declare global {
|
||||
remoteTar: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
@@ -580,6 +580,53 @@ declare global {
|
||||
|
||||
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
readClipboardText?(): Promise<string>;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
|
||||
isCloseToTray?(): Promise<{ enabled: boolean }>;
|
||||
updateTrayMenuData?(data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
}>;
|
||||
}): Promise<{ success: boolean }>;
|
||||
onTrayFocusSession?(callback: (sessionId: string) => void): () => void;
|
||||
onTrayTogglePortForward?(callback: (ruleId: string, start: boolean) => void): () => void;
|
||||
|
||||
onTrayPanelJumpToSession?(callback: (sessionId: string) => void): () => void;
|
||||
onTrayPanelConnectToHost?(callback: (hostId: string) => void): () => void;
|
||||
|
||||
hideTrayPanel?(): Promise<{ success: boolean }>;
|
||||
openMainWindow?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
onTrayPanelRefresh?(callback: () => void): () => void;
|
||||
onTrayPanelMenuData?(callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void): () => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
12
index.tsx
@@ -11,6 +11,7 @@ import App from './App';
|
||||
import { ToastProvider } from './components/ui/toast';
|
||||
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
@@ -23,6 +24,9 @@ const getRoute = () => {
|
||||
if (hash === '#/settings' || hash.startsWith('#/settings')) {
|
||||
return 'settings';
|
||||
}
|
||||
if (hash === '#/tray' || hash.startsWith('#/tray')) {
|
||||
return 'tray';
|
||||
}
|
||||
return 'main';
|
||||
};
|
||||
|
||||
@@ -38,6 +42,14 @@ const renderApp = () => {
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else if (route === 'tray') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
|
||||
<LazyTrayPanel />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else {
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
@@ -46,6 +47,9 @@ export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
|
||||
// Editor Settings
|
||||
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
|
||||
|
||||
// Session Logs Settings
|
||||
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';
|
||||
@@ -56,3 +60,7 @@ export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
|
||||
|
||||
// Managed Sources - external files that manage groups of hosts (e.g., ~/.ssh/config)
|
||||
export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
|
||||
@@ -95,10 +95,10 @@ export class CloudSyncManager {
|
||||
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
|
||||
SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG
|
||||
);
|
||||
|
||||
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
|
||||
|
||||
const deviceId = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_ID)
|
||||
|| generateDeviceId();
|
||||
|
||||
|
||||
const deviceName = this.loadFromStorage<string>(SYNC_STORAGE_KEYS.DEVICE_NAME)
|
||||
|| getDefaultDeviceName();
|
||||
|
||||
@@ -153,13 +153,13 @@ export class CloudSyncManager {
|
||||
private loadProviderConnection(provider: CloudProvider): ProviderConnection {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
|
||||
|
||||
|
||||
// Determine the correct status: if tokens or config exist, should be 'connected'
|
||||
// Never restore 'syncing' or 'error' status - those are transient
|
||||
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
|
||||
? 'connected'
|
||||
: 'disconnected';
|
||||
|
||||
|
||||
return {
|
||||
provider,
|
||||
...stored,
|
||||
@@ -222,7 +222,7 @@ export class CloudSyncManager {
|
||||
// Handle master key config changes (e.g., when set up in settings window)
|
||||
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
|
||||
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
|
||||
|
||||
|
||||
if (nextConfig && !this.state.masterKeyConfig) {
|
||||
// Master key was set up in another window - update our state
|
||||
this.state.masterKeyConfig = nextConfig;
|
||||
@@ -451,10 +451,10 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
const config = await EncryptionService.createMasterKeyConfig(password);
|
||||
|
||||
|
||||
this.state.masterKeyConfig = config;
|
||||
this.state.securityState = 'LOCKED';
|
||||
|
||||
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG, config);
|
||||
this.emit({ type: 'SECURITY_STATE_CHANGED', state: 'LOCKED' });
|
||||
|
||||
@@ -537,7 +537,7 @@ export class CloudSyncManager {
|
||||
this.state.masterKeyConfig = newConfig;
|
||||
this.state.securityState = 'UNLOCKED';
|
||||
this.masterPassword = newPassword;
|
||||
|
||||
|
||||
// Re-derive key with new password
|
||||
this.state.unlockedKey = await EncryptionService.unlockMasterKey(
|
||||
newPassword,
|
||||
@@ -589,7 +589,7 @@ export class CloudSyncManager {
|
||||
// GitHub uses Device Flow
|
||||
const ghAdapter = adapter as GitHubAdapter;
|
||||
const deviceFlow = await ghAdapter.startAuth();
|
||||
|
||||
|
||||
return {
|
||||
type: 'device_code',
|
||||
data: deviceFlow,
|
||||
@@ -597,7 +597,7 @@ export class CloudSyncManager {
|
||||
} else {
|
||||
// Google and OneDrive use PKCE with redirect
|
||||
const redirectUri = 'http://127.0.0.1:45678/oauth/callback';
|
||||
|
||||
|
||||
if (provider === 'google') {
|
||||
const gdAdapter = adapter as GoogleDriveAdapter;
|
||||
const url = await gdAdapter.startAuth(redirectUri);
|
||||
@@ -636,7 +636,7 @@ export class CloudSyncManager {
|
||||
|
||||
try {
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
|
||||
this.state.providers.github = {
|
||||
...this.state.providers.github,
|
||||
status: 'connected',
|
||||
@@ -1028,9 +1028,9 @@ export class CloudSyncManager {
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
|
||||
|
||||
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
@@ -1288,6 +1288,7 @@ export class CloudSyncManager {
|
||||
this.state.syncState = 'ERROR';
|
||||
// lastError is set by uploadToProvider
|
||||
}
|
||||
this.notifyStateChange(); // Notify UI that sync is complete
|
||||
|
||||
// Process errors from initial checks (if any)
|
||||
checkResults.forEach((r) => {
|
||||
@@ -1371,7 +1372,7 @@ export class CloudSyncManager {
|
||||
...entry,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
|
||||
// Keep only the last 50 entries
|
||||
this.state.syncHistory = [newEntry, ...this.state.syncHistory].slice(0, 50);
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, this.state.syncHistory);
|
||||
|
||||
21
lib/utils.ts
@@ -12,4 +12,25 @@ export function cn(...inputs: ClassValue[]) {
|
||||
*/
|
||||
export function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in bracketed paste escape sequences.
|
||||
* When a terminal application enables bracketed paste mode (CSI ?2004h),
|
||||
* pasted text should be wrapped so the application can distinguish paste
|
||||
* from typed input (e.g. vim disables autoindent during paste).
|
||||
*/
|
||||
export function wrapBracketedPaste(text: string): string {
|
||||
return `\x1b[200~${text}\x1b[201~`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current platform is macOS.
|
||||
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.
|
||||
*/
|
||||
export function isMacPlatform(): boolean {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
2293
package-lock.json
generated
@@ -70,7 +70,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.6",
|
||||
"electron": "^40.1.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -80,5 +80,8 @@
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/tray-icon.png
Normal file
|
After Width: | Height: | Size: 615 B |
BIN
public/tray-icon@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/tray-iconTemplate.png
Normal file
|
After Width: | Height: | Size: 512 B |
BIN
public/tray-iconTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 942 B |
|
Before Width: | Height: | Size: 825 KiB |
|
Before Width: | Height: | Size: 995 KiB |
|
Before Width: | Height: | Size: 888 KiB |
BIN
screenshots/gifs/custom-highlight.gif
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
screenshots/gifs/custom-themes.gif
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
screenshots/gifs/drag-file-upload.gif
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/gifs/dual-terminal--split-manage.gif
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
screenshots/gifs/gird-list-tre-views.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
|
Before Width: | Height: | Size: 849 KiB |
|
Before Width: | Height: | Size: 849 KiB |
|
Before Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 671 KiB |
|
Before Width: | Height: | Size: 814 KiB |
|
Before Width: | Height: | Size: 833 KiB |
|
Before Width: | Height: | Size: 875 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 832 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 832 KiB |
|
Before Width: | Height: | Size: 897 KiB |
|
Before Width: | Height: | Size: 759 KiB |
|
Before Width: | Height: | Size: 977 KiB |
|
Before Width: | Height: | Size: 1002 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 977 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 906 KiB |
|
Before Width: | Height: | Size: 868 KiB |
|
Before Width: | Height: | Size: 777 KiB |
|
Before Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 494 KiB |
BIN
screenshots/treeview-dark.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
screenshots/treeview-light.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 884 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 776 KiB After Width: | Height: | Size: 1.1 MiB |