Compare commits

...

65 Commits

Author SHA1 Message Date
陈大猫
5930d1601a Merge pull request #31 from binaricat:copilot/fix-port-forwarding-status
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 port forwarding status sync on app restart
2026-01-06 20:36:10 +08:00
copilot-swe-agent[bot]
df3d507e2b Improve tunnel ID parsing with clearer UUID validation
Address code review feedback:
- Extract parseRuleIdFromTunnelId helper function with clear UUID validation
- Use constants for tunnel ID prefix and UUID regex pattern
- Simplify syncWithBackend to return void since return value wasn't used

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 12:17:20 +00:00
copilot-swe-agent[bot]
f8c7a9081b Fix port forwarding status sync on app restart
- Add stopAllPortForwards() to portForwardingBridge.cjs to cleanup tunnels on app quit
- Call port forwarding cleanup in main.cjs will-quit handler
- Add syncWithBackend() to portForwardingService.ts to query backend for active tunnels
- Update usePortForwardingState.ts to sync with backend on mount

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-06 12:15:50 +00:00
copilot-swe-agent[bot]
d8cfb0f1d9 Initial plan 2026-01-06 12:10:04 +00:00
陈大猫
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
43 changed files with 4441 additions and 2305 deletions

View File

@@ -208,6 +208,7 @@ function App({ settings }: { settings: SettingsState }) {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,
@@ -764,6 +765,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={createSerialSession}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}

View File

@@ -22,16 +22,8 @@
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/ダウンロード-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="macOS ARM64 をダウンロード">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/ダウンロード-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="macOS Intel をダウンロード">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/ダウンロード-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Windows をダウンロード">
<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>
@@ -269,11 +261,13 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
### ダウンロード
| プラットフォーム | アーキテクチャ | ダウンロード |
|------------------|----------------|--------------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
[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) ですべてのリリースを参照してください。

View File

@@ -22,16 +22,8 @@
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/Download-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="Download macOS ARM64">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/Download-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="Download macOS Intel">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/Download-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="Download Windows">
<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>
@@ -269,11 +261,13 @@ Netcatty automatically detects and displays OS icons for connected hosts:
### Download
| Platform | Architecture | Download |
|----------|--------------|----------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
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).

View File

@@ -22,16 +22,8 @@
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg">
<img src="https://img.shields.io/badge/下载-macOS%20ARM64-000?style=for-the-badge&logo=apple" alt="下载 macOS ARM64">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg">
<img src="https://img.shields.io/badge/下载-macOS%20Intel-000?style=for-the-badge&logo=apple" alt="下载 macOS Intel">
</a>
&nbsp;
<a href="https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe">
<img src="https://img.shields.io/badge/下载-Windows%20x64-0078D4?style=for-the-badge&logo=windows" alt="下载 Windows">
<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>
@@ -269,11 +261,13 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
### 下载
| 平台 | 架构 | 下载链接 |
|------|------|----------|
| **macOS** | Apple Silicon (M1/M2/M3) | [Netcatty-1.0.0-mac-arm64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-arm64.dmg) |
| **macOS** | Intel | [Netcatty-1.0.0-mac-x64.dmg](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-mac-x64.dmg) |
| **Windows** | x64 | [Netcatty-1.0.0-win-x64.exe](https://github.com/binaricat/Netcatty/releases/latest/download/Netcatty-1.0.0-win-x64.exe) |
从 [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) 浏览所有版本。

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',
@@ -166,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',
@@ -356,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',
@@ -382,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?',
@@ -690,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',
@@ -926,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': '选择主机',
@@ -271,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': '新建文件夹名称?',
@@ -530,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 已连接',
@@ -649,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': '本地',
@@ -724,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': '快捷键方案',
@@ -915,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

@@ -148,7 +148,7 @@ export const useCloudSync = (): CloudSyncHook => {
useEffect(() => {
// Compute a simple hash of the master key config to detect changes
const currentHash = state.masterKeyConfig
? JSON.stringify({ salt: state.masterKeyConfig.salt, iv: state.masterKeyConfig.iv })
? 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

View File

@@ -11,6 +11,7 @@ import {
getActiveRuleIds,
startPortForward,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
@@ -78,25 +79,32 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
}, []);
// Load rules from storage on mount
// Load rules from storage on mount and sync with backend
useEffect(() => {
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
// Sync status with active connections in the service layer
const _activeRuleIds = getActiveRuleIds();
const withSyncedStatus = saved.map((r) => {
const conn = getActiveConnection(r.id);
if (conn) {
// This rule has an active connection, preserve its status
return { ...r, status: conn.status, error: conn.error };
}
// No active connection, reset to inactive
return { ...r, status: "inactive" as const, error: undefined };
});
setRules(withSyncedStatus);
}
const loadAndSync = async () => {
// First, sync with backend to get any active tunnels
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
// Sync status with active connections in the service layer
const _activeRuleIds = getActiveRuleIds();
const withSyncedStatus = saved.map((r) => {
const conn = getActiveConnection(r.id);
if (conn) {
// This rule has an active connection, preserve its status
return { ...r, status: conn.status, error: conn.error };
}
// No active connection, reset to inactive
return { ...r, status: "inactive" as const, error: undefined };
});
setRules(withSyncedStatus);
}
};
void loadAndSync();
}, []);
// Persist rules to storage whenever they change

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

@@ -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";
@@ -207,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");
@@ -358,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
@@ -654,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)}
/>
@@ -685,10 +732,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
duplicateRule(editingRule.id);
closeEditPanel();
}}
onDelete={() => {
deleteRule(editingRule.id);
closeEditPanel();
}}
onDelete={() => handleDeleteRule(editingRule)}
onOpenHostSelector={() => setShowHostSelector(true)}
/>
)}
@@ -819,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

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

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

