Compare commits

...

73 Commits

Author SHA1 Message Date
陈大猫
269d790f28 Merge pull request #28 from binaricat/copilot/fix-path-overflow-issue
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix path breadcrumb overflow in SFTP views
2026-01-06 11:53:32 +08:00
copilot-swe-agent[bot]
0f12eab680 Remove 'click to show' prefix from tooltip since ellipsis is non-clickable
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 03:52:07 +00:00
copilot-swe-agent[bot]
139fa43c43 Remove click-to-expand feature, keep only tooltip for hidden paths
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 03:40:03 +00:00
copilot-swe-agent[bot]
eb30e6580e Address code review feedback - reset expansion on path change and use localization
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 03:23:05 +00:00
copilot-swe-agent[bot]
104a0c73d2 Fix path overflow in SFTP views by truncating middle path segments
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 03:19:18 +00:00
copilot-swe-agent[bot]
fc16739e99 Initial plan 2026-01-06 03:11:01 +00:00
陈大猫
dd386f218f Merge pull request #27 from binaricat/copilot/support-symbolic-link-directories
feat: Support symlink directories in SFTP views
2026-01-06 11:04:56 +08:00
copilot-swe-agent[bot]
254558771c feat: Add special icon for symlink files in SFTP views
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 03:01:30 +00:00
copilot-swe-agent[bot]
9c9d01f372 fix: Address code review feedback for symlink support
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 02:50:47 +00:00
copilot-swe-agent[bot]
a75b981630 feat: Add symlink directory support to SFTP views
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 02:43:53 +00:00
copilot-swe-agent[bot]
2b706b7b4e Initial plan 2026-01-06 02:34:04 +00:00
LAPTOP-O016UC3M\Qi Chen
8276f63c65 Update download links and add serial protocol support
Simplifies download instructions in all README translations by linking to the latest GitHub release and replacing direct binary links with a unified status table.
Adds serial protocol option to supported connection protocols to improve flexibility for device connections.
2026-01-06 10:23:09 +08:00
bincxz
cac621413c Updates application icon asset
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Replaces the existing icon with a new version to refresh
visual branding and improve recognition in user interfaces.
2026-01-05 22:53:10 +08:00
bincxz
897ddaddbf Refactors identity and credential combobox formatting
Improves readability and consistency by adjusting indentation
and formatting of identity filtering and credential selection
comboboxes. No functional changes; aids maintainability
and reduces confusion in complex conditional UI rendering.
2026-01-05 22:46:15 +08:00
bincxz
d51c0f526c Prefills group for new hosts based on navigation
Improves user experience by automatically setting the group for new hosts to match the current navigation context.
Reduces manual input and helps maintain organizational consistency.
2026-01-05 22:46:10 +08:00
bincxz
7acd9b3b8d Improves formatting and indentation in UI components
Refactors whitespace and indentation for better code readability and consistency in modal and terminal components. No logic or functional changes are introduced.
2026-01-05 22:40:09 +08:00
bincxz
05345d1ac7 Adds serial local echo and line mode terminal options
Enhances serial terminal usability by introducing configurable options
for forced local echo and line mode (buffer input, send on Enter).
Improves cross-platform compatibility by handling newlines to avoid
display artifacts, and updates UI to allow manual port entry and
clearer feedback for serial connections.
2026-01-05 22:39:53 +08:00
陈大猫
1f1ec8f7a6 Merge pull request #24 from binaricat/copilot/add-serial-port-support
Add serial port connection support
2026-01-05 20:39:12 +08:00
copilot-swe-agent[bot]
8abba4bc7d Improve serial port validation based on code review
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 12:19:46 +00:00
copilot-swe-agent[bot]
ccf707df5a Add serial port connection support
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 12:13:54 +00:00
copilot-swe-agent[bot]
48d7a63d2e Initial plan 2026-01-05 11:52:43 +00:00
LAPTOP-O016UC3M\Qi Chen
ad7f523ec2 Removes extraneous whitespace in effect hooks
Cleans up unnecessary blank lines within effect hooks to improve code readability and maintain consistency.
2026-01-05 18:24:52 +08:00
LAPTOP-O016UC3M\Qi Chen
a905b3e092 Improves shell path validation for executables
Enhances path validation logic to better detect shell executables
by checking the system PATH and handling .exe extensions on Windows.
Improves user experience when specifying shell paths that are not
absolute or lack file extensions.
2026-01-05 18:24:26 +08:00
陈大猫
23148e88b1 Merge pull request #23 from binaricat/copilot/feature-remember-window-size-position
feat: remember window size and position on restart
2026-01-05 18:13:36 +08:00
copilot-swe-agent[bot]
23c6c55968 refactor: remove duplicate code in window state persistence
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 10:00:57 +00:00
copilot-swe-agent[bot]
a53264013c feat: remember window size and position on restart
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:59:21 +00:00
copilot-swe-agent[bot]
7f58e039a2 Initial plan for window state persistence
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:56:17 +00:00
copilot-swe-agent[bot]
4999a6884b Initial plan 2026-01-05 09:53:21 +00:00
陈大猫
eb8b565a77 Merge pull request #22 from binaricat/copilot/add-configurable-terminal-shell
Add configurable shell and starting directory for local terminal
2026-01-05 17:50:37 +08:00
copilot-swe-agent[bot]
cf103d7421 Fix tilde expansion logic for path validation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:48:23 +00:00
copilot-swe-agent[bot]
88b8cfb4da Add default shell detection and path validation for local shell settings
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:45:22 +00:00
copilot-swe-agent[bot]
24f7a5a805 Address code review feedback: simplify code and add cwd path validation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:32:25 +00:00
copilot-swe-agent[bot]
37d289be50 Add configurable shell and starting directory for local terminal
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:30:33 +00:00
copilot-swe-agent[bot]
74f99e65d9 Initial plan 2026-01-05 09:14:43 +00:00
陈大猫
937608e7f3 Merge pull request #21 from binaricat/copilot/add-jump-host-support
Add jump host support for SFTP connections
2026-01-05 17:13:29 +08:00
copilot-swe-agent[bot]
3e1b72b869 Address code review feedback - add logging to catch blocks
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:08:54 +00:00
copilot-swe-agent[bot]
9d04ae86f4 Add jump host support for SFTP connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-05 09:06:17 +00:00
copilot-swe-agent[bot]
7beb9c1444 Initial plan 2026-01-05 08:46:53 +00:00
陈大猫
dd2f23b672 Merge pull request #18 from Weihong-Liu/revert-14-feature/auto_check_update 2026-01-05 15:33:15 +08:00
Puppet
eac1007764 Revert "feat: add auto check update" 2026-01-05 15:30:57 +08:00
陈大猫
62625214a0 Merge pull request #14 from Weihong-Liu/feature/auto_check_update
feat: add auto check update
2026-01-05 15:27:24 +08:00
Puppet
a6ae160932 Update electron/bridges/updateBridge.cjs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 15:06:31 +08:00
Puppet
6f1431e623 fix: guard update download and clarify install 2026-01-05 15:05:18 +08:00
Puppet
bebd161a98 fix: disable update badge while downloading 2026-01-05 15:05:18 +08:00
Puppet
3eaac53515 fix: dedupe update available toast 2026-01-05 15:05:18 +08:00
Puppet
3a6949862d Update application/state/useUpdateCheck.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 14:43:32 +08:00
Puppet
3c843c448a feat: add auto check update 2026-01-05 11:20:45 +08:00
陈大猫
ff6fa55829 Merge pull request #13 from Weihong-Liu/feature/login-shell-path
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-01-04 22:54:27 +08:00
Puppet
a9fabf6677 Use login shell for local terminals 2026-01-04 22:34:35 +08:00
LAPTOP-O016UC3M\Qi Chen
aa42468ccd Improve code formatting and consistency
Updates whitespace around type definitions for better readability
and maintains consistent formatting throughout the component.
No functional changes introduced.
2026-01-04 19:16:54 +08:00
LAPTOP-O016UC3M\Qi Chen
242c420961 Adds terminal scroll behaviors and improves SFTP modal clarity
Introduces additional scroll options to terminal settings, enabling more granular control over scroll triggers such as output, key press, and paste. Clarifies SFTP upload task error handling and enhances host picker type safety for better maintainability and reliability.
2026-01-04 19:16:41 +08:00
LAPTOP-O016UC3M\Qi Chen
abdac05db6 Fixes indentation for cloud provider props
Improves readability and consistency by correcting indentation
of cloud sync provider prop assignments in the dashboard
component. No functional changes are introduced.
2026-01-04 19:13:32 +08:00
LAPTOP-O016UC3M\Qi Chen
84809b37a7 Removes SMB cloud sync provider support
Drops all SMB provider types, UI, and logic to simplify codebase and focus on maintained sync backends. Reduces complexity and maintenance burden by eliminating unused integration.
2026-01-04 19:13:00 +08:00
陈大猫
2cdd83d6f1 Merge pull request #11 from binaricat/copilot/add-smb-protocol-support
Add SMB protocol support for cloud sync
2026-01-04 17:43:26 +08:00
copilot-swe-agent[bot]
0ecb51ea17 Add port validation for SMB configuration
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-04 09:39:41 +00:00
copilot-swe-agent[bot]
326e613e82 Fix code review issues: implement CloudAdapter interface and add SMB bridge methods
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-04 09:38:01 +00:00
copilot-swe-agent[bot]
71aaeba17b Add SMB protocol support for cloud sync
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-04 09:35:57 +00:00
copilot-swe-agent[bot]
9d2e19a034 Initial plan 2026-01-04 09:22:13 +00:00
陈大猫
fcdf5bce32 Merge pull request #10 from binaricat/copilot/optimize-port-forward-deletion
Show confirmation dialog and stop tunnel before deleting active port forwarding rules
2026-01-04 17:09:16 +08:00
copilot-swe-agent[bot]
ea655d95a3 feat: show confirmation dialog and stop tunnel before deleting active port forwarding rules
When deleting a port forwarding rule that is currently active (status === "active" or "connecting"), a confirmation dialog is shown asking if the user wants to stop and delete the rule. If confirmed, the tunnel is stopped first before deleting the rule, ensuring consistency between configuration and actual port forwarding state.

Added i18n translations for both English and Chinese languages.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-04 09:04:45 +00:00
copilot-swe-agent[bot]
be5110f306 Initial plan 2026-01-04 08:50:41 +00:00
LAPTOP-O016UC3M\Qi Chen
3876e8c479 Handles master key changes across windows
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Ensures state updates and auto-unlock logic respond correctly when the master key is created or removed in another window. Prevents stale unlock attempts and synchronizes security state, improving multi-window cloud sync reliability.
2026-01-04 15:21:22 +08:00
LAPTOP-O016UC3M\Qi Chen
7143bce61e Improves code style and consistency in settings tab
Refactors indentation and spacing for better readability across the settings application tab and toast UI components.
Enhances maintainability by unifying code formatting and minor whitespace adjustments.
No functional logic is changed.
2026-01-04 15:10:00 +08:00
LAPTOP-O016UC3M\Qi Chen
7c232335ef Adds update check with notifications and improved toast UX
Implements automatic and manual update checking, displaying toast notifications when new versions are available or when the app is up to date. Enhances the toast utility to support clickable actions, custom labels, and improved accessibility. Updates UI to surface update status more prominently and localizes new update-related messages.

Improves user awareness of available updates and provides a smoother update experience.
2026-01-04 14:59:23 +08:00
LAPTOP-O016UC3M\Qi Chen
cc61974744 Localizes Select Host panel UI text
Replaces hardcoded labels with translation keys and adds
missing English and Chinese translations for improved
internationalization and consistency in the Select Host panel.
2026-01-04 14:17:39 +08:00
LAPTOP-O016UC3M\Qi Chen
86de2b2b60 Adds identity support to host selection panels
Enables passing and handling of identity objects in host selection
and forwarding components, improving flexibility in SSH host
configuration and authentication options.
2026-01-04 14:12:16 +08:00
陈大猫
27301f3ecd Merge pull request #6 from Weihong-Liu/feature/utf8-locale-defaults
为本地终端会话补齐 UTF-8 语言环境默认值
2026-01-04 11:59:07 +08:00
Puppet
7f30d7c662 Add UTF-8 locale defaults for terminal sessions 2026-01-04 11:41:57 +08:00
陈大猫
456755a2ec Merge pull request #5 from Weihong-Liu/main
feat: improve quick connect ssh parsing
2026-01-04 10:44:00 +08:00
Puppet
1eee141bed feat: improve quick connect ssh parsing 2026-01-04 10:28:00 +08:00
bincxz
e808a2c51c docs: change screenshot layout to single column 2026-01-02 11:17:53 +08:00
bincxz
15ee1fd020 docs: update download links for v1.0.0 release 2026-01-02 00:12:10 +08:00
bincxz
c3cbf4fbdf Updates dark mode main window screenshot
Refreshes the visual documentation for the dark mode main window to reflect recent UI changes or improvements.
2026-01-02 00:07:13 +08:00
49 changed files with 5403 additions and 2377 deletions

25
App.tsx
View File

@@ -3,6 +3,7 @@ import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisi
import { useAutoSync } from './application/state/useAutoSync';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
@@ -207,6 +208,7 @@ function App({ settings }: { settings: SettingsState }) {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,
@@ -256,6 +258,28 @@ function App({ settings }: { settings: SettingsState }) {
return handleSyncNow({ trigger: 'manual' });
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
// Show toast notification when update is available
useEffect(() => {
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -741,6 +765,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={createSerialSession}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}

View File

@@ -14,13 +14,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<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>
&nbsp;
<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="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Ko-fi でサポート">
@@ -130,20 +136,30 @@
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
| ダークモード | ライトモード | リストビュー |
|------------|------------|------------|
| ![ダーク](screenshots/main-window-dark.png) | ![ライト](screenshots/main-window-light.png) | ![リスト](screenshots/main-window-dark-list.png) |
**ダークモード**
![ダークモード](screenshots/main-window-dark.png)
**ライトモード**
![ライトモード](screenshots/main-window-light.png)
**リストビュー**
![リストビュー](screenshots/main-window-dark-list.png)
<a name="ターミナル"></a>
## ターミナル
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
| 分割ウィンドウ | テーマカスタマイズ |
|--------------|-----------------|
| ![分割](screenshots/split-window.png) | ![テーマ](screenshots/terminal-theme-change.png) |
**分割ウィンドウ**
![ターミナルテーマ](screenshots/terminal-theme-change-2.png)
![分割ウィンドウ](screenshots/split-window.png)
**テーマカスタマイズ**
![テーマカスタマイズ](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +261,15 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
### ダウンロード
[GitHub Releases](https://github.com/user/netcatty/releases/latest) から最新版をダウンロードしてください。
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
> ```bash
@@ -261,8 +285,8 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
```bash
# リポジトリをクローン
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 依存関係をインストール
npm install

View File

@@ -14,13 +14,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<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>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
@@ -130,20 +136,30 @@
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.
| Dark Mode | Light Mode | List View |
|-----------|------------|-----------|
| ![Dark](screenshots/main-window-dark.png) | ![Light](screenshots/main-window-light.png) | ![List](screenshots/main-window-dark-list.png) |
**Dark Mode**
![Dark Mode](screenshots/main-window-dark.png)
**Light Mode**
![Light Mode](screenshots/main-window-light.png)
**List View**
![List View](screenshots/main-window-dark-list.png)
<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 | Theme Customization |
|---------------|---------------------|
| ![Split](screenshots/split-window.png) | ![Theme](screenshots/terminal-theme-change.png) |
**Split Windows**
![Terminal Themes](screenshots/terminal-theme-change-2.png)
![Split Windows](screenshots/split-window.png)
**Theme Customization**
![Theme Customization](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +261,15 @@ Netcatty automatically detects and displays OS icons for connected hosts:
### Download
Download the latest release from [GitHub Releases](https://github.com/user/netcatty/releases/latest).
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
| Platform | Architecture | Status |
|----------|--------------|--------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
| **macOS** | Intel | ✅ Supported |
| **Windows** | x64 | ✅ Supported |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
@@ -261,8 +285,8 @@ Download the latest release from [GitHub Releases](https://github.com/user/netca
```bash
# Clone the repository
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# Install dependencies
npm install

View File

@@ -14,13 +14,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<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>
&nbsp;
<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="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="在 Ko-fi 上支持我">
@@ -130,20 +136,30 @@
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
| 深色模式 | 浅色模式 | 列表视图 |
|---------|---------|---------|
| ![深色](screenshots/main-window-dark.png) | ![浅色](screenshots/main-window-light.png) | ![列表](screenshots/main-window-dark-list.png) |
**深色模式**
![深色模式](screenshots/main-window-dark.png)
**浅色模式**
![浅色模式](screenshots/main-window-light.png)
**列表视图**
![列表视图](screenshots/main-window-dark-list.png)
<a name="终端"></a>
## 终端
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
| 分屏窗口 | 主题定制 |
|---------|---------|
| ![分屏](screenshots/split-window.png) | ![主题](screenshots/terminal-theme-change.png) |
**分屏窗口**
![终端主题](screenshots/terminal-theme-change-2.png)
![分屏窗口](screenshots/split-window.png)
**主题定制**
![主题定制](screenshots/terminal-theme-change.png)
<a name="sftp"></a>
## SFTP
@@ -245,7 +261,15 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
### 下载
从 [GitHub Releases](https://github.com/user/netcatty/releases/latest) 下载最新版本。
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
@@ -261,8 +285,8 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
```bash
# 克隆仓库
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 安装依赖
npm install

View File

@@ -28,6 +28,7 @@ const en: Messages = {
'common.noResultsFound': 'No results found',
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
@@ -72,6 +73,17 @@ const en: Messages = {
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
@@ -155,6 +167,18 @@ const en: Messages = {
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
'settings.terminal.localShell.shell.placeholder': 'System default',
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
'settings.terminal.localShell.startDir.notFound': 'Directory not found',
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -246,7 +270,7 @@ const en: Messages = {
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname...',
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
@@ -345,6 +369,9 @@ const en: Messages = {
'pf.view.list': 'List',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'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',
// SFTP
'sftp.newFolder': 'New Folder',
@@ -371,11 +398,13 @@ const en: Messages = {
'sftp.itemsCount': '{count} items',
'sftp.selectedCount': '{count} selected',
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.goUp': 'Go up',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.prompt.newFolderName': 'New folder name?',
@@ -431,6 +460,9 @@ const en: Messages = {
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
@@ -439,6 +471,7 @@ const en: Messages = {
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',
@@ -675,6 +708,20 @@ const en: Messages = {
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
'cloudSync.s3.showSecret': 'Show secrets',
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
'cloudSync.smb.title': 'SMB Settings',
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
'cloudSync.smb.share': 'Share Path',
'cloudSync.smb.username': 'Username',
'cloudSync.smb.password': 'Password',
'cloudSync.smb.domain': 'Domain (optional)',
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
'cloudSync.smb.port': 'Port (optional)',
'cloudSync.smb.showSecret': 'Show password',
'cloudSync.smb.validation.share': 'Share path is required.',
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
'cloudSync.connect.smb.success': 'SMB connected successfully',
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
'cloudSync.provider.smb': 'SMB Share',
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
@@ -911,6 +958,37 @@ const en: Messages = {
'snippets.packageDialog.root': 'Root',
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
'serial.modal.desc': 'Configure serial port connection settings',
'serial.field.port': 'Serial Port',
'serial.field.selectPort': 'Select a port...',
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
'serial.parity.none': 'None',
'serial.parity.even': 'Even',
'serial.parity.odd': 'Odd',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': 'None',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Force Local Echo',
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.connectionError': 'Failed to connect to serial port',
};
export default en;

View File

@@ -19,6 +19,7 @@ const zhCN: Messages = {
'common.noResultsFound': '没有匹配结果',
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.left': '左侧',
'common.right': '右侧',
'common.selectAHost': '选择主机',
@@ -60,6 +61,17 @@ const zhCN: Messages = {
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
// Update notifications
'update.available.title': '发现新版本',
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
'update.checking': '正在检查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
@@ -148,7 +160,7 @@ const zhCN: Messages = {
'vault.hosts.header.live': '{count} 个在线',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname…',
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
@@ -260,11 +272,13 @@ const zhCN: Messages = {
'sftp.itemsCount': '{count} 个项目',
'sftp.selectedCount': '已选 {count} 个',
'sftp.path.doubleClickToEdit': '双击编辑路径',
'sftp.showHiddenPaths': '隐藏的路径',
'sftp.task.waiting': '等待中...',
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.goUp': '上一级',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
@@ -304,6 +318,9 @@ const zhCN: Messages = {
// Select Host panel
'selectHost.title': '选择主机',
'selectHost.noHostsFound': '未找到主机',
'selectHost.newHost': '新建主机',
'selectHost.continue': '继续',
'selectHost.continueWithCount': '继续(已选 {count} 个)',
// Quick Connect
'quickConnect.knownHost.title': '确认要连接吗?',
@@ -312,6 +329,7 @@ const zhCN: Messages = {
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts',
'quickConnect.knownHost.addAndContinue': '加入并继续',
'quickConnect.addKey': '添加 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
// Protocol select dialog
'protocolSelect.chooseProtocol': '选择协议',
@@ -515,6 +533,20 @@ const zhCN: Messages = {
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL适用于 MinIO/R2 等)',
'cloudSync.s3.showSecret': '显示密钥',
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
'cloudSync.smb.title': 'SMB 设置',
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
'cloudSync.smb.share': '共享路径',
'cloudSync.smb.username': '用户名',
'cloudSync.smb.password': '密码',
'cloudSync.smb.domain': '域(可选)',
'cloudSync.smb.domainPlaceholder': '例如WORKGROUP',
'cloudSync.smb.port': '端口(可选)',
'cloudSync.smb.showSecret': '显示密码',
'cloudSync.smb.validation.share': '共享路径必填。',
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
'cloudSync.connect.smb.success': 'SMB 已连接',
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
'cloudSync.provider.smb': 'SMB 共享',
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
@@ -634,6 +666,9 @@ const zhCN: Messages = {
'pf.view.list': '列表',
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.deleteActive.title': '删除正在运行的端口转发?',
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
'pf.deleteActive.confirm': '关闭并删除',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
@@ -709,6 +744,18 @@ const zhCN: Messages = {
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.section.localShell': '本地 Shell',
'settings.terminal.localShell.shell': 'Shell 可执行文件',
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe。留空使用系统默认。',
'settings.terminal.localShell.shell.placeholder': '系统默认',
'settings.terminal.localShell.shell.detected': '检测到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
'settings.terminal.localShell.startDir': '起始目录',
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -900,6 +947,37 @@ const zhCN: Messages = {
'snippets.packageDialog.root': '根目录',
'snippets.packageDialog.placeholder': '例如ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',
'serial.modal.desc': '配置串口连接参数',
'serial.field.port': '串口',
'serial.field.selectPort': '选择串口...',
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',
'serial.parity.none': '无',
'serial.parity.even': '偶校验',
'serial.parity.odd': '奇校验',
'serial.parity.mark': 'Mark',
'serial.parity.space': 'Space',
'serial.flowControl.none': '无',
'serial.flowControl.xon/xoff': 'XON/XOFF (软件)',
'serial.flowControl.rts/cts': 'RTS/CTS (硬件)',
'serial.field.localEcho': '强制本地回显',
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.connectionError': '连接串口失败',
};
export default zhCN;

View File

@@ -142,8 +142,21 @@ export const useCloudSync = (): CloudSyncHook => {
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
// Track the master key config hash to detect when a new master key is set up in another window.
const lastMasterKeyHashRef = useRef<string | null>(null);
const attemptedAutoUnlockRef = useRef(false);
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
: null;
// If master key config changed (e.g., set up in settings window), reset the attempt flag
if (currentHash !== lastMasterKeyHashRef.current) {
lastMasterKeyHashRef.current = currentHash;
attemptedAutoUnlockRef.current = false;
}
if (attemptedAutoUnlockRef.current) return;
if (state.securityState !== 'LOCKED') return;
attemptedAutoUnlockRef.current = true;
@@ -162,7 +175,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Ignore auto-unlock errors; manual actions will surface them.
}
})();
}, [state.securityState]);
}, [state.securityState, state.masterKeyConfig]);
// ========== Computed Values ==========

View File

@@ -1,5 +1,5 @@
import { MouseEvent,useCallback,useMemo,useState } from 'react';
import { ConnectionLog,Host,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import {
collectSessionIds,
createWorkspaceFromSessions as createWorkspaceEntity,
@@ -53,6 +53,24 @@ export const useSessionState = () => {
setActiveTabId(sessionId);
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: serialHostId,
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: config,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
const newSession: TerminalSession = {
id: crypto.randomUUID(),
@@ -590,6 +608,7 @@ export const useSessionState = () => {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,

View File

@@ -41,6 +41,11 @@ const getFileExtension = (name: string): string => {
return ext || "file";
};
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
};
// Check if path is Windows-style
const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
@@ -293,6 +298,47 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
hostname: host.hostname,
username: resolved.username,
@@ -303,9 +349,11 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
};
},
[identities, keys],
[hosts, identities, keys],
);
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
@@ -327,6 +375,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
}));
},
[getMockLocalFiles],
@@ -344,6 +393,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
}));
},
[],
@@ -1295,7 +1345,8 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[], identities: Identity
return;
}
if (entry.type === "directory") {
// Navigate into directories, or symlinks that point to directories
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
await navigateTo(side, newPath);
}

View File

@@ -17,6 +17,11 @@ export const useTerminalBackend = () => {
return !!bridge?.startLocalSession;
}, []);
const serialAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startSerialSession;
}, []);
const execAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.execCommand;
@@ -46,6 +51,12 @@ export const useTerminalBackend = () => {
return bridge.startLocalSession(options);
}, []);
const startSerialSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startSerialSession) throw new Error("startSerialSession unavailable");
return bridge.startSerialSession(options);
}, []);
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
@@ -99,17 +110,26 @@ export const useTerminalBackend = () => {
return !!bridge?.startSSHSession;
}, []);
const listSerialPorts = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.listSerialPorts) return [];
return bridge.listSerialPorts();
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
writeToSession,
resizeSession,

View File

@@ -0,0 +1,265 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Debug logging for update checks
const debugLog = (...args: unknown[]) => {
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
console.log('[UpdateCheck]', ...args);
}
};
export interface UpdateState {
isChecking: boolean;
hasUpdate: boolean;
currentVersion: string;
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
}
export interface UseUpdateCheckResult {
updateState: UpdateState;
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
}
/**
* Hook for managing update checks
* - Automatically checks for updates on startup (with delay)
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(): UseUpdateCheckResult {
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
currentVersion: '',
latestRelease: null,
error: null,
lastCheckedAt: null,
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Get current app version
useEffect(() => {
const loadVersion = async () => {
try {
const bridge = netcattyBridge.get();
const info = await bridge?.getAppInfo?.();
if (info?.version) {
setUpdateState((prev) => ({ ...prev, currentVersion: info.version }));
}
} catch {
// Ignore - running without Electron bridge
}
};
void loadVersion();
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
// In demo mode, use a fake version to allow checking
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersion;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
debugLog('Skipping check - invalid version:', effectiveVersion);
// Skip check for dev builds
return null;
}
if (isCheckingRef.current) {
debugLog('Already checking, skipping');
return null;
}
isCheckingRef.current = true;
setUpdateState((prev) => ({ ...prev, isChecking: true, error: null }));
try {
let result: UpdateCheckResult;
if (IS_UPDATE_DEMO_MODE) {
debugLog('Demo mode: creating fake update result');
// Simulate a short delay like a real API call
await new Promise(resolve => setTimeout(resolve, 500));
// In demo mode, create a fake update result
result = {
hasUpdate: true,
currentVersion: '0.0.1',
latestRelease: {
version: '1.0.0',
tagName: 'v1.0.0',
name: 'Netcatty v1.0.0',
body: 'Demo release for testing update notification',
htmlUrl: 'https://github.com/binaricat/Netcatty/releases',
publishedAt: new Date().toISOString(),
assets: [],
},
};
} else {
result = await checkForUpdates(currentVersion);
}
debugLog('Check result:', result);
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const showUpdate = result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
debugLog('Show update:', showUpdate, 'dismissed version:', dismissedVersion);
debugLog('Setting state with hasUpdate:', showUpdate);
setUpdateState((prev) => {
debugLog('State updated:', { ...prev, hasUpdate: showUpdate, latestRelease: result.latestRelease });
return {
...prev,
isChecking: false,
hasUpdate: showUpdate,
latestRelease: result.latestRelease,
error: result.error || null,
lastCheckedAt: now,
};
});
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
setUpdateState((prev) => ({
...prev,
isChecking: false,
error: errorMsg,
}));
return null;
} finally {
isCheckingRef.current = false;
}
}, []);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
localStorageAdapter.writeString(
STORAGE_KEY_UPDATE_DISMISSED_VERSION,
updateState.latestRelease.version
);
}
setUpdateState((prev) => ({ ...prev, hasUpdate: false }));
}, [updateState.latestRelease?.version]);
const openReleasePage = useCallback(async () => {
const url = updateState.latestRelease
? getReleaseUrl(updateState.latestRelease.version)
: getReleaseUrl();
try {
const bridge = netcattyBridge.get();
if (bridge?.openExternal) {
await bridge.openExternal(url);
return;
}
} catch {
// Fallback to window.open
}
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
// In demo mode, trigger check immediately after a short delay
if (IS_UPDATE_DEMO_MODE) {
debugLog('Demo mode: scheduling update check in', STARTUP_CHECK_DELAY_MS, 'ms');
startupCheckTimeoutRef.current = setTimeout(() => {
debugLog('=== Demo mode: Triggering update check ===');
void performCheck('0.0.1');
}, STARTUP_CHECK_DELAY_MS);
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}
// Normal mode: wait for version to be loaded, then check
// This is handled by the version-dependent effect below
}, [performCheck]);
// Normal mode startup check - depends on currentVersion
useEffect(() => {
// Skip in demo mode (handled above)
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
if (!updateState.currentVersion || updateState.currentVersion === '0.0.0') {
return;
}
// Check if we've checked recently
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
return;
}
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(() => {
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
return () => {
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, performCheck]);
return {
updateState,
checkNow,
dismissUpdate,
openReleasePage,
};
}

View File

@@ -1045,13 +1045,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="github"
name="GitHub Gist"
icon={<Github size={24} />}
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
isSyncing={sync.providers.github.status === 'syncing'}
isConnecting={sync.providers.github.status === 'connecting'}
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
isSyncing={sync.providers.github.status === 'syncing'}
isConnecting={sync.providers.github.status === 'connecting'}
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
@@ -1061,13 +1061,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="google"
name="Google Drive"
icon={<GoogleDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={sync.providers.google.status === 'connecting'}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={sync.providers.google.status === 'connecting'}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
onConnect={handleConnectGoogle}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
@@ -1077,13 +1077,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="onedrive"
name="Microsoft OneDrive"
icon={<OneDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={sync.providers.onedrive.status === 'connecting'}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={sync.providers.onedrive.status === 'connecting'}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
onConnect={handleConnectOneDrive}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
@@ -1093,13 +1093,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="webdav"
name={t('cloudSync.provider.webdav')}
icon={<Server size={24} />}
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
isSyncing={sync.providers.webdav.status === 'syncing'}
isConnecting={sync.providers.webdav.status === 'connecting'}
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
isSyncing={sync.providers.webdav.status === 'syncing'}
isConnecting={sync.providers.webdav.status === 'connecting'}
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
@@ -1110,13 +1110,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="s3"
name={t('cloudSync.provider.s3')}
icon={<Database size={24} />}
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
isSyncing={sync.providers.s3.status === 'syncing'}
isConnecting={sync.providers.s3.status === 'connecting'}
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
isSyncing={sync.providers.s3.status === 'syncing'}
isConnecting={sync.providers.s3.status === 'connecting'}
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}
@@ -1714,7 +1714,7 @@ interface CloudSyncSettingsProps {
export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
const { securityState } = useCloudSync();
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
// so users don't have to manage a separate LOCKED screen.
if (securityState === 'NO_KEY') {

View File

@@ -59,6 +59,7 @@ interface HostDetailsPanelProps {
groups: string[];
allTags?: string[]; // All available tags for autocomplete
allHosts?: Host[]; // All hosts for chain selection
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
@@ -72,6 +73,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groups,
allTags = [],
allHosts = [],
defaultGroup,
onSave,
onCancel,
onCreateGroup,
@@ -95,6 +97,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
charset: "UTF-8",
theme: "Flexoki Dark",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
);
@@ -286,10 +289,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const base = identities;
const filtered = q
? base.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: base;
return filtered.slice(0, 6);
}, [form.username, identities, selectedIdentity]);
@@ -639,10 +642,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const q = next.toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
@@ -650,10 +653,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const q = (form.username || "").toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
@@ -670,10 +673,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
@@ -702,8 +705,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{filteredIdentitySuggestions.map((identity) => {
const keyLabel = identity.keyId
? availableKeys.find(
(k) => k.id === identity.keyId,
)?.label
(k) => k.id === identity.keyId,
)?.label
: undefined;
const methodLabel =
identity.authMethod === "certificate"
@@ -850,42 +853,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!selectedIdentity &&
selectedCredentialType === "key" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
emptyText={t("hostDetails.keys.empty")}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
emptyText={t("hostDetails.keys.empty")}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
{!selectedIdentity &&
selectedCredentialType === "certificate" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
{!selectedIdentity &&
selectedCredentialType === "certificate" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.certificate.map((k) => ({
value: k.id,
label: k.label,
@@ -913,11 +916,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
</div>
</Card>
</Button>
</div>
)}
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">

View File

@@ -1,4 +1,5 @@
import {
AlertTriangle,
Check,
ChevronDown,
Globe,
@@ -26,6 +27,14 @@ import {
AsidePanelFooter,
} from "./ui/aside-panel";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { SortDropdown } from "./ui/sort-dropdown";
@@ -53,6 +62,7 @@ type WizardStep =
interface PortForwardingProps {
hosts: Host[];
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
@@ -62,6 +72,7 @@ interface PortForwardingProps {
const PortForwarding: React.FC<PortForwardingProps> = ({
hosts,
keys,
identities = [],
customGroups: _customGroups,
onNewHost: _onNewHost,
onSaveHost,
@@ -205,6 +216,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// New forwarding menu
const [showNewMenu, setShowNewMenu] = useState(false);
// Delete confirmation dialog state for active tunnels
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<PortForwardingRule | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Reset wizard
const resetWizard = () => {
setWizardStep("type");
@@ -356,12 +372,50 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
};
// Close edit panel
const closeEditPanel = () => {
const closeEditPanel = useCallback(() => {
setShowEditPanel(false);
setEditingRule(null);
setEditDraft({});
setSelectedRuleId(null);
};
}, [setSelectedRuleId]);
// Handle delete with confirmation for active tunnels
const handleDeleteRule = useCallback(
(rule: PortForwardingRule) => {
// If tunnel is active or connecting, show confirmation dialog
if (rule.status === "active" || rule.status === "connecting") {
setRuleToDelete(rule);
setShowDeleteConfirm(true);
} else {
// If inactive, delete directly
if (editingRule?.id === rule.id) {
closeEditPanel();
}
deleteRule(rule.id);
}
},
[editingRule, deleteRule, closeEditPanel],
);
// Confirm delete of active tunnel: stop first, then delete
const confirmDeleteActiveRule = useCallback(async () => {
if (!ruleToDelete) return;
setIsDeleting(true);
try {
// Stop the tunnel first
await stopTunnel(ruleToDelete.id);
// Then delete the rule
if (editingRule?.id === ruleToDelete.id) {
closeEditPanel();
}
deleteRule(ruleToDelete.id);
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
setRuleToDelete(null);
}
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
// Handle wizard navigation
// Flow for local: type -> local-config -> destination -> host-selection
@@ -652,12 +706,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}}
onEdit={() => startEditRule(rule)}
onDuplicate={() => duplicateRule(rule.id)}
onDelete={() => {
if (editingRule?.id === rule.id) {
closeEditPanel();
}
deleteRule(rule.id);
}}
onDelete={() => handleDeleteRule(rule)}
onStart={() => handleStartTunnel(rule)}
onStop={() => handleStopTunnel(rule)}
/>
@@ -683,10 +732,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
duplicateRule(editingRule.id);
closeEditPanel();
}}
onDelete={() => {
deleteRule(editingRule.id);
closeEditPanel();
}}
onDelete={() => handleDeleteRule(editingRule)}
onOpenHostSelector={() => setShowHostSelector(true)}
/>
)}
@@ -796,9 +842,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
title="Select Host"
/>
)}
@@ -817,6 +863,45 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
isValid={isNewFormValid()}
/>
)}
{/* Delete Active Tunnel Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
if (!isDeleting) {
setShowDeleteConfirm(open);
if (!open) setRuleToDelete(null);
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t("pf.deleteActive.title")}
</DialogTitle>
<DialogDescription>
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setRuleToDelete(null);
}}
disabled={isDeleting}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteActiveRule}
disabled={isDeleting}
>
{t("pf.deleteActive.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -31,6 +31,7 @@ interface QuickConnectWizardProps {
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
onAddKey?: () => void;
@@ -42,6 +43,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
onAddKey,
@@ -644,6 +646,16 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
{/* Progress indicator */}
<div className="px-6">{renderProgressIndicator()}</div>
{warnings && warnings.length > 0 && (
<div className="px-6 pb-2">
<div className="text-xs text-amber-600 bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2">
{t("quickConnect.warning.unparsedOptions", {
options: warnings.join(", "),
})}
</div>
</div>
)}
{/* Content */}
<div className="px-6 py-4">
{step === "protocol" && renderProtocolStep()}