@@ -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,
@@ -47,6 +49,7 @@ import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
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(() => {
@@ -960,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>
)}
@@ -1356,6 +1374,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
onUpdateHosts(
editingHost
@@ -1503,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

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

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

@@ -313,6 +313,28 @@ async function listPortForwards() {
return list;
}
/**
* Stop all active port forwards (cleanup on app quit)
*/
function stopAllPortForwards() {
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
for (const [tunnelId, tunnel] of portForwardingTunnels) {
try {
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.conn) {
tunnel.conn.end();
}
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
}
}
portForwardingTunnels.clear();
console.log('[PortForward] All tunnels stopped');
}
/**
* Register IPC handlers for port forwarding operations
*/
@@ -329,4 +351,5 @@ module.exports = {
stopPortForward,
getPortForwardStatus,
listPortForwards,
stopAllPortForwards,
};

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,18 +1,27 @@
/**
* 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
@@ -91,6 +100,7 @@ function startLocalSession(event, payload) {
? findExecutable("powershell") || "powershell.exe"
: process.env.SHELL || "/bin/bash";
const shell = payload?.shell || defaultShell;
const shellArgs = getLoginShellArgs(shell);
const env = applyLocaleDefaults({
...process.env,
...(payload?.env || {}),
@@ -98,10 +108,30 @@ function startLocalSession(event, payload) {
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 = {
@@ -414,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
*/
@@ -428,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') {
@@ -482,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) {
@@ -501,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
*/
@@ -525,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) {
@@ -545,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

@@ -539,13 +539,18 @@ app.on("window-all-closed", () => {
}
});
// Cleanup all PTY sessions before quitting to prevent node-pty assertion errors
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
console.warn("Error during terminal cleanup:", err);
}
try {
portForwardingBridge.stopAllPortForwards();
} catch (err) {
console.warn("Error during port forwarding cleanup:", err);
}
});
// Export for testing

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

@@ -35,6 +35,75 @@ export const getActiveRuleIds = (): string[] => {
.map(([ruleId]) => ruleId);
};
// Tunnel ID prefix and UUID regex pattern for parsing
const TUNNEL_ID_PREFIX = 'pf-';
// UUID format: 8-4-4-4-12 hexadecimal characters
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Parse rule ID from tunnel ID
* Tunnel ID format is "pf-{ruleId}-{timestamp}" where ruleId is a UUID
*/
const parseRuleIdFromTunnelId = (tunnelId: string): string | null => {
if (!tunnelId.startsWith(TUNNEL_ID_PREFIX)) {
return null;
}
// Remove prefix and split remaining parts
const withoutPrefix = tunnelId.slice(TUNNEL_ID_PREFIX.length);
const parts = withoutPrefix.split('-');
// UUID has 5 parts (8-4-4-4-12), so we need at least 6 parts (5 UUID + timestamp)
if (parts.length < 6) {
return null;
}
// Reconstruct the UUID from first 5 parts
const ruleId = parts.slice(0, 5).join('-');
// Validate it's a proper UUID format
if (!UUID_REGEX.test(ruleId)) {
return null;
}
return ruleId;
};
/**
* Sync active connections with backend
* Called on app startup to restore state of tunnels that may still be running
* This updates the local activeConnections map to match the backend state.
*/
export const syncWithBackend = async (): Promise<void> => {
const bridge = netcattyBridge.get();
if (!bridge?.listPortForwards) {
logger.warn('[PortForwardingService] Backend not available for sync');
return;
}
try {
const activeTunnels = await bridge.listPortForwards();
logger.info(`[PortForwardingService] Backend reports ${activeTunnels.length} active tunnels`);
for (const tunnel of activeTunnels) {
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
if (ruleId) {
// Update local connection tracking
activeConnections.set(ruleId, {
ruleId,
tunnelId: tunnel.tunnelId,
status: 'active',
});
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
}
}
} catch (err) {
logger.error('[PortForwardingService] Failed to sync with backend:', err);
}
};
/**
* Start a port forwarding tunnel
*/

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