View File

@@ -3,6 +3,7 @@ import {
ChevronRight,
Database,
Download,
ExternalLink,
File,
FileArchive,
FileAudio,
@@ -18,6 +19,7 @@ import {
Key,
Loader2,
Lock,
MoreHorizontal,
Plus,
RefreshCw,
Settings,
@@ -51,7 +53,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
// Comprehensive file icon helper
const getFileIcon = (fileName: string, isDirectory: boolean) => {
const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
if (isDirectory)
return (
<Folder
@@ -62,6 +64,11 @@ const getFileIcon = (fileName: string, isDirectory: boolean) => {
/>
);
// For symlink files (not directories), show a special symlink icon
if (isSymlink) {
return <ExternalLink size={18} className="text-cyan-500" />;
}
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const iconClass = "text-muted-foreground";
@@ -233,6 +240,8 @@ interface SFTPModalProps {
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
};
open: boolean;
onClose: () => void;
@@ -322,6 +331,9 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
// Breadcrumb truncation constant
const MAX_VISIBLE_BREADCRUMB_PARTS = 4;
const isWindowsPath = useCallback((path: string): boolean => {
return /^[A-Za-z]:/.test(path);
@@ -445,6 +457,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -461,6 +475,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
openSftp,
]);
@@ -743,14 +759,14 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
return true;
} catch (e) {
setUploadTasks((prev) =>
prev.map((t) =>
t.id === taskId
prev.map((task) =>
task.id === taskId
? {
...t,
...task,
status: "failed" as const,
error: e instanceof Error ? e.message : t("sftp.error.uploadFailed"),
}
: t,
: task,
),
);
return false;
@@ -865,9 +881,11 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Sorted files
const sortedFiles = useMemo(() => {
return [...files].sort((a, b) => {
// Directories always first
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
// Directories and symlinks pointing to directories come first
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
let cmp = 0;
switch (sortField) {
@@ -968,6 +986,35 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Breadcrumbs
const breadcrumbs = getBreadcrumbs(currentPath);
// Compute visible/hidden breadcrumbs for truncation (always truncate, no expansion)
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } = useMemo(() => {
if (breadcrumbs.length <= MAX_VISIBLE_BREADCRUMB_PARTS) {
return {
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
needsBreadcrumbTruncation: false
};
}
// Show first part + ellipsis + last (MAX_VISIBLE_BREADCRUMB_PARTS - 1) parts
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
const lastPartsCount = MAX_VISIBLE_BREADCRUMB_PARTS - 1;
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: breadcrumbs.length - lastPartsCount + idx
}));
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1
}));
return {
visibleBreadcrumbs: [...firstPart, ...lastParts],
hiddenBreadcrumbs: hidden,
needsBreadcrumbTruncation: true
};
}, [breadcrumbs]);
const handleFileClick = (
file: RemoteFile,
@@ -1031,7 +1078,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
};
const handleFileDoubleClick = (file: RemoteFile) => {
if (file.type === "directory") {
// Navigate into directories, or symlinks that point to directories
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
handleNavigate(joinPath(currentPath, file.name));
} else {
handleDownload(file);
@@ -1143,32 +1191,55 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
<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={t("sftp.path.doubleClickToEdit")}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1"
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={() => setCurrentPath(getRootPath(currentPath))}
>
{isLocalSession && isWindowsPath(currentPath)
? getWindowsDrive(currentPath) ?? "C:"
: "/"}
</button>
{breadcrumbs.map((part, idx) => (
<React.Fragment key={idx}>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className="text-muted-foreground hover:text-foreground truncate px-1"
onClick={() =>
setCurrentPath(breadcrumbPathAt(breadcrumbs, idx))
}
>
{part}
</button>
</React.Fragment>
))}
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore = needsBreadcrumbTruncation && displayIdx === 1;
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"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium"
)}
onClick={() =>
setCurrentPath(breadcrumbPathAt(breadcrumbs, originalIndex))
}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
@@ -1298,7 +1369,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="divide-y divide-border/30">
{sortedFiles.map((file, idx) => (
{sortedFiles.map((file, idx) => {
// Check if this entry is navigable like a directory
const isNavigableDirectory = file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory");
const isDownloadableFile = file.type === "file" || (file.type === "symlink" && file.linkTarget === "file");
return (
<ContextMenu key={idx}>
<ContextMenuTrigger>
<div
@@ -1315,18 +1391,24 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
>
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0">
{getFileIcon(file.name, file.type === "directory")}
{getFileIcon(file.name, isNavigableDirectory, file.type === "symlink" && !isNavigableDirectory)}
</div>
<span className="truncate font-medium">{file.name}</span>
<span className={cn("truncate font-medium", file.type === "symlink" && "italic")}>
{file.name}
{file.type === "symlink" && <span className="sr-only"> (symbolic link)</span>}
</span>
{file.type === "symlink" && (
<span className="text-xs text-muted-foreground shrink-0" aria-hidden="true"></span>
)}
</div>
<div className="text-xs text-muted-foreground">
{file.type === "directory" ? "--" : formatBytes(file.size)}
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified, resolvedLocale)}
</div>
<div className="flex items-center justify-end gap-1">
{file.type === "file" && (
{isDownloadableFile && (
<Button
variant="ghost"
size="icon"
@@ -1356,7 +1438,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{file.type === "directory" && (
{(file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) && (
<ContextMenuItem
onClick={() =>
handleNavigate(
@@ -1369,7 +1451,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
{t("sftp.context.open")}
</ContextMenuItem>
)}
{file.type === "file" && (
{(file.type === "file" || (file.type === "symlink" && file.linkTarget === "file")) && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" /> {t("sftp.context.download")}
</ContextMenuItem>
@@ -1382,7 +1464,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
);
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -30,6 +30,7 @@ interface SelectHostPanelProps {
onNewHost?: () => void;
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
title?: string;
@@ -47,6 +48,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onContinue,
onNewHost,
availableKeys = [],
identities = [],
onSaveHost,
onCreateGroup,
title,
@@ -203,7 +205,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
className,
)}
>
@@ -247,7 +249,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}}
>
<Plus size={14} />
NEW HOST
{t('selectHost.newHost')}
</Button>
)}
<div className="relative flex-1 max-w-xs">
@@ -256,7 +258,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search"
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -393,8 +395,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}}
>
{multiSelect
? `Continue (${selectedHostIds.length} selected)`
: "Continue"}
? t('selectHost.continueWithCount', { count: selectedHostIds.length })
: t('selectHost.continue')}
</Button>
</div>
@@ -403,6 +405,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<HostDetailsPanel
initialData={null}
availableKeys={availableKeys}
identities={identities}
groups={customGroups}
allHosts={hosts}
onSave={(host) => {

View File

@@ -0,0 +1,332 @@
/**
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
interface SerialPort {
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
type?: 'hardware' | 'pseudo' | 'custom';
}
interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
}
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
open,
onClose,
onConnect,
}) => {
const { t } = useI18n();
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Form state
const [selectedPort, setSelectedPort] = useState('');
const [baudRate, setBaudRate] = useState(115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(1);
const [parity, setParity] = useState<SerialParity>('none');
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const terminalBackend = useTerminalBackend();
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
try {
const result = await terminalBackend.listSerialPorts();
setPorts(result);
// Auto-select first port if available and no port is selected
if (result.length > 0) {
setSelectedPort((prev) => prev || result[0].path);
}
} catch (err) {
console.error('[Serial] Failed to list ports:', err);
} finally {
setIsLoadingPorts(false);
}
}, [terminalBackend]);
useEffect(() => {
if (open) {
loadPorts();
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const handleConnect = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
onConnect(config);
onClose();
};
// Convert ports to Combobox options
const portOptions: ComboboxOption[] = useMemo(() => {
return ports.map((port) => ({
value: port.path,
label: port.path,
sublabel: port.manufacturer || undefined,
}));
}, [ports]);
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
const isBaudRateValid = BAUD_RATES.includes(baudRate);
const isValid = isPortValid && isBaudRateValid;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb size={18} />
{t('serial.modal.title')}
</DialogTitle>
<DialogDescription>
{t('serial.modal.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Serial Port Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
<Button
variant="ghost"
size="sm"
onClick={loadPorts}
disabled={isLoadingPorts}
className="h-6 px-2 text-xs"
>
<RefreshCw size={12} className={cn("mr-1", isLoadingPorts && "animate-spin")} />
{t('common.refresh')}
</Button>
</div>
{/* Combobox for port selection with manual input support */}
<Combobox
options={portOptions}
value={selectedPort}
onValueChange={setSelectedPort}
placeholder={t('serial.field.selectPort')}
emptyText={t('serial.noPorts')}
allowCreate
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
</p>
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<select
id="baud-rate"
value={baudRate}
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{BAUD_RATES.map((rate) => (
<option key={rate} value={rate}>
{rate}
</option>
))}
</select>
</div>
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between h-9 px-0 hover:bg-transparent"
>
<span className="text-sm font-medium text-muted-foreground">
{t('common.advanced')}
</span>
{showAdvanced ? (
<ChevronUp size={14} className="text-muted-foreground" />
) : (
<ChevronDown size={14} className="text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
</div>
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
{t('serial.field.localEcho')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.localEchoDesc')}
</p>
</div>
<input
type="checkbox"
id="local-echo"
checked={localEcho}
onChange={(e) => setLocalEcho(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
{t('serial.field.lineMode')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.lineModeDesc')}
</p>
</div>
<input
type="checkbox"
id="line-mode"
checked={lineMode}
onChange={(e) => setLineMode(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
<Cpu size={14} className="mr-2" />
{t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SerialConnectModal;

View File

@@ -1,91 +1,145 @@
import React, { useEffect, useMemo, useState } from "react";
import { Bug, Github, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
import AppLogo from "./AppLogo";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
type AppInfo = {
name: string;
version: string;
platform?: string;
name: string;
version: string;
platform?: string;
};
const REPO_URL = "https://github.com/binaricat/Netcatty";
const buildIssueUrl = (appInfo: AppInfo) => {
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
});
return `${REPO_URL}/issues/new?${params.toString()}`;
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
});
return `${REPO_URL}/issues/new?${params.toString()}`;
};
const ActionRow: React.FC<{
icon: React.ReactNode;
title: string;
subtitle: string;
onClick: () => void;
icon: React.ReactNode;
title: string;
subtitle: string;
onClick: () => void;
}> = ({ icon, title, subtitle, onClick }) => (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
"hover:bg-muted/50 transition-colors"
)}
>
<div className="shrink-0 text-muted-foreground">{icon}</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{title}</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
</div>
</button>
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
"hover:bg-muted/50 transition-colors"
)}
>
<div className="shrink-0 text-muted-foreground">{icon}</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{title}</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
</div>
</button>
);
export default function SettingsApplicationTab() {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [hasAutoChecked, setHasAutoChecked] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const info = await getApplicationInfo();
if (!cancelled && info?.name && typeof info?.version === "string") {
setAppInfo(info);
}
} catch {
// Ignore: running in browser/dev without Electron bridge
}
};
void load();
return () => {
cancelled = true;
};
}, [getApplicationInfo]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const info = await getApplicationInfo();
if (!cancelled && info?.name && typeof info?.version === "string") {
setAppInfo(info);
}
} catch {
// Ignore: running in browser/dev without Electron bridge
}
};
void load();
return () => {
cancelled = true;
};
}, [getApplicationInfo]);
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
const releasesUrl = `${REPO_URL}/releases`;
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Auto check for updates when entering this page
useEffect(() => {
if (hasAutoChecked) return;
if (updateState.isChecking) return;
// In demo mode or when we have a valid version, auto-check
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
if (!canCheck) return;
setHasAutoChecked(true);
void checkNow();
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
// Dev build - just open releases page
openReleasePage();
return;
}
setLastCheckResult('none');
const result = await checkNow();
if (result?.hasUpdate && result.latestRelease) {
setLastCheckResult('available');
toast.info(
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Open the release page
openReleasePage();
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
t('update.upToDate.message', { version: appInfo.version }),
t('update.upToDate.title')
);
}
// Reset the result after 3 seconds
setTimeout(() => setLastCheckResult('none'), 3000);
};
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
const releasesUrl = `${REPO_URL}/releases`;
const discussionsUrl = `${REPO_URL}/discussions`;
return (
@@ -96,16 +150,46 @@ export default function SettingsApplicationTab() {
<AppLogo className="w-16 h-16" />
<div>
<div className="text-3xl font-semibold leading-none">{appInfo.name}</div>
<div className="text-sm text-muted-foreground mt-1">
{appInfo.version ? appInfo.version : " "}
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update available badge - inline with version */}
{updateState.hasUpdate && updateState.latestRelease && (
<button
onClick={() => void openReleasePage()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version} {t('update.downloadNow')}
</button>
)}
</div>
</div>
</div>
<div className="mt-6">
<Button variant="secondary" className="gap-2" onClick={() => void openExternal(releasesUrl)}>
<RefreshCcw size={16} />
{t("settings.application.checkUpdates")}
<Button
variant="secondary"
className="gap-2"
onClick={() => void handleCheckForUpdates()}
disabled={updateState.isChecking}
>
{updateState.isChecking ? (
<Loader2 size={16} className="animate-spin" />
) : lastCheckResult === 'upToDate' ? (
<Check size={16} />
) : (
<RefreshCcw size={16} />
)}
{updateState.isChecking
? t("update.checking")
: t("settings.application.checkUpdates")
}
</Button>
</div>
</div>

View File

@@ -42,6 +42,7 @@ import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
isNavigableDirectory,
SftpBreadcrumb,
SftpConflictDialog,
SftpFileRow,
@@ -181,8 +182,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
// Include both directories and symlinks pointing to directories
const folders = filteredFiles.filter(
(f) => f.type === "directory" && f.name !== "..",
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
@@ -455,10 +457,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
.filter((f) => selectedNames.includes(f.name))
.map((f) => ({
name: f.name,
isDirectory: f.type === "directory",
isDirectory: isNavigableDirectory(f),
side,
}))
: [{ name: entry.name, isDirectory: entry.type === "directory", side }];
: [{ name: entry.name, isDirectory: isNavigableDirectory(entry), side }];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
@@ -466,7 +468,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const handleEntryDragOver = (entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (entry.type === "directory" && entry.name !== "..") {
// Allow drag over for directories and symlinks pointing to directories
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
@@ -475,7 +478,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const handleEntryDrop = (entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (entry.type === "directory" && entry.name !== "..") {
// Allow drop on directories and symlinks pointing to directories
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
@@ -851,7 +855,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
{entry.name !== ".." && (
<ContextMenuContent>
<ContextMenuItem onClick={() => onOpenEntry(entry)}>
{entry.type === "directory"
{isNavigableDirectory(entry)
? t("sftp.context.open")
: t("sftp.context.download")}
</ContextMenuItem>
@@ -867,7 +871,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
);
return {
name,
isDirectory: file?.type === "directory" || false,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
onCopyToOtherPane(fileData);

View File

@@ -12,6 +12,7 @@ import {
Host,
Identity,
KnownHost,
SerialConfig,
SSHKey,
Snippet,
TerminalSession,
@@ -58,6 +59,7 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
serialConfig?: SerialConfig;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
@@ -103,6 +105,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
@@ -138,6 +141,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const serialLineBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
@@ -280,6 +284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startupCommand,
terminalSettings,
terminalBackend,
serialConfig,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
@@ -336,6 +341,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
commandBufferRef,
setIsSearchOpen,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
});
xtermRuntimeRef.current = runtime;
@@ -346,7 +355,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const term = runtime.term;
if (host.protocol === "local" || host.hostname === "localhost") {
if (host.protocol === "serial") {
setStatus("connecting");
setProgressLogs(["Initializing serial connection..."]);
await sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
setStatus("connecting");
setProgressLogs(["Initializing local shell..."]);
await sessionStarters.startLocal(term);
@@ -407,21 +420,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Connection timeline and timeout visuals
useEffect(() => {
if (status !== "connecting" || auth.needsAuth) return;
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
const stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
@@ -445,13 +465,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
clearInterval(stepTimer);
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth]);
}, [status, auth.needsAuth, host.protocol, host.hostname]);
const safeFit = () => {
const fitAddon = fitAddonRef.current;
@@ -822,11 +842,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
@@ -847,14 +867,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
@@ -866,22 +886,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
)}
>
<Radio size={12} />
</Button>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
)}
{renderControls({ showClose: inWorkspace })}
</div>
</div>
@@ -972,6 +992,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => allHosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
username: resolvedAuth.username,
hostname: host.hostname,
@@ -983,6 +1044,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
};
})()}
open={showSFTP && status === "connected"}

View File

@@ -683,6 +683,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
serialConfig={session.serialConfig}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}

View File

@@ -16,6 +16,7 @@ import {
TerminalSquare,
Trash2,
Upload,
Usb,
Zap,
} from "lucide-react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
@@ -33,6 +34,7 @@ import {
HostProtocol,
Identity,
KnownHost,
SerialConfig,
SSHKey,
ShellHistoryEntry,
Snippet,
@@ -46,7 +48,8 @@ import KeychainManager from "./KeychainManager";
import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInput } from "../domain/quickConnect";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -90,6 +93,7 @@ interface VaultViewProps {
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onCreateLocalTerminal: () => void;
onConnectSerial?: (config: SerialConfig) => void;
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
@@ -124,6 +128,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onOpenSettings,
onOpenQuickSwitcher,
onCreateLocalTerminal,
onConnectSerial,
onDeleteHost,
onConnect,
onUpdateHosts,
@@ -156,6 +161,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [renameGroupName, setRenameGroupName] = useState("");
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
// Handle external navigation requests
useEffect(() => {
@@ -184,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
port?: number;
} | null>(null);
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false);
const [quickConnectWarnings, setQuickConnectWarnings] = useState<string[]>([]);
// Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
@@ -198,9 +205,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Handle connect button click - detect quick connect or regular search
const handleConnectClick = useCallback(() => {
if (isSearchQuickConnect) {
const target = parseQuickConnectInput(search);
if (target) {
setQuickConnectTarget(target);
const parsed = parseQuickConnectInputWithWarnings(search);
if (parsed.target) {
setQuickConnectTarget(parsed.target);
setQuickConnectWarnings(parsed.warnings);
setIsQuickConnectOpen(true);
}
} else {
@@ -268,6 +276,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect(host);
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
setSearch("");
},
[onConnect],
@@ -957,6 +966,18 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={cn(
"h-10 px-3 app-no-drag",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</header>
)}
@@ -1308,6 +1329,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
customGroups={customGroups}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -1352,6 +1374,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
onUpdateHosts(
editingHost
@@ -1483,7 +1506,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClose={() => {
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
}}
warnings={quickConnectWarnings}
/>
)}
@@ -1497,6 +1522,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
</Suspense>
)}
{/* Serial Connect Modal */}
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config) => {
if (onConnectSerial) {
onConnectSerial(config);
}
}}
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { AlertCircle, Check, Minus, Plus, RotateCcw } from "lucide-react";
import type {
CursorShape,
LinkModifier,
@@ -93,6 +93,87 @@ export default function SettingsTerminalTab(props: {
} = props;
const { t } = useI18n();
// Local shell settings state
const [defaultShell, setDefaultShell] = useState<string>("");
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
// Fetch default shell on mount
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.getDefaultShell) {
bridge.getDefaultShell().then((shell) => {
setDefaultShell(shell);
}).catch(() => {
// Ignore errors - might not be in Electron
});
}
}, []);
// Validate shell path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const shellPath = terminalSettings.localShell;
if (!shellPath) {
setShellValidation(null);
return;
}
if (!bridge?.validatePath) {
setShellValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(shellPath, 'file').then((result) => {
if (result.exists && result.isFile) {
setShellValidation({ valid: true });
} else if (result.exists && result.isDirectory) {
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.isDirectory") });
} else {
setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.notFound") });
}
}).catch(() => {
setShellValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, t]);
// Validate directory path when it changes
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const dirPath = terminalSettings.localStartDir;
if (!dirPath) {
setDirValidation(null);
return;
}
if (!bridge?.validatePath) {
setDirValidation(null);
return;
}
const timeoutId = setTimeout(() => {
bridge.validatePath(dirPath, 'directory').then((result) => {
if (result.exists && result.isDirectory) {
setDirValidation({ valid: true });
} else if (result.exists && result.isFile) {
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.isFile") });
} else {
setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.notFound") });
}
}).catch(() => {
setDirValidation(null);
});
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localStartDir, t]);
const clampFontSize = useCallback((next: number) => {
const safe = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, next));
setTerminalFontSize(safe);
@@ -443,6 +524,60 @@ export default function SettingsTerminalTab(props: {
</div>
)}
</div>
<SectionHeader title={t("settings.terminal.section.localShell")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.localShell.shell")}
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<Input
value={terminalSettings.localShell}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
className={cn(
"w-48",
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
</span>
)}
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
</span>
)}
</div>
</SettingRow>
<SettingRow
label={t("settings.terminal.localShell.startDir")}
description={t("settings.terminal.localShell.startDir.desc")}
>
<div className="flex flex-col gap-1">
<Input
value={terminalSettings.localStartDir}
placeholder={t("settings.terminal.localShell.startDir.placeholder")}
onChange={(e) => updateTerminalSetting("localStartDir", e.target.value)}
className={cn(
"w-48",
dirValidation && !dirValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{dirValidation && !dirValidation.valid && dirValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{dirValidation.message}
</span>
)}
</div>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -2,17 +2,27 @@
* SFTP Breadcrumb navigation component
*/
import { ChevronRight,Home } from 'lucide-react';
import React,{ memo } from 'react';
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
interface SftpBreadcrumbProps {
path: string;
onNavigate: (path: string) => void;
onHome: () => void;
/** Maximum number of visible path segments before truncation (default: 4) */
maxVisibleParts?: number;
}
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate, onHome }) => {
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
path,
onNavigate,
onHome,
maxVisibleParts = 4
}) => {
const { t } = useI18n();
// Handle both Windows (C:\path) and Unix (/path) style paths
const isWindowsPath = /^[A-Za-z]:/.test(path);
const separator = isWindowsPath ? /[\\/]/ : /\//;
@@ -26,27 +36,73 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate,
return '/' + parts.slice(0, index + 1).join('/');
};
// Determine which parts to show (always truncate, no expansion)
const { visibleParts, hiddenParts, needsTruncation } = useMemo(() => {
if (parts.length <= maxVisibleParts) {
return {
visibleParts: parts.map((part, idx) => ({ part, originalIndex: idx })),
hiddenParts: [] as { part: string; originalIndex: number }[],
needsTruncation: false
};
}
// Show first part + ellipsis + last (maxVisibleParts - 1) parts
const firstPart = [{ part: parts[0], originalIndex: 0 }];
const lastPartsCount = maxVisibleParts - 1;
const lastParts = parts.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: parts.length - lastPartsCount + idx
}));
const hidden = parts.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1
}));
return {
visibleParts: [...firstPart, ...lastParts],
hiddenParts: hidden,
needsTruncation: true
};
}, [parts, maxVisibleParts]);
return (
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-x-auto scrollbar-none">
<div
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden"
title={path}
>
<button
onClick={onHome}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
title="Go to home"
title={t("sftp.goHome")}
>
<Home size={12} />
</button>
<ChevronRight size={12} className="opacity-40 shrink-0" />
{parts.map((part, idx) => {
const partPath = buildPath(idx);
const isLast = idx === parts.length - 1;
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
const partPath = buildPath(originalIndex);
const isLast = originalIndex === parts.length - 1;
const showEllipsisBefore = needsTruncation && displayIdx === 1;
return (
<React.Fragment key={partPath}>
{showEllipsisBefore && (
<>
<span
className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
>
<MoreHorizontal size={14} />
</span>
<ChevronRight size={12} className="opacity-40 shrink-0" />
</>
)}
<button
onClick={() => onNavigate(partPath)}
className={cn(
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px]",
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
isLast && "text-foreground font-medium"
)}
title={part}
>
{part}
</button>

View File

@@ -2,11 +2,11 @@
* SFTP File row component for file list
*/
import { Folder } from 'lucide-react';
import { Folder, Link } from 'lucide-react';
import React,{ memo } from 'react';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import { ColumnWidths,formatBytes,formatDate,getFileIcon } from './utils';
import { ColumnWidths,formatBytes,formatDate,getFileIcon,isNavigableDirectory } from './utils';
interface SftpFileRowProps {
entry: SftpFileEntry;
@@ -36,6 +36,9 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onDrop,
}) => {
const isParentDir = entry.name === '..';
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
const isNavDir = isNavigableDirectory(entry);
const isSymlinkToDirectory = entry.type === 'symlink' && entry.linkTarget === 'directory';
return (
<div
@@ -50,25 +53,32 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
isDragOver && entry.type === 'directory' && "bg-primary/25 ring-1 ring-primary/50"
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
)}
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
"h-7 w-7 rounded flex items-center justify-center shrink-0",
entry.type === 'directory' ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
)}>
{entry.type === 'directory' ? <Folder size={14} /> : getFileIcon(entry)}
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
{/* Show link indicator for symlinks */}
{entry.type === 'symlink' && (
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
)}
</div>
<span className="truncate">{entry.name}</span>
<span className={cn("truncate", entry.type === 'symlink' && "italic")}>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">{formatDate(entry.lastModified)}</span>
<span className="text-xs text-muted-foreground truncate text-right">
{entry.type === 'directory' ? '--' : formatBytes(entry.size)}
{isNavDir ? '--' : formatBytes(entry.size)}
</span>
<span className="text-xs text-muted-foreground truncate capitalize text-right">
{entry.type === 'directory' ? 'folder' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
</span>
</div>
);

View File

@@ -44,10 +44,13 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
).sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, hostSearch]);
const sideLabel = side === 'left' ? t('common.left') : t('common.right');
const items = useMemo(() => {
return [{ type: 'local' as const, id: 'local' }].concat(
filteredHosts.map((host) => ({ type: 'host' as const, id: host.id, host }))
);
type PickerItem = { type: 'local'; id: string } | { type: 'host'; id: string; host: Host };
const items = useMemo<PickerItem[]>(() => {
const localItem: PickerItem = { type: 'local', id: 'local' };
const hostItems: PickerItem[] = filteredHosts.map((host) => ({ type: 'host', id: host.id, host }));
return [localItem, ...hostItems];
}, [filteredHosts]);
useEffect(() => {
@@ -62,7 +65,7 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
setSelectedIndex(0);
}, [hostSearch, open]);
const handleSelect = (item: typeof items[number]) => {
const handleSelect = (item: PickerItem) => {
if (item.type === 'local') {
onSelectLocal();
} else {

View File

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
type SortOrder
} from './utils';

View File

@@ -4,6 +4,7 @@
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
@@ -73,6 +74,11 @@ export const formatSpeed = (bytesPerSecond: number): string => {
*/
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
// For symlink files (not directories), show a special symlink icon
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
@@ -173,3 +179,11 @@ export interface ColumnWidths {
size: number;
type: number;
}
/**
* Check if an entry is navigable like a directory
* This includes regular directories and symlinks that point to directories
*/
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};

View File

@@ -58,6 +58,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
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-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
@@ -95,17 +97,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
return (
<>
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
{!hidesSftp && (
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>

View File

@@ -3,7 +3,7 @@ import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
@@ -11,6 +11,7 @@ type TerminalBackendApi = {
telnetAvailable: () => boolean;
moshAvailable: () => boolean;
localAvailable: () => boolean;
serialAvailable: () => boolean;
execAvailable: () => boolean;
startSSHSession: (options: NetcattySSHOptions) => Promise<string>;
startTelnetSession: (
@@ -22,6 +23,9 @@ type TerminalBackendApi = {
startLocalSession: (
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
) => Promise<string>;
startSerialSession: (
options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0],
) => Promise<string>;
execCommand: (options: Parameters<NetcattyBridge["execCommand"]>[0]) => Promise<{
stdout?: string;
stderr?: string;
@@ -61,6 +65,7 @@ export type TerminalSessionStartersContext = {
startupCommand?: string;
terminalSettings?: TerminalSettings;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionRef: RefObject<string | null>;
hasConnectedRef: RefObject<boolean>;
@@ -114,12 +119,21 @@ const attachSessionToTerminal = (
opts?: {
onExitMessage?: (evt: { exitCode?: number; signal?: number }) => string;
onConnected?: () => void;
// For serial: convert lone LF to CRLF to avoid "staircase effect"
convertLfToCrlf?: boolean;
},
) => {
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(ctx.highlightProcessorRef.current(chunk));
let data = chunk;
// Convert lone LF (\n) to CRLF (\r\n) for proper terminal display
// This prevents the "staircase effect" common in serial terminals
if (opts?.convertLfToCrlf) {
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(ctx.highlightProcessorRef.current(data));
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
@@ -521,10 +535,16 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
// Get local shell configuration from terminal settings
const localShell = ctx.terminalSettings?.localShell;
const localStartDir = ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
sessionId: ctx.sessionId,
cols: term.cols,
rows: term.rows,
shell: localShell,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
@@ -584,5 +604,50 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
return { startSSH, startTelnet, startMosh, startLocal };
// Start Serial session
const startSerial = async (term: XTerm) => {
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
term.writeln("\r\n[Error: No serial configuration provided]");
ctx.updateStatus("disconnected");
return;
}
try {
logger.info("[Serial] Starting serial session", {
port: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
});
const id = await ctx.terminalBackend.startSerialSession({
sessionId: ctx.sessionId,
path: ctx.serialConfig.path,
baudRate: ctx.serialConfig.baudRate,
dataBits: ctx.serialConfig.dataBits,
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
});
// Serial connection is established immediately when session starts
// Update status right away since serial ports don't require handshake
ctx.updateStatus("connected");
ctx.setProgressValue(100);
term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
convertLfToCrlf: true,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to connect to serial port: ${message}]`);
ctx.updateStatus("disconnected");
}
};
return { startSSH, startTelnet, startMosh, startLocal, startSerial };
};

View File

@@ -71,6 +71,11 @@ export type CreateXTermRuntimeContext = {
) => void;
commandBufferRef: RefObject<string>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
};
const detectPlatform = (): XTermPlatform => {
@@ -397,7 +402,64 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
term.onData((data) => {
const id = ctx.sessionRef.current;
if (id) {
ctx.terminalBackend.writeToSession(id, data);
// Serial line mode: buffer input and send on Enter
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
if (data === "\r") {
// Enter key: send buffered line + CR
const line = ctx.serialLineBufferRef.current + "\r";
ctx.terminalBackend.writeToSession(id, line);
ctx.serialLineBufferRef.current = "";
// Local echo newline if enabled
if (ctx.serialLocalEcho) {
term.write("\r\n");
}
} else if (data === "\x7f" || data === "\b") {
// Backspace: remove last character from buffer
if (ctx.serialLineBufferRef.current.length > 0) {
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
if (ctx.serialLocalEcho) {
term.write("\b \b");
}
}
} else if (data === "\x03") {
// Ctrl+C: clear buffer and send Ctrl+C
ctx.serialLineBufferRef.current = "";
ctx.terminalBackend.writeToSession(id, data);
if (ctx.serialLocalEcho) {
term.write("^C\r\n");
}
} else if (data === "\x15") {
// Ctrl+U: clear line buffer
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
// Erase the displayed line
const len = ctx.serialLineBufferRef.current.length;
term.write("\b \b".repeat(len));
}
ctx.serialLineBufferRef.current = "";
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
// Regular characters: add to buffer
ctx.serialLineBufferRef.current += data;
if (ctx.serialLocalEcho) {
term.write(data);
}
}
} else {
// Character mode (default): send immediately
ctx.terminalBackend.writeToSession(id, data);
// Local echo for serial connections only when explicitly enabled
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
if (data === "\r") {
term.write("\r\n");
} else if (data === "\x7f" || data === "\b") {
term.write("\b \b");
} else if (data === "\x03") {
term.write("^C");
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
term.write(data);
}
}
}
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);

View File

@@ -1,5 +1,5 @@
import { AlertCircle,AlertTriangle,CheckCircle,Info,X } from 'lucide-react';
import React,{ createContext,useCallback,useContext,useEffect,useState } from 'react';
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -10,6 +10,8 @@ export interface Toast {
title?: string;
message: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
interface ToastContextValue {
@@ -31,18 +33,29 @@ export const useToast = () => {
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
export interface ToastOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
export const toast = {
success: (message: string, title?: string) => {
globalShowToast?.({ type: 'success', message, title, duration: 3000 });
success: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
},
error: (message: string, title?: string) => {
globalShowToast?.({ type: 'error', message, title, duration: 5000 });
error: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
},
warning: (message: string, title?: string) => {
globalShowToast?.({ type: 'warning', message, title, duration: 4000 });
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
},
info: (message: string, title?: string) => {
globalShowToast?.({ type: 'info', message, title, duration: 3000 });
info: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
},
};
@@ -99,6 +112,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => void }> = ({ toasts, onDismiss }) => {
if (toasts.length === 0) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
t.onClick();
onDismiss(t.id);
}
};
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
@@ -107,8 +127,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
className={cn(
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
TOAST_STYLES[t.type]
TOAST_STYLES[t.type],
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => handleToastClick(t)}
role={t.onClick ? "button" : undefined}
tabIndex={t.onClick ? 0 : undefined}
>
<div className="flex-shrink-0 mt-0.5">
{TOAST_ICONS[t.type]}
@@ -118,9 +142,12 @@ const ToastContainer: React.FC<{ toasts: Toast[]; onDismiss: (id: string) => voi
<div className="text-sm font-medium text-foreground">{t.title}</div>
)}
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
{t.actionLabel && t.onClick && (
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} </div>
)}
</div>
<button
onClick={() => onDismiss(t.id)}
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />

View File

@@ -23,7 +23,22 @@ export interface EnvVar {
}
// Protocol type for connections
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local';
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'local' | 'serial';
// Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
export type SerialFlowControl = 'none' | 'xon/xoff' | 'rts/cts';
export interface SerialConfig {
path: string; // Serial port path (e.g., /dev/ttyUSB0, COM1)
baudRate: number; // Baud rate (e.g., 9600, 115200)
dataBits?: 5 | 6 | 7 | 8; // Data bits (default: 8)
stopBits?: 1 | 1.5 | 2; // Stop bits (default: 1)
parity?: SerialParity; // Parity (default: 'none')
flowControl?: SerialFlowControl; // Flow control (default: 'none')
localEcho?: boolean; // Force local echo (default: false, rely on remote echo)
lineMode?: boolean; // Line mode - buffer input and send on Enter (default: false)
}
// Per-protocol configuration
export interface ProtocolConfig {
@@ -48,7 +63,7 @@ export interface Host {
tags: string[];
os: 'linux' | 'windows' | 'macos';
identityFileId?: string; // Reference to SSHKey
protocol?: 'ssh' | 'telnet' | 'local'; // Default/primary protocol
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
password?: string;
authMethod?: 'password' | 'key' | 'certificate';
agentForwarding?: boolean;
@@ -345,6 +360,9 @@ export interface TerminalSettings {
// Keyboard
altAsMeta: boolean; // Use ⌥ as the Meta key
scrollOnInput: boolean; // Scroll terminal to bottom on input
scrollOnOutput: boolean; // Scroll terminal to bottom on output
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
// Mouse
rightClickBehavior: RightClickBehavior;
@@ -356,6 +374,10 @@ export interface TerminalSettings {
// Keyword Highlighting
keywordHighlightEnabled: boolean;
keywordHighlightRules: KeywordHighlightRule[];
// Local Shell Configuration
localShell: string; // Path to shell executable (empty = system default)
localStartDir: string; // Starting directory for local terminal (empty = home directory)
}
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
@@ -381,6 +403,9 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
minimumContrastRatio: 1,
altAsMeta: false,
scrollOnInput: true,
scrollOnOutput: false,
scrollOnKeyPress: false,
scrollOnPaste: true,
rightClickBehavior: 'context-menu',
copyOnSelect: false,
middleClickPaste: true,
@@ -388,6 +413,8 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
linkModifier: 'none',
keywordHighlightEnabled: true,
keywordHighlightRules: DEFAULT_KEYWORD_HIGHLIGHT_RULES,
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
};
export interface TerminalTheme {
@@ -428,16 +455,19 @@ export interface TerminalSession {
workspaceId?: string;
startupCommand?: string; // Command to run after connection (for snippet runner)
// Connection-time protocol overrides (used instead of looking up from hosts)
protocol?: 'ssh' | 'telnet' | 'local';
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
port?: number;
moshEnabled?: boolean;
// Serial-specific connection settings
serialConfig?: SerialConfig;
}
export interface RemoteFile {
name: string;
type: 'file' | 'directory';
type: 'file' | 'directory' | 'symlink';
size: string;
lastModified: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
}
export type WorkspaceNode =
@@ -476,6 +506,7 @@ export interface SftpFileEntry {
permissions?: string;
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
}
export interface SftpConnection {

View File

@@ -4,10 +4,12 @@ export interface QuickConnectTarget {
port?: number;
}
// Parse user@host:port format
export function parseQuickConnectInput(
input: string,
): QuickConnectTarget | null {
export interface QuickConnectParseResult {
target: QuickConnectTarget | null;
warnings: string[];
}
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
@@ -43,10 +45,230 @@ export function parseQuickConnectInput(
username: username || undefined,
port,
};
};
const sshArgOptions = new Set([
"-b",
"-c",
"-D",
"-E",
"-F",
"-i",
"-I",
"-J",
"-L",
"-m",
"-O",
"-P",
"-R",
"-S",
"-W",
"-w",
]);
const parseSshOption = (
raw: string,
nextToken?: string,
): { key: string; value: string; consumedNext: boolean } | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split("=");
if (parts.length >= 2) {
const key = parts[0]?.trim();
const value = parts.slice(1).join("=").trim();
if (key && value) {
return { key, value, consumedNext: false };
}
}
if (nextToken && !nextToken.startsWith("-")) {
return { key: trimmed, value: nextToken, consumedNext: true };
}
return null;
};
const parseSshCommand = (input: string): QuickConnectParseResult | null => {
const trimmed = input.trim();
if (!/^ssh(\s|$)/i.test(trimmed)) return null;
const tokens = trimmed.split(/\s+/);
if (tokens.length < 2) return null;
const warnings: string[] = [];
let username: string | undefined;
let optionUsername: string | undefined;
let port: number | undefined;
let optionPort: number | undefined;
let portInvalid = false;
let optionHostname: string | undefined;
let hostToken: string | undefined;
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (token === "-p") {
const value = tokens[i + 1];
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
i++;
}
continue;
}
if (token.startsWith("-p") && token.length > 2) {
const value = token.replace(/^-p=?/, "");
if (value) {
port = parseInt(value, 10);
if (Number.isNaN(port)) portInvalid = true;
}
continue;
}
if (token === "-l") {
const value = tokens[i + 1];
if (value) {
username = value;
i++;
}
continue;
}
if (token.startsWith("-l") && token.length > 2) {
const value = token.replace(/^-l=?/, "");
if (value) username = value;
continue;
}
if (token === "-o") {
const optionToken = tokens[i + 1];
if (optionToken) {
const nextToken = tokens[i + 2];
const parsed = parseSshOption(optionToken, nextToken);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
i += parsed.consumedNext ? 2 : 1;
continue;
}
warnings.push("-o");
i++;
}
continue;
}
if (token.startsWith("-o") && token.length > 2) {
const parsed = parseSshOption(token.slice(2), tokens[i + 1]);
if (parsed) {
const key = parsed.key.toLowerCase();
if (key === "port") {
const parsedPort = parseInt(parsed.value, 10);
if (Number.isNaN(parsedPort)) {
portInvalid = true;
} else {
optionPort = parsedPort;
}
} else if (key === "user") {
optionUsername = parsed.value;
} else if (key === "hostname") {
optionHostname = parsed.value;
} else {
warnings.push(`-o ${parsed.key}`);
}
if (parsed.consumedNext) i++;
continue;
}
warnings.push("-o");
}
if (sshArgOptions.has(token)) {
warnings.push(token);
const next = tokens[i + 1];
if (next) i++;
continue;
}
if (token.startsWith("-")) {
warnings.push(token);
continue;
}
if (!hostToken) {
hostToken = token;
} else {
warnings.push(token);
}
}
if (!hostToken) return null;
const base = optionHostname
? parseDirectTarget(optionHostname)
: parseDirectTarget(hostToken);
if (!base) return null;
if (portInvalid) return null;
const resolvedPort =
port !== undefined && !Number.isNaN(port)
? port
: optionPort !== undefined && !Number.isNaN(optionPort)
? optionPort
: base.port;
if (
resolvedPort !== undefined &&
(Number.isNaN(resolvedPort) || resolvedPort < 1 || resolvedPort > 65535)
) {
return null;
}
return {
target: {
hostname: base.hostname,
username: optionUsername || username || base.username,
port: resolvedPort,
},
warnings: Array.from(new Set(warnings)),
};
};
// Parse user@host:port or ssh command formats with warning details
export function parseQuickConnectInputWithWarnings(
input: string,
): QuickConnectParseResult {
const trimmed = input.trim();
if (!trimmed) return { target: null, warnings: [] };
const sshTarget = parseSshCommand(trimmed);
if (sshTarget) return sshTarget;
return { target: parseDirectTarget(trimmed), warnings: [] };
}
// Parse user@host:port or ssh command formats
export function parseQuickConnectInput(
input: string,
): QuickConnectTarget | null {
return parseQuickConnectInputWithWarnings(input).target;
}
// Check if input looks like a quick connect address
export function isQuickConnectInput(input: string): boolean {
return parseQuickConnectInput(input) !== null;
}

View File

@@ -373,6 +373,7 @@ export const SYNC_STORAGE_KEYS = {
PROVIDER_ONEDRIVE: 'netcatty_provider_onedrive_v1',
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
PROVIDER_S3: 'netcatty_provider_s3_v1',
PROVIDER_SMB: 'netcatty_provider_smb_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
} as const;

View File

@@ -9,6 +9,7 @@ const os = require("node:os");
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
@@ -27,19 +28,53 @@ async function listLocalDir(event, payload) {
const entry = entries[i];
try {
const fullPath = path.join(dirPath, entry.name);
// fs.promises.stat follows symlinks, so we get the target's stats
const stat = await fs.promises.stat(fullPath);
let type;
let linkTarget = null;
if (entry.isSymbolicLink()) {
// This is a symlink - mark it as such and record the target type
type = "symlink";
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
linkTarget = stat.isDirectory() ? "directory" : "file";
} else if (entry.isDirectory()) {
type = "directory";
} else {
type = "file";
}
result[i] = {
name: entry.name,
type: entry.isDirectory()
? "directory"
: entry.isSymbolicLink()
? "symlink"
: "file",
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
};
} catch (err) {
console.warn(`Could not stat ${entry.name}:`, err.message);
// Handle broken symlinks - lstat doesn't follow symlinks
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
const brokenEntry = entries[i];
try {
const fullPath = path.join(dirPath, brokenEntry.name);
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
};
return;
}
} catch (lstatErr) {
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
}
}
console.warn(`Could not stat ${entries[i].name}:`, err.message);
result[i] = null;
}
}

View File

@@ -6,13 +6,18 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const net = require("node:net");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
let electronModule = null;
// Storage for jump host connections that need to be cleaned up
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
/**
* Initialize the SFTP bridge with dependencies
*/
@@ -21,18 +26,312 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
* Reused from sshBridge.cjs
*/
function createProxySocket(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
if (proxy.type === 'http') {
// HTTP CONNECT proxy
const socket = net.connect(proxy.port, proxy.host, () => {
let authHeader = '';
if (proxy.username && proxy.password) {
const auth = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
authHeader = `Proxy-Authorization: Basic ${auth}\r\n`;
}
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
resolve(socket);
} else {
socket.destroy();
reject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
}
};
socket.on('data', onData);
});
socket.on('error', reject);
} else if (proxy.type === 'socks5') {
// SOCKS5 proxy
const socket = net.connect(proxy.port, proxy.host, () => {
// SOCKS5 greeting
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
socket.destroy();
reject(new Error('Invalid SOCKS5 response'));
return;
}
const method = data[1];
if (method === 0x02 && proxy.username && proxy.password) {
// Username/password auth
step = 'auth';
const userBuf = Buffer.from(proxy.username);
const passBuf = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, userBuf.length]),
userBuf,
Buffer.from([passBuf.length]),
passBuf
]));
} else if (method === 0x00) {
// No auth, proceed to connect
step = 'connect';
sendConnectRequest();
} else {
socket.destroy();
reject(new Error('SOCKS5 authentication method not supported'));
}
} else if (step === 'auth') {
if (data[1] !== 0x00) {
socket.destroy();
reject(new Error('SOCKS5 authentication failed'));
return;
}
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
socket.removeListener('data', onData);
if (data[1] === 0x00) {
resolve(socket);
} else {
const errors = {
0x01: 'General failure',
0x02: 'Connection not allowed',
0x03: 'Network unreachable',
0x04: 'Host unreachable',
0x05: 'Connection refused',
0x06: 'TTL expired',
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
socket.destroy();
reject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
}
}
};
const sendConnectRequest = () => {
// SOCKS5 connect request
const hostBuf = Buffer.from(targetHost);
const request = Buffer.concat([
Buffer.from([0x05, 0x01, 0x00, 0x03, hostBuf.length]),
hostBuf,
Buffer.from([(targetPort >> 8) & 0xff, targetPort & 0xff])
]);
socket.write(request);
};
socket.on('data', onData);
});
socket.on('error', reject);
} else {
reject(new Error(`Unknown proxy type: ${proxy.type}`));
}
});
}
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
const connections = [];
let currentSocket = null;
try {
// Connect through each jump host
for (let i = 0; i < jumpHosts.length; i++) {
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
const conn = new SSHClient();
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
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'],
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
let authAgent = null;
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
meta: {
label: jump.keyId || jump.username || "",
certificate: jump.certificate,
privateKey: jump.privateKey,
passphrase: jump.passphrase,
},
});
connOpts.agent = authAgent;
} else if (jump.privateKey) {
connOpts.privateKey = jump.privateKey;
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
}
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
} else if (!isFirst && currentSocket) {
// Tunnel through previous hop
connOpts.sock = currentSocket;
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
resolve();
});
conn.on('error', (err) => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
conn.on('timeout', () => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
// Last jump host, forward to final target
nextHost = targetHost;
nextPort = targetPort;
} else {
// Forward to next jump host
const nextJump = jumpHosts[i + 1];
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Forwarding to ${nextHost}:${nextPort}...`);
currentSocket = await new Promise((resolve, reject) => {
conn.forwardOut('127.0.0.1', 0, nextHost, nextPort, (err, stream) => {
if (err) {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut failed:`, err.message);
reject(err);
return;
}
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: forwardOut success`);
resolve(stream);
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
connections
};
} catch (err) {
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP Chain] Cleanup error:', cleanupErr.message); }
}
throw err;
}
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
*/
async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
// Check if we need to connect through jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
let chainConnections = [];
let connectionSocket = null;
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
const chainResult = await connectThroughChainForSftp(
event,
options,
jumpHosts,
options.hostname,
options.port || 22
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
} else if (hasProxy) {
console.log(`[SFTP] Opening connection through proxy to ${options.hostname}:${options.port || 22}`);
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.port || 22
);
}
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
};
// Use the tunneled socket if we have one
if (connectionSocket) {
connectOpts.sock = connectionSocket;
// When using sock, we should not set host/port as the connection is already established
delete connectOpts.host;
delete connectOpts.port;
}
const hasCertificate = typeof options.certificate === "string" && options.certificate.trim().length > 0;
let authAgent = null;
@@ -61,25 +360,78 @@ async function openSftp(event, options) {
connectOpts.authHandler = order;
}
await client.connect(connectOpts);
sftpClients.set(connId, client);
return { sftpId: connId };
try {
await client.connect(connectOpts);
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed
if (chainConnections.length > 0) {
jumpConnectionsMap.set(connId, {
connections: chainConnections,
socket: connectionSocket
});
}
console.log(`[SFTP] Connection established: ${connId}`);
return { sftpId: connId };
} catch (err) {
// Cleanup jump connections on error
for (const conn of chainConnections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on connect failure:', cleanupErr.message); }
}
throw err;
}
}
/**
* List files in a directory
* Properly handles symlinks by resolving their target type
*/
async function listSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const list = await client.list(payload.path || ".");
return list.map((item) => ({
name: item.name,
type: item.type === "d" ? "directory" : "file",
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
const basePath = payload.path || ".";
// Process items and resolve symlinks
const results = await Promise.all(list.map(async (item) => {
let type;
let linkTarget = null;
if (item.type === "d") {
type = "directory";
} else if (item.type === "l") {
// This is a symlink - try to resolve its target type
type = "symlink";
try {
// Use path.posix.join to properly construct the path and avoid double slashes
const fullPath = path.posix.join(basePath === "." ? "/" : basePath, item.name);
const stat = await client.stat(fullPath);
// stat follows symlinks, so we get the target's type
if (stat.isDirectory) {
linkTarget = "directory";
} else {
linkTarget = "file";
}
} catch (err) {
// If we can't stat the symlink target (broken link), keep it as symlink
console.warn(`Could not resolve symlink target for ${item.name}:`, err.message);
}
} else {
type = "file";
}
return {
name: item.name,
type,
linkTarget,
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
};
}));
return results;
}
/**
@@ -167,6 +519,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
/**
* Close an SFTP connection
* Also cleans up any jump host connections if present
*/
async function closeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
@@ -178,6 +531,16 @@ async function closeSftp(event, payload) {
console.warn("SFTP close failed", err);
}
sftpClients.delete(payload.sftpId);
// Clean up jump connections if any
const jumpData = jumpConnectionsMap.get(payload.sftpId);
if (jumpData) {
for (const conn of jumpData.connections) {
try { conn.end(); } catch (cleanupErr) { console.warn('[SFTP] Cleanup error on close:', cleanupErr.message); }
}
jumpConnectionsMap.delete(payload.sftpId);
console.log(`[SFTP] Cleaned up ${jumpData.connections.length} jump connection(s) for ${payload.sftpId}`);
}
}
/**

View File

@@ -1,17 +1,28 @@
/**
* Terminal Bridge - Handles local shell and telnet/mosh sessions
* Terminal Bridge - Handles local shell, telnet/mosh, and serial port sessions
* Extracted from main.cjs for single responsibility
*/
const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
// Shared references
let sessions = null;
let electronModule = null;
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
const getLoginShellArgs = (shellPath) => {
if (!shellPath || process.platform === "win32") return [];
const shellName = path.basename(shellPath);
return LOGIN_SHELLS.has(shellName) ? ["-l"] : [];
};
/**
* Initialize the terminal bridge with dependencies
*/
@@ -52,6 +63,32 @@ function findExecutable(name) {
return name;
}
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
const isEmptyLocale = (value) => {
if (value === undefined || value === null) return true;
const trimmed = String(value).trim();
if (!trimmed) return true;
return trimmed === "C" || trimmed === "POSIX";
};
const applyLocaleDefaults = (env) => {
const hasUtf8 =
isUtf8Locale(env.LC_ALL) || isUtf8Locale(env.LC_CTYPE) || isUtf8Locale(env.LANG);
if (hasUtf8) return env;
const hasAnyLocale =
!isEmptyLocale(env.LC_ALL) || !isEmptyLocale(env.LC_CTYPE) || !isEmptyLocale(env.LANG);
if (hasAnyLocale) return env;
return {
...env,
LANG: DEFAULT_UTF8_LOCALE,
LC_CTYPE: DEFAULT_UTF8_LOCALE,
LC_ALL: DEFAULT_UTF8_LOCALE,
};
};
/**
* Start a local terminal session
*/
@@ -63,17 +100,38 @@ function startLocalSession(event, payload) {
? findExecutable("powershell") || "powershell.exe"
: process.env.SHELL || "/bin/bash";
const shell = payload?.shell || defaultShell;
const env = {
const shellArgs = getLoginShellArgs(shell);
const env = applyLocaleDefaults({
...process.env,
...(payload?.env || {}),
TERM: "xterm-256color",
COLORTERM: "truecolor",
};
});
const proc = pty.spawn(shell, [], {
// Determine the starting directory
// Default to home directory if not specified or if specified path is invalid
const defaultCwd = os.homedir();
let cwd = defaultCwd;
if (payload?.cwd) {
try {
// Resolve to absolute path and check if it exists and is a directory
const resolvedPath = path.resolve(payload.cwd);
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
cwd = resolvedPath;
} else {
console.warn(`[Terminal] Specified cwd "${payload.cwd}" is not a valid directory, using home directory`);
}
} catch (err) {
console.warn(`[Terminal] Error validating cwd "${payload.cwd}":`, err.message);
}
}
const proc = pty.spawn(shell, shellArgs, {
cols: payload?.cols || 80,
rows: payload?.rows || 24,
env,
cwd,
});
const session = {
@@ -386,6 +444,103 @@ async function startMoshSession(event, options) {
}
}
/**
* List available serial ports (hardware only)
*/
async function listSerialPorts() {
try {
const ports = await SerialPort.list();
return ports.map(port => ({
path: port.path,
manufacturer: port.manufacturer || '',
serialNumber: port.serialNumber || '',
vendorId: port.vendorId || '',
productId: port.productId || '',
pnpId: port.pnpId || '',
type: 'hardware',
}));
} catch (err) {
console.error("[Serial] Failed to list ports:", err.message);
return [];
}
}
/**
* Start a serial port session (supports both hardware serial ports and PTY devices)
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
*/
async function startSerialSession(event, options) {
const sessionId =
options.sessionId ||
`serial-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const portPath = options.path;
const baudRate = options.baudRate || 115200;
const dataBits = options.dataBits || 8;
const stopBits = options.stopBits || 1;
const parity = options.parity || 'none';
const flowControl = options.flowControl || 'none';
console.log(`[Serial] Starting connection to ${portPath} at ${baudRate} baud`);
return new Promise((resolve, reject) => {
try {
const serialPort = new SerialPort({
path: portPath,
baudRate: baudRate,
dataBits: dataBits,
stopBits: stopBits,
parity: parity,
rtscts: flowControl === 'rts/cts',
xon: flowControl === 'xon/xoff',
xoff: flowControl === 'xon/xoff',
autoOpen: false,
});
serialPort.open((err) => {
if (err) {
console.error(`[Serial] Failed to open port ${portPath}:`, err.message);
reject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
console.log(`[Serial] Connected to ${portPath}`);
const session = {
serialPort,
type: 'serial',
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
serialPort.on('data', (data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
});
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
});
resolve({ sessionId });
});
} catch (err) {
console.error("[Serial] Failed to start serial session:", err.message);
reject(err);
}
});
}
/**
* Write data to a session
*/
@@ -400,6 +555,8 @@ function writeToSession(event, payload) {
session.proc.write(payload.data);
} else if (session.socket) {
session.socket.write(payload.data);
} else if (session.serialPort) {
session.serialPort.write(payload.data);
}
} catch (err) {
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
@@ -454,6 +611,8 @@ function closeSession(event, payload) {
session.proc.kill();
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
session.serialPort.close();
}
if (session.chainConnections) {
for (const c of session.chainConnections) {
@@ -473,11 +632,90 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:start", startLocalSession);
ipcMain.handle("netcatty:telnet:start", startTelnetSession);
ipcMain.handle("netcatty:mosh:start", startMoshSession);
ipcMain.handle("netcatty:serial:start", startSerialSession);
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
ipcMain.handle("netcatty:local:validatePath", validatePath);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:close", closeSession);
}
/**
* Get the default shell for the current platform
*/
function getDefaultShell() {
if (process.platform === "win32") {
return findExecutable("powershell") || "powershell.exe";
}
return process.env.SHELL || "/bin/bash";
}
/**
* Validate a path - check if it exists and whether it's a file or directory
* @param {object} event - IPC event
* @param {object} payload - Contains { path: string, type?: 'file' | 'directory' | 'any' }
* @returns {{ exists: boolean, isFile: boolean, isDirectory: boolean }}
*/
function validatePath(event, payload) {
const targetPath = payload?.path;
const type = payload?.type || 'any';
if (!targetPath) {
return { exists: false, isFile: false, isDirectory: false };
}
try {
// Resolve path (handle ~, etc.)
let resolvedPath = targetPath;
if (resolvedPath === "~") {
resolvedPath = os.homedir();
} else if (resolvedPath.startsWith("~/")) {
resolvedPath = path.join(os.homedir(), resolvedPath.slice(2));
}
resolvedPath = path.resolve(resolvedPath);
if (fs.existsSync(resolvedPath)) {
const stat = fs.statSync(resolvedPath);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
// If type is 'file' and path doesn't exist, try to resolve via PATH (for executables like cmd.exe, powershell.exe)
if (type === 'file') {
const resolvedExecutable = findExecutable(targetPath);
// findExecutable returns the original name if not found, so check if it actually resolves to a real path
if (resolvedExecutable !== targetPath && fs.existsSync(resolvedExecutable)) {
const stat = fs.statSync(resolvedExecutable);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
// Also try with .exe extension on Windows if not already present
if (process.platform === 'win32' && !targetPath.toLowerCase().endsWith('.exe')) {
const withExe = findExecutable(targetPath + '.exe');
if (withExe !== targetPath + '.exe' && fs.existsSync(withExe)) {
const stat = fs.statSync(withExe);
return {
exists: true,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
}
}
}
return { exists: false, isFile: false, isDirectory: false };
} catch (err) {
console.warn(`[Terminal] Error validating path "${targetPath}":`, err.message);
return { exists: false, isFile: false, isDirectory: false };
}
}
/**
* Cleanup all sessions - call before app quit
*/
@@ -497,6 +735,12 @@ function cleanupAllSessions() {
}
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
try {
session.serialPort.close();
} catch (e) {
// Ignore errors during cleanup
}
}
if (session.chainConnections) {
for (const c of session.chainConnections) {
@@ -517,8 +761,12 @@ module.exports = {
startLocalSession,
startTelnetSession,
startMoshSession,
startSerialSession,
listSerialPorts,
writeToSession,
resizeSession,
closeSession,
cleanupAllSessions,
getDefaultShell,
validatePath,
};

View File

@@ -27,11 +27,15 @@ let currentTheme = "light";
let currentLanguage = "en";
let handlersRegistered = false; // Prevent duplicate IPC handler registration
let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
const rendererReadyCallbacksByWebContentsId = new Map();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
function debugLog(...args) {
if (!DEBUG_WINDOWS) return;
@@ -43,6 +47,78 @@ function debugLog(...args) {
}
}
/**
* Get the path to the window state file
*/
function getWindowStatePath() {
try {
if (!electronApp) return null;
return path.join(electronApp.getPath("userData"), WINDOW_STATE_FILE);
} catch {
return null;
}
}
/**
* Load saved window state from disk
*/
function loadWindowState() {
try {
const statePath = getWindowStatePath();
if (!statePath || !fs.existsSync(statePath)) {
return null;
}
const data = fs.readFileSync(statePath, "utf8");
const state = JSON.parse(data);
// Validate the loaded state has required properties
if (
typeof state.width === "number" &&
typeof state.height === "number" &&
state.width > 0 &&
state.height > 0
) {
return state;
}
return null;
} catch (err) {
debugLog("Failed to load window state:", err?.message || err);
return null;
}
}
/**
* Save window state to disk
*/
function saveWindowState(state) {
try {
const statePath = getWindowStatePath();
if (!statePath) return false;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return true;
} catch (err) {
debugLog("Failed to save window state:", err?.message || err);
return false;
}
}
/**
* Get the current window bounds state for saving
* @param {BrowserWindow} win - The window to get bounds from
* @param {Object} overrideBounds - Optional bounds to use instead of current window bounds (for normal bounds tracking)
*/
function getWindowBoundsState(win, overrideBounds) {
if (!win || win.isDestroyed()) return null;
const bounds = overrideBounds || win.getBounds();
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
};
}
const MENU_LABELS = {
en: { edit: "Edit", view: "View", window: "Window" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
@@ -420,18 +496,58 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme } = electronModule;
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
// Load saved window state
const savedState = loadWindowState();
let windowBounds = {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
if (savedState) {
// Use saved dimensions
windowBounds.width = savedState.width;
windowBounds.height = savedState.height;
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
try {
// Check if the saved position is within any available display
const displays = screen?.getAllDisplays?.() || [];
const isPositionVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
// Check if at least part of the window would be visible on this display
return (
savedState.x < x + width &&
savedState.x + savedState.width > x &&
savedState.y < y + height &&
savedState.y + savedState.height > y
);
});
if (isPositionVisible) {
windowBounds.x = savedState.x;
windowBounds.y = savedState.y;
}
} catch {
// Ignore screen check errors, just don't set position
}
}
}
const win = new BrowserWindow({
width: 1400,
height: 900,
...windowBounds,
backgroundColor,
icon: appIcon,
show: false,
@@ -448,12 +564,68 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
try {
win.maximize();
} catch {
// ignore
}
});
}
// Track window bounds for saving (use last non-maximized/non-fullscreen bounds)
let lastNormalBounds = null;
let saveStateTimer = null;
const updateNormalBounds = () => {
if (!win.isDestroyed() && !win.isMaximized() && !win.isFullScreen()) {
lastNormalBounds = win.getBounds();
}
};
const scheduleSaveState = () => {
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
}, 500);
};
// Update normal bounds on resize/move when not maximized/fullscreen
win.on("resize", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("move", () => {
updateNormalBounds();
scheduleSaveState();
});
win.on("maximize", scheduleSaveState);
win.on("unmaximize", () => {
updateNormalBounds();
scheduleSaveState();
});
// Save state when window is about to close
win.on("close", () => {
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
});
win.on("enter-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", true);
scheduleSaveState();
});
win.on("leave-full-screen", () => {
win.webContents?.send("netcatty:window:fullscreen-changed", false);
updateNormalBounds();
scheduleSaveState();
});
// Ensure native background matches frontend background, even before first paint.

View File

@@ -215,6 +215,19 @@ const api = {
const result = await ipcRenderer.invoke("netcatty:local:start", options || {});
return result.sessionId;
},
startSerialSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:serial:start", options);
return result.sessionId;
},
listSerialPorts: async () => {
return ipcRenderer.invoke("netcatty:serial:list");
},
getDefaultShell: async () => {
return ipcRenderer.invoke("netcatty:local:defaultShell");
},
validatePath: async (path, type) => {
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
},
writeToSession: (sessionId, data) => {
ipcRenderer.send("netcatty:write", { sessionId, data });
},

31
global.d.ts vendored
View File

@@ -1,5 +1,5 @@
import type { RemoteFile } from "./types";
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
declare global {
// Proxy configuration for SSH connections
@@ -134,7 +134,26 @@ interface NetcattyBridge {
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; env?: Record<string, string> }): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;
@@ -261,6 +280,14 @@ interface NetcattyBridge {
): Promise<{ resourceId: string }>;
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;

View File

@@ -31,5 +31,9 @@ export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v
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';
// Update check
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

View File

@@ -217,6 +217,26 @@ export class CloudSyncManager {
const key = event.key;
if (!key) return;
// 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;
this.state.securityState = 'LOCKED';
this.notifyStateChange();
} else if (!nextConfig && this.state.masterKeyConfig) {
// Master key was removed in another window
this.state.masterKeyConfig = null;
this.state.securityState = 'NO_KEY';
this.state.unlockedKey = null;
this.masterPassword = null;
this.notifyStateChange();
}
return;
}
// Sync versions + auto-sync settings
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
const next = this.safeJsonParse<{

View File

@@ -0,0 +1,166 @@
/**
* Update Service - Checks GitHub releases for new versions
*/
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
export interface ReleaseInfo {
version: string; // e.g. "1.0.0" (without 'v' prefix)
tagName: string; // e.g. "v1.0.0"
name: string; // Release title
body: string; // Release notes (markdown)
htmlUrl: string; // URL to the release page
publishedAt: string; // ISO date string
assets: ReleaseAsset[];
}
export interface ReleaseAsset {
name: string;
browserDownloadUrl: string;
size: number;
}
export interface UpdateCheckResult {
hasUpdate: boolean;
currentVersion: string;
latestRelease: ReleaseInfo | null;
error?: string;
}
/**
* Parse version string to comparable array
* e.g. "1.2.3" -> [1, 2, 3]
*/
function parseVersion(version: string): number[] {
// Remove 'v' prefix if present
const clean = version.replace(/^v/i, '');
return clean.split('.').map((part) => {
const num = parseInt(part, 10);
return isNaN(num) ? 0 : num;
});
}
/**
* Compare two version strings
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
export function compareVersions(a: string, b: string): number {
const partsA = parseVersion(a);
const partsB = parseVersion(b);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] ?? 0;
const numB = partsB[i] ?? 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}
/**
* Fetch the latest release info from GitHub
*/
export async function fetchLatestRelease(): Promise<ReleaseInfo | null> {
try {
const response = await fetch(GITHUB_API_URL, {
headers: {
'Accept': 'application/vnd.github.v3+json',
// Using anonymous access - rate limited to 60 requests/hour
},
});
if (!response.ok) {
if (response.status === 404) {
// No releases yet
return null;
}
throw new Error(`GitHub API error: ${response.status}`);
}
const data = await response.json();
return {
version: data.tag_name?.replace(/^v/i, '') || '0.0.0',
tagName: data.tag_name || '',
name: data.name || data.tag_name || '',
body: data.body || '',
htmlUrl: data.html_url || RELEASES_PAGE_URL,
publishedAt: data.published_at || '',
assets: (data.assets || []).map((asset: { name?: string; browser_download_url?: string; size?: number }) => ({
name: asset.name || '',
browserDownloadUrl: asset.browser_download_url || '',
size: asset.size || 0,
})),
};
} catch (error) {
console.warn('[UpdateService] Failed to fetch latest release:', error);
return null;
}
}
/**
* Check for updates
*/
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
const result: UpdateCheckResult = {
hasUpdate: false,
currentVersion,
latestRelease: null,
};
try {
const release = await fetchLatestRelease();
if (!release) {
return result;
}
result.latestRelease = release;
result.hasUpdate = compareVersions(release.version, currentVersion) > 0;
return result;
} catch (error) {
result.error = error instanceof Error ? error.message : 'Unknown error';
return result;
}
}
/**
* Get release page URL for a specific version
*/
export function getReleaseUrl(version?: string): string {
if (version) {
return `${RELEASES_PAGE_URL}/tag/v${version.replace(/^v/i, '')}`;
}
return RELEASES_PAGE_URL;
}
/**
* Get download URL for current platform
*/
export function getDownloadUrlForPlatform(
release: ReleaseInfo,
platform: string
): string | null {
const assets = release.assets;
// Platform-specific file patterns
const patterns: Record<string, RegExp[]> = {
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
darwin: [/\.dmg$/i, /mac.*\.zip$/i, /darwin/i],
linux: [/\.AppImage$/i, /\.deb$/i, /linux/i],
};
const platformPatterns = patterns[platform] || [];
for (const pattern of platformPatterns) {
const asset = assets.find((a) => pattern.test(a.name));
if (asset) {
return asset.browserDownloadUrl;
}
}
// Fallback to release page
return null;
}

3974
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@
"node-pty": "1.1.0-beta19",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"serialport": "^13.0.0",
"ssh2-sftp-client": "^12.0.1",
"tailwind-merge": "3.4.0",
"uuid": "^13.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

After

Width:  |  Height:  |  Size: 875 KiB