Compare commits

...

261 Commits

Author SHA1 Message Date
陈大猫
c668525d17 Merge pull request #95 from binaricat/copilot/fix-empty-lines-when-copying
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix extra blank lines when pasting text from other terminals
2026-01-20 13:56:45 +08:00
bincxz
a21970a278 Refactors terminal paste line ending normalization
Extracts the logic for normalizing line endings (CRLF to LF) during clipboard paste operations into a shared utility function.

This improves code reusability and consistency across different paste mechanisms, ensuring that pasted content always uses Unix-style line endings and prevents issues like extra blank lines in the terminal.
2026-01-20 13:56:05 +08:00
copilot-swe-agent[bot]
c07fd505d3 Fix: Normalize CRLF to LF when pasting text to prevent extra blank lines
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 04:21:58 +00:00
copilot-swe-agent[bot]
3bb47243ce Initial plan 2026-01-20 04:17:38 +00:00
陈大猫
d2483c5863 Merge pull request #93 from binaricat/copilot/add-new-file-button-sftp
Add "New File" button to SFTP views
2026-01-20 12:14:11 +08:00
bincxz
e2f7788c13 Adds SFTP filename validation and overwrite protection.
Improves file management by introducing client-side validation for new SFTP filenames. This prevents creation of files with invalid characters or system-reserved names, enhancing cross-platform compatibility and preventing potential errors.

Enhances user experience by adding an overwrite confirmation dialog when creating a file with an existing name. This prevents accidental data loss.

Increases robustness of SFTP operations by validating the presence of source and target SFTP sessions for remote transfers, preventing operations on disconnected endpoints.

Adds convenience features such as a smart default filename generator for new files and a context menu on the empty file list area for quick access to file creation and refresh actions.
2026-01-20 12:12:40 +08:00
copilot-swe-agent[bot]
2e417e1dd5 Fix potential null reference in createFile fallback logic
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 03:46:04 +00:00
copilot-swe-agent[bot]
b233e9609f Add new file creation feature to SFTP views
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 03:41:30 +00:00
copilot-swe-agent[bot]
f754378bea Initial plan 2026-01-20 03:30:53 +00:00
陈大猫
72e79bdc9a Merge pull request #91 from binaricat/copilot/fix-sftp-reconnect-issue
Fix SFTP retry button to trigger reconnection when connection is lost
2026-01-20 11:09:01 +08:00
bincxz
5d25bda560 Migrates SFTP error messages to i18n.
Centralizes SFTP-related error and status messages within the internationalization system.

Introduces new i18n keys for connection loss and session errors. Updates the SFTP state management to store these i18n keys instead of hardcoded strings, and ensures the SFTP view component translates them for display.

This improves localization, consistency, and maintainability of error messages.
2026-01-20 11:07:09 +08:00
bincxz
5baff1ee63 Refines connection error and transfer logic
Adds a specific error message when a connection is lost and no previous host information is available, guiding the user to manually reconnect.

Passes the `targetSide` argument to `processTransfer` calls, providing additional context for file transfer operations.
2026-01-20 11:02:42 +08:00
copilot-swe-agent[bot]
1d14f1b0ba Improve reconnection logic by explicitly checking for no connection
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 02:52:22 +00:00
copilot-swe-agent[bot]
3f2c3e15d6 Fix SFTP retry button to trigger reconnection when connection is lost
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 02:49:02 +00:00
copilot-swe-agent[bot]
395361b559 Initial plan 2026-01-20 02:42:41 +00:00
陈大猫
918d58862e Merge pull request #90 from binaricat/copilot/add-2fa-login-feature
Add keyboard-interactive (2FA/MFA) authentication support
2026-01-20 10:37:06 +08:00
bincxz
fea1ebf274 Fix: Auto-answer password prompts in keyboard-interactive authentication
When a server uses keyboard-interactive for both password and 2FA:
1. First round prompts for "Password:" - now auto-filled with configured password
2. Second round prompts for 2FA code - shows modal for user input

This fixes the issue where users had to manually enter password in the
2FA modal, leading to authentication failures.

Applied to:
- sshBridge.cjs
- sftpBridge.cjs
- portForwardingBridge.cjs
2026-01-20 10:31:57 +08:00
bincxz
a56ade35a3 Fix: Add tryKeyboard: true to enable keyboard-interactive authentication
The ssh2 library requires tryKeyboard: true in connection options to
trigger the 'keyboard-interactive' event. Without this setting, the
library will not attempt keyboard-interactive authentication even if
it's listed in authHandler.

Added to all connection configurations:
- sshBridge.cjs (main connection + jump hosts)
- sftpBridge.cjs (main connection + jump hosts)
- portForwardingBridge.cjs
2026-01-20 10:23:45 +08:00
bincxz
1b0cb918d8 Refactor: Extract shared proxyUtils and add TTL cleanup for keyboard-interactive requests
- Extract createProxySocket to shared proxyUtils.cjs module
- Add 5-minute TTL cleanup for abandoned keyboard-interactive requests
- Update sshBridge.cjs and sftpBridge.cjs to use shared module
2026-01-20 10:11:45 +08:00
copilot-swe-agent[bot]
869d30d4dd Add keyboard-interactive (2FA/MFA) authentication support for SSH, SFTP, and Port Forwarding
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 18:13:21 +00:00
copilot-swe-agent[bot]
87388b93d9 Initial plan 2026-01-19 17:57:59 +00:00
陈大猫
15a269e5d4 Merge pull request #89 from binaricat:copilot/fix-pane-drag-refresh-issue
Fix SFTP pane auto-refresh when dragging files from right to left
2026-01-20 01:54:48 +08:00
copilot-swe-agent[bot]
cf6b33a3eb Fix SFTP pane auto-refresh when dragging files from right to left
Pass targetSide parameter to processTransfer instead of using unreliable
object reference comparison (targetPane === leftPane) which fails due to
memoized computed values having different object references.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:50:56 +00:00
copilot-swe-agent[bot]
dfa9b109c2 Initial plan 2026-01-19 17:44:31 +00:00
陈大猫
55b55d77c9 Merge pull request #88 from binaricat/copilot/support-folder-upload-sftp
Add folder upload support to SFTP drag-and-drop
2026-01-20 01:42:38 +08:00
bincxz
4dccc11041 Enhances SFTP UI responsiveness and folder upload UX
Implements periodic yielding to the main thread during large folder uploads and local folder parsing. This prevents the UI from freezing, improving responsiveness during these intensive operations.

Integrates folder upload progress directly into the transfer status panel, replacing the previous full-screen overlay. This provides a less intrusive and more consistent user experience for monitoring folder uploads.

Refines the drag-and-drop handler to correctly prioritize internal pane-to-pane transfers over external file/folder drops from the operating system.
2026-01-20 01:41:53 +08:00
bincxz
188e6c860a Adds SFTP folder upload progress and cancellation
Introduces a new feature to provide real-time feedback during SFTP folder uploads.

- Displays an overlay showing the current file being uploaded and overall progress (e.g., "Uploading X of Y files").
- Allows users to cancel an ongoing folder upload via a dedicated button in the progress overlay.
- Implements state management to track upload status and cancellation requests.
- Adds corresponding internationalization keys for the new UI messages.
2026-01-20 01:29:23 +08:00
copilot-swe-agent[bot]
f454c56192 Address code review feedback - fix directory handling and simplify condition
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:05:01 +00:00
copilot-swe-agent[bot]
4480e5dc8d Add folder upload support to SFTP via drag-and-drop
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-19 17:02:12 +00:00
copilot-swe-agent[bot]
8426da1596 Initial plan 2026-01-19 16:53:27 +00:00
陈大猫
c472eaada2 Merge pull request #84 from Nightsuki/feature/proxyjump-support
feat: add ProxyJump support for SSH config import
2026-01-20 00:51:50 +08:00
bincxz
71433252a1 Refines SSH config ProxyJump import and cycle detection
Uses a stable hostname and port key for ProxyJump mapping to ensure correct resolution after host deduplication.

Collects dynamically created "inline" jump hosts separately and adds them to the final import result. This prevents issues when modifying the host list during iteration.

Implements robust cycle detection for ProxyJump chains, identifying both direct self-references and indirect circular dependencies across multiple hosts. Chains involved in cycles are removed to prevent infinite loops.

Prevents duplicate host IDs within a single jump chain and avoids creating redundant inline jump hosts by reusing existing ones when possible.
2026-01-20 00:50:22 +08:00
bincxz
ca42787808 Adds SSH Agent Forwarding option
Introduces an option in host details to enable SSH agent forwarding. This allows remote servers to leverage local SSH keys for operations like Git, enhancing convenience for users.
2026-01-20 00:46:39 +08:00
Nightsuki
c13c330747 feat: add ProxyJump support for SSH config import
- Parse ProxyJump directive when importing SSH config files
- Support all standard formats: host, user@host, host:port, user@host:port, ssh://
- Support chained jumps (comma-separated): ProxyJump jump1,jump2,jump3
- Auto-resolve jump hosts to existing hosts or create inline hosts
- Map parsed ProxyJump to hostChain for connection tunneling

UI improvements:
- Rename 'Jump Hosts' to 'Proxy via Hosts' for clarity
- Rename 'Proxy' to 'Proxy via HTTP/SOCKS5' to distinguish from host proxying
- Always show Proxy via Hosts config (not hidden behind toggle)
- Multi-line display for proxy chains with numbered hosts
- Remove confusing Agent Forwarding toggle from UI
2026-01-19 17:13:02 +08:00
陈大猫
a27b99cbf7 Merge pull request #79 from Nightsuki/fix/ssh-config-import-file-filter
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: allow importing ssh_config files without extension
2026-01-18 18:26:52 +08:00
Nightsuki
3d6e981758 fix: allow importing ssh_config files without extension
The file picker filter for ssh_config import was set to only accept
.conf, .config, and .txt files. However, the standard SSH config file
at ~/.ssh/config has no file extension, making it impossible to select
in the import dialog.

Changed the accept attribute to '*' to allow selecting any file,
including extension-less files like 'config'.
2026-01-18 18:20:39 +08:00
bincxz
e6d8c1381c Updates README screenshots
Replaces multiple screenshots with a single, more representative image across all language versions of the README files.
2026-01-17 21:28:33 +08:00
bincxz
bc3d73c683 Refreshes READMEs with latest features and screenshots
Updates the project READMEs across all languages to reflect recent feature additions and improvements.

*   Adds a direct link to the official application website.
*   Refreshes the Vault section with new images showcasing grid view, nested folder organization, and list view.
*   Updates the Terminal section to highlight Broadcast Mode and performance monitoring, replacing older screenshots.
*   Enhances the SFTP section with an updated dual-pane view and a new screenshot for the transfer queue.
*   Introduces a screenshot for the new Key Generator feature in the Keychain section.
*   Adds a "Contributors" section to acknowledge community contributions.
2026-01-17 21:26:36 +08:00
bincxz
dd5f3ddffd Ignores Monaco editor public assets
Prevents ESLint from processing files within the `public/monaco` directory. This helps avoid linting third-party or generated code, reducing unnecessary warnings and improving linting performance.
2026-01-17 17:45:58 +08:00
陈大猫
3959328e24 Merge pull request #77 from binaricat/copilot/add-sftp-reconnect-feature
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Add SFTP reconnect UI overlay with spinner
2026-01-17 04:12:37 +08:00
bincxz
48928254fa Adds auto-reconnect for lost SFTP sessions
Detects common SFTP session errors and automatically attempts to re‑establish the connection (up to three tries).
Provides user feedback with a reconnect overlay, spinner integration, and success/error toast notifications.
Adds corresponding English and Chinese i18n messages for reconnect status and failure.
Minor build config comment added (no functional impact).
2026-01-17 04:08:06 +08:00
copilot-swe-agent[bot]
30962c992f Revert package-lock.json changes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-16 11:32:35 +00:00
copilot-swe-agent[bot]
02e0fae051 Add reconnecting overlay UI with spinner for SFTP connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-16 11:30:01 +00:00
copilot-swe-agent[bot]
6a94716880 Initial plan 2026-01-16 11:24:06 +00:00
bincxz
2fecbb94fb Migrate electron-builder config to JS, drop JSON
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Removes the legacy `electron-builder.json` file and updates all packaging scripts to reference the new `electron-builder.config.cjs` module. This centralizes the build configuration in a JavaScript file, enabling richer logic and easier maintenance while eliminating the now‑unused JSON config.
2026-01-14 01:20:31 +08:00
bincxz
6bd3968e04 Reformats electron‑builder config and fixes script path
Improves readability by expanding target architecture arrays and UI element definitions onto separate lines.
Updates the FixQuarantine script reference to use the ${projectDir} variable, ensuring the correct absolute path during builds.
These changes make the configuration easier to maintain and avoid path resolution issues.
2026-01-14 00:49:23 +08:00
bincxz
528cda1f70 Updates dependencies to latest versions
Bumps a wide range of packages to their newest releases, including AWS SDK v3.967 and related @smithy modules, Electron builder libraries, Rollup 4.55.1, Babel 7.28.x, and @npmcli tooling. Removes deprecated packages (e.g., @tootallnate/once, is-ci) and adds missing utilities such as @isaacs/fs-minipass and ci-info. These updates improve compatibility with newer Node versions, address security fixes, and enhance build stability.
2026-01-13 16:15:51 +08:00
bincxz
29bde31989 Adjust peer flags in package-lock.json
Adds missing `"peer": true` entries for several development packages and removes incorrectly set peer flags from optional dependencies. This aligns the lockfile metadata with the actual peer‑dependency relationships, improving package resolution and consistency.
2026-01-13 16:12:44 +08:00
陈大猫
b12a2171e7 Merge pull request #75 from binaricat/copilot/add-custom-baud-rate-support
Add custom baud rate support, serial config persistence, and connection logging
2026-01-13 14:43:20 +08:00
bincxz
ffcd94e216 Add host tag/group i18n and clean imports
Introduce new localization entries for host tags and groups (add, select, create) in English and Chinese resource files, enabling UI support for tagging and grouping hosts.

Remove unused `X` icon import and the `cn` utility from the SerialHostDetailsPanel component to eliminate dead code and streamline imports.
2026-01-13 14:42:45 +08:00
copilot-swe-agent[bot]
9b77fc9e3b Add dedicated SerialHostDetailsPanel for editing serial hosts
- Create SerialHostDetailsPanel with serial-specific fields (port, baud rate, data bits, stop bits, parity, flow control, etc.)
- Update VaultView to show SerialHostDetailsPanel when editing serial hosts instead of the SSH-focused HostDetailsPanel
- Add i18n translations for serial edit panel (en, zh-CN)
- Fix ESLint exhaustive-deps warning

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:36:38 +00:00
copilot-swe-agent[bot]
9e57f2eb90 Address code review: fix substr deprecation and improve comments
- Replace deprecated substr() with substring()
- Add clearer documentation about port field usage for serial hosts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:10:53 +00:00
copilot-swe-agent[bot]
8cf6e9243d Support connecting saved serial hosts and add visual indicators
- Add serialConfig field to Host interface for storing full serial configuration
- Update connectToHost to handle serial hosts and create proper serial sessions
- Update handleConnectToHost to log serial connections properly
- Update DistroAvatar to show USB icon for serial hosts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:09:31 +00:00
copilot-swe-agent[bot]
7a32aa0743 Add custom baud rate support, serial config saving, and connection logging
- Changed baud rate input from fixed select to combobox with preset options + custom input
- Added "Save Configuration" checkbox to save serial port settings as a host entry
- Added connection logging for serial connections (similar to SSH/local terminals)
- Updated ConnectionLog type to include 'serial' protocol
- Updated ConnectionLogsManager to display serial connections with USB icon
- Added i18n translations for new UI elements (en, zh-CN)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-13 06:06:43 +00:00
copilot-swe-agent[bot]
a1d0ce02fe Initial plan 2026-01-13 05:57:40 +00:00
bincxz
adb2bc9403 Rename onOpenFile prop to _onOpenFile and tidy formatting
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Rename the unused `onOpenFile` callback to `_onOpenFile` to suppress lint warnings and clarify that it is intentionally unused.
Remove extraneous blank lines and adjust string concatenation in upload success messages for cleaner code formatting.

Rename unused onOpenFile prop and clean formatting

Renames the unused `onOpenFile` callback to `_onOpenFile` to silence lint warnings and make its intentional non‑use explicit.
Removes stray blank lines and streamlines string concatenation in upload success messages, improving code readability and consistency.
2026-01-12 23:08:54 +08:00
bincxz
7a6ed660fb Clears stray output lines after pwd command
Adds ANSI escape sequences to the pwd request so that the command echo, start/end markers, and the pwd output are removed from the terminal view. This prevents leftover lines from cluttering the SSH session display, resulting in a cleaner UI.
2026-01-12 23:00:35 +08:00
bincxz
035b22b467 Add stop‑bits warning, binary SFTP read support
- Introduces a user warning for 1.5 stop‑bits, which may be unsupported on some Windows devices, and updates both English and Chinese locales.
- Extends serial port validation to recognize Windows UNC COM paths and displays the new warning when 1.5 stop‑bits are selected.
- Improves SFTP modal behavior by re‑initializing when the initial path changes, using a ref to track the previous path.
- Refactors terminal logic to obtain the current working directory via stream markers, applies `flushSync` to commit the path before opening the SFTP modal, ensuring correct initialization.
- Adds a binary SFTP read API (`readSftpBinary`) on the Electron side and exposes it through the preload script, enabling proper handling of binary files.
- Updates local Claude settings to allow linting via `npm run lint:*`.
- Minor code clean‑ups and whitespace fixes across several bridge modules.
2026-01-12 22:58:44 +08:00
陈大猫
1bce2c9808 Merge pull request #73 from binaricat/copilot/add-ftp-auto-sync-feature
feat: Sync SFTP to terminal's current working directory
2026-01-12 22:12:04 +08:00
copilot-swe-agent[bot]
ca2d699e55 feat: Add terminal folder sync to SFTP - open SFTP at terminal's current directory
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 14:03:58 +00:00
copilot-swe-agent[bot]
6907fb54c8 Initial plan 2026-01-12 13:57:16 +00:00
陈大猫
4bae2517fe Merge pull request #72 from binaricat/copilot/fix-sftp-download-issue
Fix SFTP context menu mislabel: "Download" → "Open" for files
2026-01-12 21:05:26 +08:00
copilot-swe-agent[bot]
da4936ff22 Add Download to local functionality to SFTP context menu
- Added onDownloadFile callback to SftpPaneCallbacks interface
- Implemented handleDownloadFileForSide that reads file as binary
  and triggers browser download
- Added Download menu item after Edit in file context menu
- Now both Open (打开) and Download (下载) functions exist

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 13:03:35 +00:00
copilot-swe-agent[bot]
2223ec34f0 Fix SFTP context menu: change "Download" label to "Open" for files
The first context menu item for files incorrectly displayed "Download" (下载)
but actually performed an "Open" action. This fixes the mislabeling by:
- Changing the label from "Download" to "Open"
- Changing the icon from Download to ExternalLink
- Removing the duplicate "Open" menu item
- Updating the renderRow dependency array

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 12:49:49 +00:00
copilot-swe-agent[bot]
ca1423051d Initial plan 2026-01-12 12:44:53 +00:00
陈大猫
ca8b36c7d5 Merge pull request #71 from binaricat/copilot/fix-group-switching-issue
Fix group switching not saving when host already has a group
2026-01-12 19:35:42 +08:00
copilot-swe-agent[bot]
b96eaf2aca Fix group switching bug: sync groupInputValue when Combobox value changes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 11:22:11 +00:00
copilot-swe-agent[bot]
663fe88b2e Initial plan 2026-01-12 11:14:42 +00:00
陈大猫
42da477425 Merge pull request #68 from binaricat/copilot/add-sftp-drag-and-drop
Add external file drag-and-drop to SFTP views
2026-01-12 19:13:34 +08:00
copilot-swe-agent[bot]
474a13e4f9 polish: Final code review improvements
- Simplify success/failure message logic (check failCount === 0)
- Improve eslint comment to list all stable dependencies
- Clarify progress API fallback condition with explicit checks
- Add comment explaining fallback behavior

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:44:49 +00:00
copilot-swe-agent[bot]
3c5e12cc8b refactor: Improve code clarity per review feedback
- Use template literals instead of string concatenation for i18n
- Add inline comments to clarify undefined parameters
- Remove empty comment block
- Simplify toast message construction

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:43:30 +00:00
copilot-swe-agent[bot]
c2a01d83d7 fix: Properly handle writeSftpBinaryWithProgress return value
- Check result.success property instead of undefined
- Add fallback to writeSftpBinary if progress API fails
- Improve code clarity by checking method existence upfront

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:41:58 +00:00
copilot-swe-agent[bot]
dcc3b6fce7 fix: Correct upload progress API fallback logic and add eslint comment
- Fix writeSftpBinaryWithProgress return value check (undefined vs boolean)
- Add eslint-disable comment for sftpRef dependency (follows existing pattern)
- Simplify progress API fallback condition for clarity

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:40:37 +00:00
copilot-swe-agent[bot]
fb43b53f33 refactor: Address code review feedback
- Extract shared upload logic into handleUploadExternalFilesForSide helper
- Simplify SFTP upload fallback logic for better readability
- Remove code duplication between left and right upload handlers

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:38:24 +00:00
copilot-swe-agent[bot]
b90c29f56a feat: Add external file drag-and-drop support to SftpView
- Add uploadExternalFiles method to useSftpState for handling OS file drops
- Detect external file drag events in SftpView (e.dataTransfer.types includes 'Files')
- Handle external file drops separately from internal pane-to-pane transfers
- Upload dropped files to local or remote filesystems using existing backend methods
- Display success/error toasts for upload operations
- Support drag-and-drop to any active pane/tab

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:36:17 +00:00
copilot-swe-agent[bot]
37092826f3 Initial plan 2026-01-12 08:29:10 +00:00
陈大猫
30b809a8f6 Merge pull request #67 from binaricat/copilot/add-sftp-show-hidden-files-option
Add SFTP show hidden files setting
2026-01-12 15:47:52 +08:00
copilot-swe-agent[bot]
989a1aa3d7 Support Windows hidden files using file attribute, remove dotfile filtering
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:40:35 +00:00
copilot-swe-agent[bot]
9e5c5f826f Update hidden files documentation to clarify Unix/Linux dotfile convention
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:28:22 +00:00
copilot-swe-agent[bot]
74e0249797 Refactor: extract hidden file filtering into shared utility
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:16:59 +00:00
copilot-swe-agent[bot]
d89d6d3959 Add SFTP show hidden files setting
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:13:41 +00:00
copilot-swe-agent[bot]
66680d585f Initial plan 2026-01-12 07:00:55 +00:00
陈大猫
57dd2fb48b Merge pull request #65 from binaricat/copilot/fix-windows-serial-connection-issue
Fix Windows serial port validation to accept COM ports
2026-01-12 14:59:28 +08:00
copilot-swe-agent[bot]
6d973f9bc8 Fix Windows serial port validation to accept COM ports
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 06:26:00 +00:00
copilot-swe-agent[bot]
425647eeda Initial plan 2026-01-12 06:21:00 +00:00
陈大猫
9109aec4ab Merge pull request #64 from Weihong-Liu/feature/dmg-repair-helper
Add DMG background and repair helper app
2026-01-12 14:01:45 +08:00
Puppet
6d5283173a Add DMG background and repair helper app 2026-01-12 13:45:38 +08:00
陈大猫
ad67099ff3 Merge pull request #58 from binaricat:copilot/refactor-theme-modal-interface
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Move terminal theme selection to modal in Settings
2026-01-09 18:15:11 +08:00
copilot-swe-agent[bot]
02d44652df Address code review: remove unused translations and add accessibility
- Remove unused 'currentTheme' translation keys from English and Chinese
- Add role="dialog", aria-modal="true", aria-labelledby to ThemeSelectModal
- Add aria-label for close button accessibility

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 10:01:06 +00:00
copilot-swe-agent[bot]
d227424096 Move terminal theme selection to modal in SettingsTerminalTab
- Create new ThemeSelectModal component for theme selection
- Replace inline theme grid with a preview button that opens modal
- Group themes by type (dark/light) in modal
- Add i18n translations for modal (English and Chinese)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 09:58:06 +00:00
copilot-swe-agent[bot]
1105f7fbb1 Initial plan 2026-01-09 09:49:59 +00:00
陈大猫
ef681194e3 Merge pull request #57 from binaricat/copilot/fix-sftp-editor-save-issue
feat(sftp): add auto-sync for files opened with external apps
2026-01-09 17:47:03 +08:00
LAPTOP-O016UC3M\Qi Chen
4971a72620 fix: import shell from electron module 2026-01-09 17:44:33 +08:00
LAPTOP-O016UC3M\Qi Chen
8947d29717 feat(sftp): add temp directory management with System settings page
- Create dedicated Netcatty temp directory in system temp folder
- Add tempDirBridge for temp directory management
- Add System tab in Settings with temp dir info and cleanup
- Register temp files for cleanup when SFTP session closes
- Add i18n translations for System settings (en/zh-CN)
- Update agents.md with temp file guidelines
2026-01-09 17:42:39 +08:00
copilot-swe-agent[bot]
dfaeed1ed6 feat(sftp): add comprehensive logging for third-party app debugging
Add detailed console logging throughout the file open/watch flow:
- Log file download details (sftpId, paths, temp path)
- Log app launch command and platform
- Log file watch start/stop events
- Log auto-sync check status
- Add error handling for app spawn failures

This will help diagnose issues with third-party apps not triggering
file sync on Windows and other platforms.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:52:45 +00:00
copilot-swe-agent[bot]
443e038dcf fix(sftp): fix Ctrl+S stale closure and improve file watcher events
- Fix built-in editor Ctrl+S stale closure bug that caused saving the
  initial content instead of the current content
- Use a ref to always capture the latest handleSave function
- Improve file watcher to handle both 'change' and 'rename' events
  (some editors use atomic writes which trigger 'rename' instead)

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:17:04 +00:00
copilot-swe-agent[bot]
242d35927a fix(sftp): improve file watcher reliability
- Change fs.watch to use persistent: true for more reliable file watching
- Store webContents reference in watchInfo for later use
- Add debug logging for file system events

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 08:00:48 +00:00
copilot-swe-agent[bot]
708ee1cd09 feat(sftp): add system-level notifications for file sync
Use Electron's Notification API to show macOS/Windows/Linux system
notifications when files are synced to or fail to sync to remote.
These notifications are visible even when the app is not focused.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:52:49 +00:00
copilot-swe-agent[bot]
a2c24c2656 fix: use crypto.randomUUID for watchId and add file size validation
Address code review comments:
- Use crypto.randomUUID() instead of Math.random() for watchId generation
- Add file size checking in addition to mtime to prevent spurious sync events

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:33:35 +00:00
copilot-swe-agent[bot]
d91ed8dd23 feat(sftp): add auto-sync feature for files opened with external apps
Implements file watching to automatically sync changes back to remote:
- Add fileWatcherBridge in Electron for watching local temp files
- Add startFileWatch/stopFileWatch APIs in preload
- Add sftpAutoSync setting with UI toggle in settings
- Update downloadToTempAndOpen to support file watching
- Add i18n translations for auto-sync UI
- Show toast notifications when files are synced to remote

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-09 06:29:29 +00:00
copilot-swe-agent[bot]
689bb313f7 Initial plan 2026-01-09 06:12:22 +00:00
陈大猫
4ff05f7dbb Merge pull request #53 from AkarinServer/feature/linux-build-support
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add linux build support (x64/arm64)
2026-01-08 23:34:28 +08:00
TachibanaLolo
f930e80dab feat: add linux build support (x64/arm64) 2026-01-08 23:21:05 +08:00
陈大猫
e19b68db12 Merge pull request #52 from binaricat:copilot/add-auto-start-reconnect-port-forwarding
feat: add auto-start and auto-reconnect for port forwarding rules
2026-01-08 20:13:10 +08:00
copilot-swe-agent[bot]
f6e67b6edb fix: move auto-start to app level and remove indicator icon
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 12:10:31 +00:00
copilot-swe-agent[bot]
a86c74e509 refactor: extract reconnect logic into helper function and add a11y attributes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 11:43:43 +00:00
copilot-swe-agent[bot]
bedcaddea7 feat: add auto-start and auto-reconnect for port forwarding rules
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 11:39:36 +00:00
copilot-swe-agent[bot]
78aaa6840b Initial plan 2026-01-08 11:26:17 +00:00
陈大猫
dff869a89d Merge pull request #51 from binaricat/copilot/add-keepalive-feature
Add SSH Keepalive Interval setting
2026-01-08 19:09:52 +08:00
copilot-swe-agent[bot]
78d7b417fc Address code review feedback for keepalive interval
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 10:36:17 +00:00
copilot-swe-agent[bot]
27fcc4e493 Add keepaliveInterval setting for SSH connections
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 10:33:41 +00:00
copilot-swe-agent[bot]
b7216e9427 Initial plan 2026-01-08 10:26:20 +00:00
陈大猫
be4da72b21 Merge pull request #50 from qi-xmu/add_local_fonts
Add  local mono fonts support
2026-01-08 18:09:28 +08:00
LAPTOP-O016UC3M\Qi Chen
7b903c44b0 Removes unused import of terminal fonts list
Streamlines imports by removing a redundant font list import,
reducing unnecessary dependencies and improving code clarity.
2026-01-08 18:07:52 +08:00
LAPTOP-O016UC3M\Qi Chen
c3c23d042f refactor: replace useFontState hook with global fontStore singleton
- Create fontStore.ts with useSyncExternalStore pattern (matches project style)
- Eliminate props drilling (availableFonts through 5+ layers)
- Improve font detection with KNOWN_MONOSPACE_FONTS (50+ fonts)
- Add eager initialization at app startup
- Remove unused useFontState.ts
- Components now use useAvailableFonts()/useFontById() directly
2026-01-08 18:05:15 +08:00
LAPTOP-O016UC3M\Qi Chen
3263676996 fix: Address PR review comments for local font support
- Improve font filtering logic with word boundary matching to avoid false positives
- Rename snake_case variable to camelCase (mono_fonts -> monoFonts)
- Translate Chinese comments to English
- Add TypeScript type definitions for Font Access API
- Check API availability before calling queryLocalFonts
- Apply CJK fallback fonts to local fonts
- Add 'local-' prefix for local font IDs to avoid collisions
- Handle permission denied gracefully
- Use TERMINAL_FONTS as default for availableFonts
- Add safer fallbacks for empty availableFonts in all components
- Export withCjkFallback function for reuse
2026-01-08 17:53:46 +08:00
陈大猫
7c6a14afda Update lib/localFonts.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 17:37:20 +08:00
qi-xmu
6a76287bf7 Add local mono fonts support 2026-01-08 15:38:20 +08:00
LAPTOP-O016UC3M\Qi Chen
5317a4b81b Removes unnecessary whitespace in state initialization
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
Cleans up formatting in the state initialization expression for improved code consistency. No functional changes introduced.
2026-01-08 11:15:16 +08:00
LAPTOP-O016UC3M\Qi Chen
2574d6d5e4 Syncs editor theme with app by observing HTML class
Replaces use of global settings state for theme with a MutationObserver
that tracks the presence of the 'dark' class on the root HTML element.
Ensures the editor theme stays consistent with the actual app theme,
even if changed via side effects outside React state.
2026-01-08 11:15:11 +08:00
LAPTOP-O016UC3M\Qi Chen
f04b1220ed Syncs editor theme with user settings
Updates the editor to use the app's current theme preference,
ensuring consistency with user-selected light or dark modes.
Also improves the loading UI to better match the surrounding style.
2026-01-08 11:09:41 +08:00
LAPTOP-O016UC3M\Qi Chen
ce4d156c2c Remove trailing whitespace for code style consistency
Cleans up unnecessary trailing whitespace to maintain consistent code formatting and improve readability. No functional changes introduced.
2026-01-08 11:05:46 +08:00
LAPTOP-O016UC3M\Qi Chen
ca46c9c924 Adds toggleable filter bar to SFTP pane toolbar
Introduces a dedicated, toggleable filter bar for searching SFTP files, improving discoverability and usability over the previous inline filter input. Updates translations to support the new filter UI in English and Chinese.

Enhances user experience by making filtering more accessible and visually distinct from other toolbar actions.
2026-01-08 11:05:41 +08:00
陈大猫
f0d2c5c60d Merge pull request #48 from binaricat/copilot/add-human-readable-file-size
Display human-readable file sizes in SftpView
2026-01-08 10:58:18 +08:00
copilot-swe-agent[bot]
6cdf33a29d Fix file sizes to display in human-readable format (KB, MB, GB) in SftpView
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-08 02:51:18 +00:00
copilot-swe-agent[bot]
9b0b7c0eb7 Initial plan 2026-01-08 02:46:44 +00:00
LAPTOP-O016UC3M\Qi Chen
5954359995 Allow manual build trigger to publish releases
Enables publishing a GitHub Release when the workflow is manually
triggered and the corresponding input is set, providing more
flexibility for release management beyond tag-based automation.
2026-01-08 10:40:00 +08:00
LAPTOP-O016UC3M\Qi Chen
044165319e Updates Monaco editor path handling for production builds
Configures the editor to load Monaco assets from a local directory in production, improving reliability and performance by avoiding CDN usage.
Adds prebuild script to copy Monaco files, and updates ignore rules to exclude copied assets from version control.
2026-01-08 10:34:49 +08:00
bincxz
131553128a Removes initial path prop and improves SFTP start logic
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
Simplifies SFTP modal initialization by eliminating the initial
directory path prop and streamlining logic for selecting the
start path. Now always defaults to the user's home directory
when possible, with a fallback to root if inaccessible.
Enhances reliability and user experience by verifying
directory accessibility before loading contents.
2026-01-08 01:21:26 +08:00
bincxz
4aae4b19fc Improves loading overlay visibility when navigating
Adjusts the loading overlay to ensure it fully covers the SFTP pane and appears above other elements during directory navigation. Increases loader icon size and layering for better user feedback while loading files.
2026-01-08 01:13:05 +08:00
bincxz
7b5fb46fd7 Moves loading overlay from pane component to parent view
Centralizes the loading overlay logic by removing it from the individual pane component and handling it at the parent view level instead. Improves overlay rendering, ensures correct z-index stacking, and maintains clearer separation of concerns between view and pane responsibilities.
2026-01-08 01:10:47 +08:00
bincxz
5bfb1f01c2 Improves code readability by fixing indentation
Standardizes indentation and spacing for consistency and clarity,
making future maintenance easier and reducing potential confusion.
No logic or functional changes are introduced.
2026-01-08 01:02:45 +08:00
bincxz
12188e11ef Adds loading indicators for text editor file loading
Improves user feedback by displaying loading spinners when opening files in the SFTP and text editor modals.
Clarifies loading state to prevent confusion during slow file reads.
2026-01-08 01:02:28 +08:00
bincxz
c0756e9981 Suppresses monaco-editor source map warnings
Introduces a custom plugin to prevent noisy source map
warnings from monaco-editor during development server runs,
improving console clarity and developer experience.
2026-01-08 00:55:01 +08:00
bincxz
b600aedc6f Improves context menu icons for directory and file actions
Adds distinct icons for "open" and "download" actions in the context menu to enhance user recognition and clarify available operations for directories and files.
2026-01-08 00:53:11 +08:00
bincxz
9fe915c65e Improves SFTP home directory detection for root user
Ensures the home path resolves to /root for the root user
instead of /home/root, preventing navigation errors on SFTP.
Adds fallback logic for non-root users to check both their
home and /root directories for accessibility.
2026-01-08 00:50:02 +08:00
陈大猫
1aa634a6c2 Merge pull request #44 from binaricat/copilot/add-sftp-double-click-option
Add configurable SFTP double-click behavior setting
2026-01-08 00:47:12 +08:00
copilot-swe-agent[bot]
bfbab88ac2 Optimize: Cache isNavigableDirectory result to avoid repeated calls
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:40:47 +00:00
copilot-swe-agent[bot]
faa7fd6dad Clean up: Remove debug console.log and fix isDirectory check
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:39:29 +00:00
copilot-swe-agent[bot]
9c6c653931 Fix: SFTPModal should open files on double-click (not download)
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:37:20 +00:00
copilot-swe-agent[bot]
d46b63398e Fix: Use custom radio buttons instead of non-existent RadioGroup component
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:36:05 +00:00
copilot-swe-agent[bot]
72bc03573c Add SFTP double-click behavior setting (open vs transfer)
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:33:00 +00:00
copilot-swe-agent[bot]
66c543cb97 Initial plan 2026-01-07 16:23:42 +00:00
bincxz
fc61647c34 Improve code formatting for file opener logic
Updates spacing and indentation to enhance readability
and maintain consistency in file opener-related code blocks.
No functional changes introduced.
2026-01-08 00:19:55 +08:00
bincxz
b2390da5b6 Prevents stale file opener associations in callbacks
Uses a ref to always access the latest file opener association in file operation callbacks, avoiding issues caused by stale closures when opener associations change.

Adds logging for debugging opener selection logic and ensures dialogs are shown if opener data is invalid or missing.

Improves reliability of file opening behavior in SFTP view.
2026-01-08 00:19:50 +08:00
bincxz
a3e0d4d5c1 Replaces language selector with combobox and improves UI truncation
Switches the language selection component in the editor modal to a combobox for better usability and UX consistency.
Improves truncation and overflow handling on dialogs and combobox triggers to prevent layout issues and enhance readability, especially for long file names and options.
Removes redundant "only system app" info for non-editable files to streamline the dialog.
2026-01-08 00:13:37 +08:00
bincxz
45af36fd28 Removes unused SFTP binary read function from props
Cleans up the component props by eliminating an unused function,
improving maintainability and reducing potential confusion.
No functional changes introduced.
2026-01-07 23:51:53 +08:00
bincxz
00784a6b0e Removes built-in image preview functionality
Drops support for the built-in image viewer, including related UI, state, and file association logic. Simplifies file opener options and context menus to support only text editing and external system applications.

Streamlines codebase by eliminating redundant image preview code and reduces maintenance overhead for this feature.
2026-01-07 23:50:37 +08:00
bincxz
de6acf0347 Add 'Open With' option to SFTP file context menu
Enables users to open files with a chosen application via a new
'Open With' context menu action, which always prompts for an
opener selection. Improves flexibility for file handling and
association management in the SFTP view.
2026-01-07 23:36:47 +08:00
bincxz
7c067964ee Normalize whitespace and formatting in components
Cleans up inconsistent whitespace and formatting in multiple component files
to improve readability and maintain code style consistency. No functional
logic is changed.
2026-01-07 23:29:08 +08:00
bincxz
6b4cecf94f Adds "Open With" system application support for SFTP files
Enables users to open SFTP files using any system application via a new "Open With" dialog. Remembers associations per file extension, supports editing/removing these in settings, and synchronizes changes across components. Updates backend and Electron bridge to handle selecting and launching external applications on all platforms. Improves localization and user feedback for the new feature.
2026-01-07 23:28:51 +08:00
bincxz
6b83f6c494 Remove extraneous whitespace in path join logic
Cleans up unnecessary blank line to improve code readability
and maintain consistent formatting. No functional changes made.
2026-01-07 22:06:14 +08:00
bincxz
d2b58e69b0 Adds file preview and edit actions to SFTP UI
Enables text and image file viewing and editing directly from the SFTP interface with context menu actions and modals. Improves file type detection to distinguish between text, image, and known binary files, preventing inappropriate editing options. Updates editor modal with find support and refines build config to reduce dev warnings.

Enhances user workflow by reducing friction in managing remote files.
2026-01-07 22:06:04 +08:00
LAPTOP-O016UC3M\Qi Chen
7ffc9d427e Removes unnecessary whitespace for code style consistency
Cleans up extra blank lines and trailing spaces across multiple
components to improve code readability and maintain consistent
formatting. No logic or functional changes are introduced.
2026-01-07 21:09:25 +08:00
LAPTOP-O016UC3M\Qi Chen
d6db6c5db1 Adds Monaco-based syntax highlighting editor and OSC 7 CWD sync
Integrates Monaco Editor for enhanced syntax highlighting in the text editor modal, improving editing experience for code and config files.

Tracks and synchronizes the terminal's current working directory via OSC 7 escape sequences, enabling SFTP dialogs to open at the detected shell path even after user or directory changes.

Introduces IPC and backend support for querying the current shell directory from active SSH sessions, with fallback and path validation logic for remote SFTP browsing.

Improves file type detection by analyzing file content in addition to extensions.

Updates i18n strings and UI elements for clarity and future system application integration.

Adds necessary dependencies for Monaco Editor and updates content security policy for web workers.
2026-01-07 21:09:07 +08:00
陈大猫
a528ade563 Merge pull request #43 from binaricat/copilot/add-sftp-open-file-functionality
Add SFTP file opener with built-in text editor and image preview
2026-01-07 20:18:02 +08:00
copilot-swe-agent[bot]
3e2edbec5e Fix code review issues: remove pyc from text extensions, use readSftpBinary for images
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:30:21 +00:00
copilot-swe-agent[bot]
f7464f1d45 Add Settings tab for SFTP file associations management
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:27:34 +00:00
copilot-swe-agent[bot]
3bbe5f5fc4 Fix lint warnings and TypeScript errors in SFTP file opener
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:27:23 +00:00
copilot-swe-agent[bot]
e515e3d981 Add SFTP file opener feature with text editor and image preview
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:22:28 +00:00
copilot-swe-agent[bot]
41ecef675c Initial plan 2026-01-07 11:00:55 +00:00
LAPTOP-O016UC3M\Qi Chen
ac6f61b8cf Refreshes file list and selection on directory change
Ensures file list and selection state reset when switching directories or hosts, preventing stale data display. Also improves table header rendering by always displaying column headers for consistency and better UX.
2026-01-07 18:01:01 +08:00
LAPTOP-O016UC3M\Qi Chen
0990c26cb2 Improves symlink UI and refactors SSH IPC error handling
Enhances the UI for symbolic links by displaying a link icon overlay
and removing the arrow indicator, making symlinks more visually
distinct. Refactors SSH IPC message sending to use a new safe send
function, preventing errors when web contents are destroyed during
shutdown, and simplifies event handling for better reliability.
2026-01-07 17:31:12 +08:00
LAPTOP-O016UC3M\Qi Chen
753ce0480c Improves symlink label spacing for file rows
Adds right padding to symlink file labels for better visual separation,
enhancing readability and UI consistency when displaying symbolic links.
2026-01-07 17:15:55 +08:00
LAPTOP-O016UC3M\Qi Chen
974506415e Improves formatting and code consistency in SFTP components
Standardizes whitespace and indentation for better readability
and maintainability. Refactors markup in file list rendering to
enhance clarity and structure without changing functionality.
2026-01-07 17:01:51 +08:00
LAPTOP-O016UC3M\Qi Chen
51330c0443 Adds SFTP file rename and permission editing support
Enables renaming and permission editing for remote SFTP files, including new dialogs and backend parsing of octal/symbolic permission formats.
Improves file list with parent directory entry and virtual scrolling for large directories.
Suppresses noisy SSH authentication error traces for cleaner UI fallback.
Enhances translation coverage for updated features.
2026-01-07 17:01:37 +08:00
陈大猫
ba761004e0 Merge pull request #41 from binaricat:copilot/fix-windows-file-system-navigation
Fix Windows drive path navigation in SFTP file browser
2026-01-07 15:07:17 +08:00
copilot-swe-agent[bot]
4278188292 Fix Windows path handling in SFTP navigation bar and breadcrumb
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 06:56:45 +00:00
copilot-swe-agent[bot]
cccb17c919 Initial plan 2026-01-07 06:50:28 +00:00
LAPTOP-O016UC3M\Qi Chen
ab0e5e95b3 Improves formatting and JSX structure in SFTP components
Cleans up whitespace and indentation in SFTP-related files for better readability and consistency.
Adjusts JSX hierarchy in the SFTP view to ensure dialog and transfer components are properly nested, reducing risk of rendering issues and improving maintainability.
No logic or behavioral changes introduced.
2026-01-07 14:44:56 +08:00
LAPTOP-O016UC3M\Qi Chen
4102a45810 Improves SFTP tab performance and cross-pane tab drag
Optimizes SFTP view rendering by isolating tab activation state, using context stores and memoization to prevent unnecessary re-renders on tab switch. Introduces cross-pane tab drag-and-drop, allowing users to move tabs between panes via the tab bar, and ensures stable callback references throughout. Refactors UI components for memoization and efficient prop updates, and wraps key dialogs with React.memo for better performance.

Enhances overall responsiveness, especially when switching or managing tabs in complex SFTP sessions.
2026-01-07 14:44:50 +08:00
LAPTOP-O016UC3M\Qi Chen
dc14255983 Improves add tab button hover feedback
Enhances the visual feedback of the add tab button by adding a gradient background and smooth transition on hover, making it more noticeable and interactive.
2026-01-07 13:31:00 +08:00
LAPTOP-O016UC3M\Qi Chen
771eef0af9 Improves formatting for readability and consistency
Refactors code style in several UI rendering sections to maintain
consistent indentation and formatting. Enhances readability and
reduces visual noise, making future maintenance easier. No functional
logic is changed.
2026-01-07 13:27:00 +08:00
LAPTOP-O016UC3M\Qi Chen
45e9960d6b Enables virtualized SFTP file lists with tab-aware panes
Improves performance and responsiveness of SFTP panes by introducing virtualization for large file lists, rendering only visible rows and reducing DOM overhead. Refactors pane rendering to be tab-aware, allowing seamless switching between multiple connections. Enhances user experience with better selection logic, drag-and-drop stability, and in-place UI feedback for file operations. Also adds extensive logging for easier debugging and transitions filter/sort updates to React's concurrent mode for increased UI responsiveness.
2026-01-07 13:26:32 +08:00
LAPTOP-O016UC3M\Qi Chen
8ced017474 Prevents extra tab creation and refines pane headers
Ensures only one tab is created on initial connect by avoiding unnecessary tab addition. Updates pane header logic to allow hiding the header for empty right panes and improves tab bar active state styling for consistency.
2026-01-07 11:20:48 +08:00
陈大猫
4a07c00a71 Merge pull request #38 from binaricat/copilot/add-multiple-tabs-to-sftpview
Add multi-tab support for SFTP view
2026-01-07 11:07:12 +08:00
copilot-swe-agent[bot]
33cacfcd3d Hide tab bar and header when no tabs, add border-bottom to tab container, fix accent color
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 03:01:04 +00:00
copilot-swe-agent[bot]
35b72b0992 Refactor connect function to avoid race condition with tab creation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:45:23 +00:00
copilot-swe-agent[bot]
fd77431847 Update tab bar styling to rectangular design with border-bottom, remove Change button, fix host selection bug
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:41:20 +00:00
copilot-swe-agent[bot]
c5f7540c6e Address code review feedback: improve comments, use constants, fix i18n keys
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:19:04 +00:00
copilot-swe-agent[bot]
b7428d0cbb Add multi-tab support for SFTP view with tab bar, drag-reorder, and close functionality
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:15:03 +00:00
copilot-swe-agent[bot]
02c4d97934 Initial plan 2026-01-07 02:00:15 +00:00
bincxz
986f552779 Closes settings window with main window exit
Some checks failed
build-packages / build-windows-latest (push) Has been cancelled
build-packages / build-macos-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Ensures the settings window closes automatically when the main window is closed, preventing orphaned settings windows and improving overall window lifecycle management.
2026-01-07 00:35:01 +08:00
bincxz
42647e3572 Removes parent window assignment for settings window
Avoids rendering issues on macOS and prevents unintended main window closure on Windows by not assigning a parent window when opening the settings window.
2026-01-07 00:29:50 +08:00
陈大猫
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
LAPTOP-O016UC3M\Qi Chen
3876e8c479 Handles master key changes across windows
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Ensures state updates and auto-unlock logic respond correctly when the master key is created or removed in another window. Prevents stale unlock attempts and synchronizes security state, improving multi-window cloud sync reliability.
2026-01-04 15:21:22 +08:00
LAPTOP-O016UC3M\Qi Chen
7143bce61e Improves code style and consistency in settings tab
Refactors indentation and spacing for better readability across the settings application tab and toast UI components.
Enhances maintainability by unifying code formatting and minor whitespace adjustments.
No functional logic is changed.
2026-01-04 15:10:00 +08:00
LAPTOP-O016UC3M\Qi Chen
7c232335ef Adds update check with notifications and improved toast UX
Implements automatic and manual update checking, displaying toast notifications when new versions are available or when the app is up to date. Enhances the toast utility to support clickable actions, custom labels, and improved accessibility. Updates UI to surface update status more prominently and localizes new update-related messages.

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

View File

@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)"
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
]
}
}

View File

@@ -2,6 +2,11 @@ name: build-packages
on:
workflow_dispatch:
inputs:
publish_release:
description: "Publish GitHub Release after build"
type: boolean
default: false
push:
tags:
- "v*"
@@ -13,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
os: [macos-latest, windows-latest, ubuntu-latest]
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -53,6 +58,12 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -74,7 +85,7 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
steps:

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ coverage
/release
/out
*.asar
/public/monaco
# Editor directories and files
.vscode/*

124
App.tsx
View File

@@ -1,10 +1,13 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { initializeFonts } from './application/state/fontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
@@ -16,12 +19,16 @@ import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
// Initialize fonts eagerly at app startup
initializeFonts();
// Visibility container for VaultView - isolates isActive subscription
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const isActive = useIsVaultActive();
@@ -144,6 +151,8 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication state (2FA/MFA)
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
const {
theme,
@@ -207,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,
@@ -256,6 +266,73 @@ function App({ settings }: { settings: SettingsState }) {
return handleSyncNow({ trigger: 'manual' });
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
// Show toast notification when update is available
useEffect(() => {
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
t('update.available.message', { version }),
{
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
});
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onKeyboardInteractive) return;
const unsubscribe = bridge.onKeyboardInteractive((request) => {
console.log('[App] Keyboard-interactive request received:', request);
setKeyboardInteractiveRequest({
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
});
});
return () => {
unsubscribe?.();
};
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
setKeyboardInteractiveRequest(null);
}, []);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
setKeyboardInteractiveRequest(null);
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -584,6 +661,25 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
addConnectionLog({
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -600,6 +696,24 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
addConnectionLog({
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
username: username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
@@ -741,6 +855,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}
@@ -916,6 +1031,13 @@ function App({ settings }: { settings: SettingsState }) {
/>
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
<KeyboardInteractiveModal
request={keyboardInteractiveRequest}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
</div>
);
}

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong>
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -14,13 +15,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=最新版をダウンロード&color=success" alt="最新版をダウンロード">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Ko-fi でサポート">
@@ -33,7 +40,7 @@
---
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty メインインターフェース](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -130,27 +137,47 @@
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
| ダークモード | ライトモード | リストビュー |
|------------|------------|------------|
| ![ダーク](screenshots/main-window-dark.png) | ![ライト](screenshots/main-window-light.png) | ![リスト](screenshots/main-window-dark-list.png) |
**ダークモード**
![ホスト管理](screenshots/vault_grid_view.png)
**ネストされたフォルダと整理**
![ネストされたフォルダ](screenshots/nested_folder_structure.png)
**リストビュー**
![リストビュー](screenshots/vault_list_view.png)
<a name="ターミナル"></a>
## ターミナル
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
| 分割ウィンドウ | テーマカスタマイズ |
|--------------|-----------------|
| ![分割](screenshots/split-window.png) | ![テーマ](screenshots/terminal-theme-change.png) |
**分割ウィンドウ**
![ターミナルテーマ](screenshots/terminal-theme-change-2.png)
**ブロードキャストモード**
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
![ブロードキャストモード](screenshots/broadcast_mode.png)
**パフォーマンス情報とカスタマイズ**
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
![ターミナルパフォーマンス](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP ビュー](screenshots/sftp.png)
![SFTP デュアルペイン](screenshots/sftp_dual_pane.png)
**転送キュー**
![転送キュー](screenshots/sftp_transfer_queue.png)
<a name="キーチェーン"></a>
## キーチェーン
@@ -172,6 +199,10 @@ WebGL アクセラレーション対応の xterm.js ベースのターミナル
![キーマネージャー](screenshots/key-manager.png)
**キー生成**
![キー生成](screenshots/key_generator_ui.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
@@ -245,7 +276,15 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
### ダウンロード
[GitHub Releases](https://github.com/user/netcatty/releases/latest) から最新版をダウンロードしてください。
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
> ```bash
@@ -261,8 +300,8 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
```bash
# リポジトリをクローン
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 依存関係をインストール
npm install
@@ -341,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくれたすべての人々に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="ライセンス"></a>
# ライセンス

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong>
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -14,13 +15,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Download%20Latest&color=success" alt="Download Latest Release">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="Support on Ko-fi">
@@ -33,7 +40,7 @@
---
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty Main Interface](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -61,7 +68,7 @@
<a name="what-is-netcatty"></a>
# What is Netcatty
**Netcatty** is a modern SSH client and terminal manager for macOS and Windows, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
**Netcatty** is a modern SSH client and terminal manager for macOS, Windows, and Linux, designed for developers, sysadmins, and DevOps engineers who need to manage multiple remote servers efficiently.
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
- **Netcatty is** a powerful SFTP client with dual-pane file browser
@@ -130,27 +137,47 @@
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
| Dark Mode | Light Mode | List View |
|-----------|------------|-----------|
| ![Dark](screenshots/main-window-dark.png) | ![Light](screenshots/main-window-light.png) | ![List](screenshots/main-window-dark-list.png) |
**Dark Mode**
![Host Management](screenshots/vault_grid_view.png)
**Nested Folders & Organization**
![Nested Folders](screenshots/nested_folder_structure.png)
**List View**
![List View](screenshots/vault_list_view.png)
<a name="terminal"></a>
## Terminal
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
| Split Windows | Theme Customization |
|---------------|---------------------|
| ![Split](screenshots/split-window.png) | ![Theme](screenshots/terminal-theme-change.png) |
**Split Windows**
![Terminal Themes](screenshots/terminal-theme-change-2.png)
**Broadcast Mode**
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
![Broadcast Mode](screenshots/broadcast_mode.png)
**Performance Info & Customization**
Monitor your connection health and customize every aspect of your terminal.
![Terminal Performance](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
![SFTP View](screenshots/sftp.png)
![SFTP Dual Pane](screenshots/sftp_dual_pane.png)
**Transfer Queue**
![Transfer Queue](screenshots/sftp_transfer_queue.png)
<a name="keychain"></a>
## Keychain
@@ -172,6 +199,10 @@ The Keychain is your secure vault for SSH credentials. Generate new keys, import
![Key Manager](screenshots/key-manager.png)
**Key Generator**
![Key Generator](screenshots/key_generator_ui.png)
<a name="port-forwarding"></a>
## Port Forwarding
@@ -245,7 +276,15 @@ Netcatty automatically detects and displays OS icons for connected hosts:
### Download
Download the latest release from [GitHub Releases](https://github.com/user/netcatty/releases/latest).
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
| Platform | Architecture | Status |
|----------|--------------|--------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
| **macOS** | Intel | ✅ Supported |
| **Windows** | x64 | ✅ Supported |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
@@ -255,14 +294,14 @@ Download the latest release from [GitHub Releases](https://github.com/user/netca
### Prerequisites
- Node.js 18+ and npm
- macOS or Windows 10+
- macOS, Windows 10+, or Linux
### Development
```bash
# Clone the repository
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# Install dependencies
npm install
@@ -305,6 +344,7 @@ npm run pack
# Package for specific platforms
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS installer)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
@@ -340,6 +380,17 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
---
<a name="contributors"></a>
# Contributors
Thanks to all the people who contribute!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="license"></a>
# License

View File

@@ -5,7 +5,8 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong>
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
@@ -14,13 +15,19 @@
</p>
<p align="center">
<a href="https://github.com/user/netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/user/netcatty?style=for-the-badge&logo=github&label=Release"></a>
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest">
<img src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=下载最新版&color=success" alt="下载最新版">
</a>
</p>
<p align="center">
<a href="https://ko-fi.com/binaricat">
<img src="https://cdn.ko-fi.com/cdn/kofi3.png?v=2" width="150" alt="在 Ko-fi 上支持我">
@@ -33,7 +40,7 @@
---
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
[![Netcatty 主界面](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
---
@@ -130,27 +137,47 @@
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
| 深色模式 | 浅色模式 | 列表视图 |
|---------|---------|---------|
| ![深色](screenshots/main-window-dark.png) | ![浅色](screenshots/main-window-light.png) | ![列表](screenshots/main-window-dark-list.png) |
**深色模式**
![主机管理](screenshots/vault_grid_view.png)
**层级文件夹与分组**
![层级文件夹](screenshots/nested_folder_structure.png)
**列表视图**
![列表视图](screenshots/vault_list_view.png)
<a name="终端"></a>
## 终端
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
| 分屏窗口 | 主题定制 |
|---------|---------|
| ![分屏](screenshots/split-window.png) | ![主题](screenshots/terminal-theme-change.png) |
**分屏窗口**
![终端主题](screenshots/terminal-theme-change-2.png)
**广播模式**
一次输入,多处执行。非常适合同时维护这多台服务器。
![广播模式](screenshots/broadcast_mode.png)
**性能信息与定制**
监控连接健康状况,并自定义终端的方方面面。
![终端性能](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 视图](screenshots/sftp.png)
![SFTP 双窗格](screenshots/sftp_dual_pane.png)
**传输队列**
![传输队列](screenshots/sftp_transfer_queue.png)
<a name="密钥管理"></a>
## 密钥管理
@@ -172,6 +199,10 @@ Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建
![密钥管理器](screenshots/key-manager.png)
**密钥生成器**
![密钥生成器](screenshots/key_generator_ui.png)
<a name="端口转发"></a>
## 端口转发
@@ -245,7 +276,15 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
### 下载
从 [GitHub Releases](https://github.com/user/netcatty/releases/latest) 下载最新版本。
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
@@ -261,8 +300,8 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
```bash
# 克隆仓库
git clone https://github.com/user/netcatty.git
cd netcatty
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# 安装依赖
npm install
@@ -341,6 +380,17 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="贡献者"></a>
# 贡献者
感谢所有参与贡献的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="开源协议"></a>
# 开源协议

View File

@@ -31,6 +31,7 @@ This project is wired around three layers: domain (pure logic), application stat
## Data & Storage
- Persisted keys: see `storageKeys.ts`. Use `localStorageAdapter` for all reads/writes.
- Seed data: `config/defaultData.ts`; terminal themes: `config/terminalThemes.ts`.
- **Temporary files**: All temporary files (e.g., SFTP downloaded files for external editing) must be written to Netcatty's dedicated temp directory via `tempDirBridge.getTempFilePath(fileName)`. Do not write directly to `os.tmpdir()`. This ensures proper cleanup and user visibility in Settings > System.
## Testing & Safety
- Favor unit tests for domain helpers (e.g., `workspace.ts`, `host.ts`) and hook-level tests for application state.

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',
@@ -60,6 +61,21 @@ const en: Messages = {
'settings.tab.terminal': 'Terminal',
'settings.tab.shortcuts': 'Shortcuts',
'settings.tab.syncCloud': 'Sync & Cloud',
'settings.tab.system': 'System',
// Settings > System
'settings.system.title': 'System',
'settings.system.description': 'System information and temporary file management.',
'settings.system.tempDirectory': 'Temporary Files',
'settings.system.location': 'Location',
'settings.system.fileCount': 'Files',
'settings.system.totalSize': 'Size',
'settings.system.openFolder': 'Open folder',
'settings.system.refresh': 'Refresh',
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
@@ -72,6 +88,17 @@ const en: Messages = {
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
// Update notifications
'update.available.title': 'Update Available',
'update.available.message': 'A new version {version} is available. Click to download.',
'update.checking': 'Checking for updates...',
'update.upToDate.title': 'Up to Date',
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
@@ -95,6 +122,10 @@ const en: Messages = {
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
'settings.terminal.themeModal.lightThemes': 'Light Themes',
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -155,6 +186,21 @@ 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.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -246,7 +292,7 @@ const en: Messages = {
'vault.hosts.header.live': '{count} live',
// Vault hosts header/actions
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname...',
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
@@ -345,9 +391,17 @@ 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',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
// SFTP
'sftp.newFolder': 'New Folder',
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
@@ -371,13 +425,17 @@ 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.fileName': 'File name',
'sftp.fileName.placeholder': 'Enter file name',
'sftp.prompt.newFolderName': 'New folder name?',
'sftp.rename.title': 'Rename',
'sftp.rename.newName': 'New name',
@@ -390,6 +448,13 @@ const en: Messages = {
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.createFileFailed': 'Failed to create file',
'sftp.error.invalidFileName': 'Filename contains invalid characters: {chars}',
'sftp.error.reservedName': 'This filename is reserved by the system',
'sftp.overwrite.title': 'File Already Exists',
'sftp.overwrite.desc': 'A file named "{name}" already exists. Do you want to replace it?',
'sftp.overwrite.confirm': 'Replace',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
'sftp.picker.searchPlaceholder': 'Search hosts...',
@@ -403,11 +468,16 @@ const en: Messages = {
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.success': 'Permissions updated successfully',
'sftp.permissions.failed': 'Failed to update permissions',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remote',
'sftp.pane.selectHost': 'Select host',
'sftp.pane.selectHostToStart': 'Select a host to start',
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
@@ -420,6 +490,88 @@ const en: Messages = {
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.replace': 'Replace',
// SFTP File Opener
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
'sftp.opener.title': 'Open with',
'sftp.opener.desc': 'Choose an application to open this file',
'sftp.opener.builtInEditor': 'Built-in Editor',
'sftp.opener.editDescription': 'Edit text files',
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
'sftp.opener.previewDescription': 'Preview images',
'sftp.opener.systemApp': 'Choose Application...',
'sftp.opener.systemAppDescription': 'Select an application from your computer',
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
'sftp.opener.noAppsAvailable': 'No applications available',
'sftp.opener.noExtension': 'files without extension',
'sftp.opener.setDefault': 'Always use this for {ext} files',
'sftp.opener.confirmTitle': 'Set as Default?',
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
'sftp.opener.yesRemember': 'Yes, remember this choice',
'sftp.opener.justOnce': 'Just this once',
'sftp.opener.confirm.title': 'Set Default Application',
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
'sftp.editor.title': 'Text Editor',
'sftp.editor.save': 'Save to Remote',
'sftp.editor.saving': 'Saving...',
'sftp.editor.saved': 'Saved successfully',
'sftp.editor.saveFailed': 'Failed to save file',
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
'sftp.preview.title': 'Image Preview',
'sftp.preview.zoomIn': 'Zoom In',
'sftp.preview.zoomOut': 'Zoom Out',
'sftp.preview.resetZoom': 'Reset Zoom',
'sftp.preview.fitToWindow': 'Fit to Window',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
'settings.sftpFileAssociations.application': 'Application',
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
'settings.sftp.doubleClickBehavior.open': 'Open file',
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
'sftp.error.connectionLostManual': 'Connection lost. Please reconnect manually.',
'sftp.error.connectionLostReconnecting': 'Connection lost. Reconnecting...',
'sftp.error.sessionLost': 'SFTP session lost. Please reconnect.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
@@ -431,6 +583,9 @@ const en: Messages = {
// Select Host panel
'selectHost.title': 'Select Host',
'selectHost.noHostsFound': 'No hosts found',
'selectHost.newHost': 'New Host',
'selectHost.continue': 'Continue',
'selectHost.continueWithCount': 'Continue ({count} selected)',
// Quick Connect
'quickConnect.knownHost.title': 'Are you sure you want to connect?',
@@ -439,6 +594,7 @@ const en: Messages = {
'quickConnect.knownHost.addQuestion': 'Do you want to add it to the list of known hosts?',
'quickConnect.knownHost.addAndContinue': 'Add and continue',
'quickConnect.addKey': 'Add key',
'quickConnect.warning.unparsedOptions': 'Some SSH arguments were ignored: {options}',
// Terminal
'terminal.connectionErrorTitle': 'Connection Error',
@@ -472,16 +628,17 @@ const en: Messages = {
'hostDetails.keys.empty': 'No keys available',
'hostDetails.certs.search': 'Search certificates...',
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Agent Forwarding',
'hostDetails.jumpHosts': 'Jump Hosts',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
'hostDetails.jumpHosts.configure': 'Configure Jump Hosts',
'hostDetails.proxy': 'Proxy',
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
@@ -522,6 +679,12 @@ const en: Messages = {
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
'hostDetails.telnet.add': 'Add Telnet Protocol',
'hostDetails.tags': 'Tags',
'hostDetails.group': 'Group',
'hostDetails.selectGroup': 'Select Group',
'hostDetails.addTag': 'Add a tag...',
'hostDetails.createTag': 'Create tag',
'hostDetails.createGroup': 'Create group',
// Host form (legacy modal)
'hostForm.title.edit': 'Edit Host',
@@ -675,6 +838,20 @@ const en: Messages = {
'cloudSync.s3.forcePathStyle': 'Force path-style URLs (for MinIO/R2, etc.)',
'cloudSync.s3.showSecret': 'Show secrets',
'cloudSync.s3.validation.required': 'Endpoint, region, bucket, access key, and secret are required.',
'cloudSync.smb.title': 'SMB Settings',
'cloudSync.smb.desc': 'Connect to an SMB/CIFS file share for encrypted sync.',
'cloudSync.smb.share': 'Share Path',
'cloudSync.smb.username': 'Username',
'cloudSync.smb.password': 'Password',
'cloudSync.smb.domain': 'Domain (optional)',
'cloudSync.smb.domainPlaceholder': 'e.g., WORKGROUP',
'cloudSync.smb.port': 'Port (optional)',
'cloudSync.smb.showSecret': 'Show password',
'cloudSync.smb.validation.share': 'Share path is required.',
'cloudSync.smb.validation.port': 'Port must be a number between 1 and 65535.',
'cloudSync.connect.smb.success': 'SMB connected successfully',
'cloudSync.connect.smb.failedTitle': 'SMB connection failed',
'cloudSync.provider.smb': 'SMB Share',
'cloudSync.connect.webdav.success': 'WebDAV connected successfully',
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
'cloudSync.connect.s3.success': 'S3 connected successfully',
@@ -911,6 +1088,57 @@ 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.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'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 or COM1',
'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',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
'serial.field.customBaudRate': 'Using custom baud rate',
'serial.field.saveConfig': 'Save Configuration',
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
'serial.field.configLabel': 'Configuration Name',
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response',
'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
};
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': '选择主机',
@@ -48,6 +49,21 @@ const zhCN: Messages = {
'settings.tab.terminal': '终端',
'settings.tab.shortcuts': '快捷键',
'settings.tab.syncCloud': '同步与云',
'settings.tab.system': '系统',
// Settings > System
'settings.system.title': '系统',
'settings.system.description': '系统信息与临时文件管理。',
'settings.system.tempDirectory': '临时文件',
'settings.system.location': '位置',
'settings.system.fileCount': '文件数量',
'settings.system.totalSize': '占用空间',
'settings.system.openFolder': '打开文件夹',
'settings.system.refresh': '刷新',
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
@@ -60,6 +76,17 @@ const zhCN: Messages = {
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
// Update notifications
'update.available.title': '发现新版本',
'update.available.message': '新版本 {version} 已发布,点击前往下载。',
'update.checking': '正在检查更新...',
'update.upToDate.title': '已是最新版本',
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
@@ -148,7 +175,7 @@ const zhCN: Messages = {
'vault.hosts.header.live': '{count} 个在线',
// Vault hosts header/actions
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname…',
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
@@ -237,6 +264,9 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
@@ -260,13 +290,17 @@ 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.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
@@ -279,6 +313,13 @@ const zhCN: Messages = {
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.createFileFailed': '创建文件失败',
'sftp.error.invalidFileName': '文件名包含非法字符:{chars}',
'sftp.error.reservedName': '此文件名是系统保留名称',
'sftp.overwrite.title': '文件已存在',
'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?',
'sftp.overwrite.confirm': '替换',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
'sftp.picker.searchPlaceholder': '搜索主机...',
@@ -287,11 +328,13 @@ const zhCN: Messages = {
'sftp.picker.local.badge': '本地',
'sftp.picker.noMatch': '没有匹配的主机',
'sftp.permissions.title': '编辑权限',
'sftp.permissions.owner': 'Owner',
'sftp.permissions.group': 'Group',
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.owner': '所有者',
'sftp.permissions.group': '群组',
'sftp.permissions.others': '其他',
'sftp.permissions.octal': '八进制',
'sftp.permissions.symbolic': '符号',
'sftp.permissions.success': '权限已更新',
'sftp.permissions.failed': '权限更新失败',
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
@@ -304,6 +347,9 @@ const zhCN: Messages = {
// Select Host panel
'selectHost.title': '选择主机',
'selectHost.noHostsFound': '未找到主机',
'selectHost.newHost': '新建主机',
'selectHost.continue': '继续',
'selectHost.continueWithCount': '继续(已选 {count} 个)',
// Quick Connect
'quickConnect.knownHost.title': '确认要连接吗?',
@@ -312,6 +358,7 @@ const zhCN: Messages = {
'quickConnect.knownHost.addQuestion': '是否将它加入 Known Hosts',
'quickConnect.knownHost.addAndContinue': '加入并继续',
'quickConnect.addKey': '添加 key',
'quickConnect.warning.unparsedOptions': '部分 SSH 参数已被忽略: {options}',
// Protocol select dialog
'protocolSelect.chooseProtocol': '选择协议',
@@ -341,12 +388,13 @@ const zhCN: Messages = {
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '代理转发',
'hostDetails.jumpHosts': '跳板主机',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置跳板主机',
'hostDetails.proxy': '代理',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -363,6 +411,12 @@ const zhCN: Messages = {
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8',
'hostDetails.telnet.add': '添加 Telnet 协议',
'hostDetails.tags': '标签',
'hostDetails.group': '分组',
'hostDetails.selectGroup': '选择分组',
'hostDetails.addTag': '添加标签...',
'hostDetails.createTag': '创建标签',
'hostDetails.createGroup': '创建分组',
// Host form (legacy modal)
'hostForm.title.edit': '编辑主机',
@@ -515,6 +569,20 @@ const zhCN: Messages = {
'cloudSync.s3.forcePathStyle': '强制使用 path-style URL适用于 MinIO/R2 等)',
'cloudSync.s3.showSecret': '显示密钥',
'cloudSync.s3.validation.required': '端点、Region、Bucket、Access Key 与 Secret 必填。',
'cloudSync.smb.title': 'SMB 设置',
'cloudSync.smb.desc': '连接到 SMB/CIFS 文件共享以进行加密同步。',
'cloudSync.smb.share': '共享路径',
'cloudSync.smb.username': '用户名',
'cloudSync.smb.password': '密码',
'cloudSync.smb.domain': '域(可选)',
'cloudSync.smb.domainPlaceholder': '例如WORKGROUP',
'cloudSync.smb.port': '端口(可选)',
'cloudSync.smb.showSecret': '显示密码',
'cloudSync.smb.validation.share': '共享路径必填。',
'cloudSync.smb.validation.port': '端口必须是 1 到 65535 之间的数字。',
'cloudSync.connect.smb.success': 'SMB 已连接',
'cloudSync.connect.smb.failedTitle': 'SMB 连接失败',
'cloudSync.provider.smb': 'SMB 共享',
'cloudSync.connect.webdav.success': 'WebDAV 已连接',
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
'cloudSync.connect.s3.success': 'S3 已连接',
@@ -634,6 +702,11 @@ 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': '关闭并删除',
'pf.form.autoStart': '自动启动',
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
@@ -641,6 +714,9 @@ const zhCN: Messages = {
'sftp.pane.selectHost': '选择主机',
'sftp.pane.selectHostToStart': '先选择一个主机',
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',
@@ -653,8 +729,94 @@ const zhCN: Messages = {
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.replace': '替换',
// SFTP File Opener
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
'sftp.opener.title': '打开方式',
'sftp.opener.desc': '选择一个应用程序来打开此文件',
'sftp.opener.builtInEditor': '内置编辑器',
'sftp.opener.editDescription': '编辑文本文件',
'sftp.opener.builtInImageViewer': '内置图片预览',
'sftp.opener.previewDescription': '预览图片',
'sftp.opener.systemApp': '选择应用程序...',
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
'sftp.opener.noAppsAvailable': '无可用应用程序',
'sftp.opener.noExtension': '无扩展名文件',
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
'sftp.opener.confirmTitle': '设为默认?',
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
'sftp.opener.yesRemember': '是,记住此选择',
'sftp.opener.justOnce': '仅此一次',
'sftp.opener.confirm.title': '设置默认应用程序',
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
'sftp.editor.title': '文本编辑器',
'sftp.editor.save': '保存到远程',
'sftp.editor.saving': '保存中...',
'sftp.editor.saved': '保存成功',
'sftp.editor.saveFailed': '保存文件失败',
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
'sftp.editor.syntaxHighlight': '语法高亮',
'sftp.preview.title': '图片预览',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '缩小',
'sftp.preview.resetZoom': '重置缩放',
'sftp.preview.fitToWindow': '适应窗口',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
'settings.sftpFileAssociations.application': '应用程序',
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
'settings.sftp.doubleClickBehavior.open': '打开文件',
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
'sftp.reconnected': '连接已恢复',
'sftp.error.reconnectFailed': '重连失败,请重试。',
'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。',
'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...',
'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
'settings.terminal.themeModal.darkThemes': '深色主题',
'settings.terminal.themeModal.lightThemes': '浅色主题',
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -709,6 +871,21 @@ 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.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -900,6 +1077,57 @@ 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.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'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': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
'serial.field.customBaudRate': '使用自定义波特率',
'serial.field.saveConfig': '保存配置',
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
'serial.field.configLabel': '配置名称',
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应',
'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
};
export default zhCN;

View File

@@ -0,0 +1,146 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import { getMonospaceFonts } from '../../lib/localFonts';
/**
* Global font store - singleton pattern using useSyncExternalStore
* Ensures fonts are loaded only once and shared across all components
*/
type Listener = () => void;
interface FontStoreState {
availableFonts: TerminalFont[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
class FontStore {
private state: FontStoreState = {
availableFonts: TERMINAL_FONTS,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
// Getters for individual state slices
getAvailableFonts = (): TerminalFont[] => this.state.availableFonts;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
getError = (): string | null => this.state.error;
private notify = () => {
// Defer listener notification to avoid "setState during render"
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<FontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
/**
* Initialize font loading - safe to call multiple times,
* will only load once
*/
initialize = async (): Promise<void> => {
// Already loaded or currently loading
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
const localFonts = await getMonospaceFonts();
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();
// Add default fonts first
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace to avoid collisions
localFonts.forEach(font => {
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
fontMap.set(localId, { ...font, id: localId });
});
this.setState({
availableFonts: Array.from(fontMap.values()),
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local fonts, using defaults:', error);
this.setState({
availableFonts: TERMINAL_FONTS,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
/**
* Find a font by ID with fallback
*/
getFontById = (fontId: string): TerminalFont => {
const fonts = this.state.availableFonts;
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
}
// Singleton instance
export const fontStore = new FontStore();
// ============== Hooks ==============
/**
* Get available fonts - triggers initialization on first use
*/
export const useAvailableFonts = (): TerminalFont[] => {
// Trigger initialization on first use
if (!fontStore.getIsLoaded() && !fontStore.getIsLoading()) {
fontStore.initialize();
}
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getAvailableFonts
);
};
/**
* Get font loading state
*/
export const useFontsLoading = (): boolean => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading
);
};
/**
* Get font by ID with fallback - useful for components that need a specific font
*/
export const useFontById = (fontId: string): TerminalFont => {
const fonts = useAvailableFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
/**
* Initialize fonts eagerly (call at app startup)
*/
export const initializeFonts = (): void => {
fontStore.initialize();
};

View File

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

View File

@@ -0,0 +1,137 @@
/**
* Hook for auto-starting port forwarding rules on app launch.
* This should be used at the App level to ensure auto-start happens
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useEffect, useRef } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
getActiveConnection,
setReconnectCallback,
startPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string }[];
}
/**
* Auto-starts port forwarding rules that have autoStart enabled.
* This hook should be called at the App level to run on app launch.
*/
export const usePortForwardingAutoStart = ({
hosts,
keys,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
// Keep refs in sync
useEffect(() => {
hostsRef.current = hosts;
}, [hosts]);
useEffect(() => {
keysRef.current = keys;
}, [keys]);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
ruleId: string,
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
) => {
// Load the current rules from storage
const rules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const rule = rules.find((r) => r.id === ruleId);
if (!rule || !rule.hostId) {
return { success: false, error: "Rule or host not found" };
}
const host = hostsRef.current.find((h) => h.id === rule.hostId);
if (!host) {
return { success: false, error: "Host not found" };
}
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
return () => {
setReconnectCallback(null);
};
}, []);
// Auto-start rules on app launch
useEffect(() => {
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const runAutoStart = async () => {
// First sync with backend to get any active tunnels
await syncWithBackend();
// Load rules from storage
const rules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
// Only start rules that are not already active
const autoStartRules = rules.filter((r) => {
if (!r.autoStart || !r.hostId) return false;
// Check if there's an active connection for this rule
const conn = getActiveConnection(r.id);
// Only start if not already connecting or active
return !conn || conn.status === 'inactive' || conn.status === 'error';
});
if (autoStartRules.length === 0) return;
autoStartExecutedRef.current = true;
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
// Start each auto-start rule
for (const rule of autoStartRules) {
const host = hosts.find((h) => h.id === rule.hostId);
if (host) {
void startPortForward(
rule,
host,
keys,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((r) =>
r.id === rule.id
? {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
}
: r,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
true, // Enable reconnect for auto-start rules
);
}
}
};
void runAutoStart();
}, [hosts, keys]);
};

View File

@@ -7,10 +7,12 @@ import {
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
clearReconnectTimer,
getActiveConnection,
getActiveRuleIds,
startPortForward,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
@@ -50,6 +52,7 @@ export interface UsePortForwardingStateResult {
host: Host,
keys: { id: string; privateKey: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
@@ -78,25 +81,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
@@ -204,11 +214,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
status: PortForwardingRule["status"],
error?: string,
) => void,
enableReconnect = false,
) => {
return startPortForward(rule, host, keys, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
});
}, enableReconnect);
},
[setRuleStatus],
);
@@ -218,6 +229,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
ruleId: string,
onStatusChange?: (status: PortForwardingRule["status"]) => void,
) => {
// Clear any pending reconnect timer when manually stopping
clearReconnectTimer(ruleId);
return stopPortForward(ruleId, (status) => {
setRuleStatus(ruleId, status);
onStatusChange?.(status);

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,7 +53,56 @@ 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) => {
// Handle serial hosts specially - use createSerialSession for them
if (host.protocol === 'serial') {
// Use stored serialConfig or construct from host data
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const sessionId = crypto.randomUUID();
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
const newSession: TerminalSession = {
id: sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
}
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: host.id,
@@ -590,6 +639,7 @@ export const useSessionState = () => {
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeWorkspace,

View File

@@ -16,11 +16,15 @@ STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -32,10 +36,13 @@ const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -97,13 +104,14 @@ const applyThemeTokens = (
root.style.setProperty('--border', tokens.border);
root.style.setProperty('--input', tokens.input);
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(theme);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
@@ -146,13 +154,25 @@ export const useSettingsState = () => {
}
return DEFAULT_HOTKEY_SCHEME;
});
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
const [customKeyBindings, setCustomKeyBindings] = useState<CustomKeyBindings>(() =>
localStorageAdapter.read<CustomKeyBindings>(STORAGE_KEY_CUSTOM_KEY_BINDINGS) || {}
);
const [isHotkeyRecording, setIsHotkeyRecordingState] = useState(false);
const [customCSS, setCustomCSS] = useState<string>(() =>
const [customCSS, setCustomCSS] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
);
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
});
const [sftpAutoSync, setSftpAutoSync] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -371,11 +391,31 @@ export const useSettingsState = () => {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -415,7 +455,7 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
@@ -426,6 +466,24 @@ export const useSettingsState = () => {
styleEl.textContent = customCSS;
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -484,8 +542,8 @@ export const useSettingsState = () => {
);
const currentTerminalFont = useMemo(
() => TERMINAL_FONTS.find(f => f.id === terminalFontFamilyId) || TERMINAL_FONTS[0],
[terminalFontFamilyId]
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
@@ -532,5 +590,12 @@ export const useSettingsState = () => {
setIsHotkeyRecording,
customCSS,
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
};
};

View File

@@ -174,6 +174,63 @@ export const useSftpBackend = () => {
return bridge.onTransferProgress(transferId, cb);
}, []);
const selectApplication = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return undefined;
return bridge.selectApplication();
}, []);
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("Download to temp / open with unavailable");
}
// Download the file to temp
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, tempPath);
} catch (err) {
console.warn("[SFTPBackend] Failed to register temp file for cleanup:", err);
}
}
// Open with the selected application
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
await bridge.openWithApplication(tempPath, appPath);
console.log("[SFTPBackend] Application launched");
// Start file watching if enabled
let watchId: string | undefined;
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
console.warn("[SFTPBackend] Failed to start file watch:", err);
// Don't fail the operation if watching fails
}
} else {
console.log("[SFTPBackend] File watching not enabled or not available");
}
return { localTempPath: tempPath, watchId };
}, []);
return {
openSftp,
closeSftp,
@@ -201,6 +258,8 @@ export const useSftpBackend = () => {
startStreamTransfer,
cancelTransfer,
onTransferProgress,
selectApplication,
downloadSftpToTempAndOpen,
};
};

View File

@@ -0,0 +1,149 @@
/**
* useSftpFileAssociations - Hook for managing SFTP file opener associations
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
systemApp?: SystemAppInfo;
}
export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// Shared state and subscribers for cross-component synchronization
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Loading from storage:', stored);
if (stored) {
const migrated: FileAssociationsMap = {};
for (const [ext, value] of Object.entries(stored)) {
if (typeof value === 'string') {
migrated[ext] = { openerType: value as FileOpenerType };
} else {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
}
// Initialize from storage
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
// Verify it was saved
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Verification read from storage:', verify);
}
function updateAssociations(newAssociations: FileAssociationsMap) {
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
subscribers.forEach(callback => callback());
}
function subscribe(callback: () => void) {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function getSnapshot() {
return snapshotRef;
}
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
/**
* Get the opener entry for a file based on its extension
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
return associations[ext] || null;
}, [associations]);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
updateAssociations({
...snapshotRef.associations,
[extension.toLowerCase()]: { openerType, systemApp },
});
}, []);
/**
* Remove the association for a specific extension
*/
const removeAssociation = useCallback((extension: string) => {
const next = { ...snapshotRef.associations };
delete next[extension.toLowerCase()];
updateAssociations(next);
}, []);
/**
* Get all associations as an array
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
extension,
openerType: entry.openerType,
systemApp: entry.systemApp,
}));
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
return result;
}, [associations]);
/**
* Clear all associations
*/
const clearAllAssociations = useCallback(() => {
updateAssociations({});
}, []);
return {
associations,
getOpenerForFile,
setOpenerForExtension,
removeAssociation,
getAllAssociations,
clearAllAssociations,
};
}

View File

@@ -0,0 +1,184 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

File diff suppressed because it is too large Load Diff

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,18 +110,34 @@ export const useTerminalBackend = () => {
return !!bridge?.startSSHSession;
}, []);
const listSerialPorts = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.listSerialPorts) return [];
return bridge.listSerialPorts();
}, []);
const getSessionPwd = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
writeToSession,
resizeSession,
closeSession,

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
@@ -63,6 +64,7 @@ interface LogItemProps {
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
return (
<div
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Server } from "lucide-react";
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { cn } from "../lib/utils";
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const containerClass = sizeClasses[size];
const iconSize = iconSizes[size];
// Show USB icon for serial hosts
if (host.protocol === 'serial') {
return (
<div
className={cn(
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
<Usb className={iconSize} />
</div>
);
}
if (logo && !errored) {
return (
<div

View File

@@ -0,0 +1,132 @@
/**
* FileOpenerDialog - Dialog for choosing how to open a file
*/
import { Edit2, FolderOpen } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
interface FileOpenerDialogProps {
open: boolean;
onClose: () => void;
fileName: string;
onSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,
onSelect,
onSelectSystemApp,
}) => {
const { t } = useI18n();
const [isSelectingApp, setIsSelectingApp] = useState(false);
const [rememberChoice, setRememberChoice] = useState(true);
const extension = getFileExtension(fileName);
// Show edit option for files that are not known binary formats
const canEdit = !isKnownBinaryFile(fileName);
// For files without extension, we use 'file' as virtual extension
// So we always allow setting default (hasExtension is always true)
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
onSelect(openerType, rememberChoice);
onClose();
}, [rememberChoice, onSelect, onClose]);
const handleSelectSystemApp = useCallback(async () => {
setIsSelectingApp(true);
try {
const result = await onSelectSystemApp();
if (result) {
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
onSelect('system-app', rememberChoice, result);
onClose();
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingApp(false);
}
}, [onSelectSystemApp, rememberChoice, onSelect, onClose]);
return (
<Dialog open={open} onOpenChange={(isOpen) => {
// Don't close while selecting system app
if (!isOpen && !isSelectingApp) {
onClose();
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader className="min-w-0">
<DialogTitle>{t('sftp.opener.title')}</DialogTitle>
<DialogDescription className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
{fileName}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
{canEdit && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-12"
onClick={() => handleSelectBuiltIn('builtin-editor')}
>
<Edit2 size={18} className="text-primary" />
<div className="text-left">
<div className="font-medium text-sm">{t('sftp.opener.builtInEditor')}</div>
<div className="text-xs text-muted-foreground">{t('sftp.opener.editDescription')}</div>
</div>
</Button>
)}
{/* System application option */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-12"
onClick={handleSelectSystemApp}
disabled={isSelectingApp}
>
<FolderOpen size={18} className="text-primary" />
<div className="text-left">
<div className="font-medium text-sm">{t('sftp.opener.systemApp')}</div>
<div className="text-xs text-muted-foreground">{t('sftp.opener.systemAppDescription')}</div>
</div>
</Button>
</div>
{/* Remember choice checkbox - always show, use 'file' for no extension */}
<div className="flex items-center gap-2 pb-2">
<input
type="checkbox"
id="remember-choice"
checked={rememberChoice}
onChange={(e) => setRememberChoice(e.target.checked)}
className="rounded border-border h-4 w-4 accent-primary"
/>
<label
htmlFor="remember-choice"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
{t('sftp.opener.setDefault', { ext: displayExtension })}
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default FileOpenerDialog;

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,
@@ -90,11 +92,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
protocol: "ssh",
tags: [],
os: "linux",
agentForwarding: false,
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
);
@@ -286,10 +288,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]);
@@ -519,12 +521,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Combobox
options={groupOptions}
value={form.group || ""}
onValueChange={(val) => update("group", val)}
onValueChange={(val) => {
update("group", val);
setGroupInputValue(val);
}}
placeholder={t("hostDetails.group.placeholder")}
allowCreate={true}
onCreateNew={(val) => {
onCreateGroup?.(val);
update("group", val);
setGroupInputValue(val);
}}
createText="Create Group"
triggerClassName="flex-1 h-10"
@@ -639,10 +645,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 +656,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 +676,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 +708,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 +856,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 +919,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">
@@ -1017,75 +1023,92 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
/>
</Card>
{/* Agent Forwarding */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<ToggleRow
label={t("hostDetails.agentForwarding")}
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.desc")}
</p>
</Card>
{/* Host Chain Configuration - Only show when Agent Forwarding is enabled */}
{form.agentForwarding && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.jumpHosts")}
</p>
</div>
{chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
{t("hostDetails.jumpHosts.direct")}
</Badge>
)}
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.jumpHosts")}
</p>
</div>
{chainedHosts.length > 0 && (
<button
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("chain")}
{chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
<Link2
size={14}
className="text-muted-foreground flex-shrink-0"
/>
<span className="text-sm truncate">
{chainedHosts
.slice(0, 3)
.map((h) => h.hostname || h.label)
.join(" -> ")}
{chainedHosts.length > 3 && "..."}
</span>
{t("hostDetails.jumpHosts.direct")}
</Badge>
)}
</div>
{chainedHosts.length > 0 && (
<button
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("chain")}
>
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0 flex-1">
<Link2
size={14}
className="text-muted-foreground flex-shrink-0"
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</span>
</div>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearHostChain();
}}
/>
</button>
)}
{chainedHosts.length === 0 && (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("chain")}
>
<Plus size={14} />
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</Card>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
<div key={h.id} className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">{idx + 1}.</span>
<span className="truncate">
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
</span>
</div>
))}
{chainedHosts.length > 5 && (
<div className="text-xs text-muted-foreground">
+{chainedHosts.length - 5} more...
</div>
)}
</div>
</button>
)}
{chainedHosts.length === 0 && (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("chain")}
>
<Plus size={14} />
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</Card>
{/* Proxy Configuration */}
<Card className="p-3 space-y-2 bg-card border-border/80">

View File

@@ -0,0 +1,189 @@
/**
* Keyboard Interactive Authentication Modal
* Global modal for handling SSH keyboard-interactive authentication (2FA/MFA)
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface KeyboardInteractivePrompt {
prompt: string;
echo: boolean;
}
export interface KeyboardInteractiveRequest {
requestId: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
}
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onCancel: (requestId: string) => void;
}
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
}
}, [request]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
const updated = [...prev];
updated[index] = value;
return updated;
});
}, []);
const toggleShowPassword = useCallback((index: number) => {
setShowPasswords((prev) => {
const updated = [...prev];
updated[index] = !updated[index];
return updated;
});
}, []);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting]
);
if (!request) return null;
const title = request.name?.trim() || t("keyboard.interactive.title");
const description =
request.instructions?.trim() ||
(request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc"));
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
{request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo;
const showPassword = showPasswords[index];
// Clean up prompt text (remove trailing colon and whitespace)
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
return (
<div key={index} className="space-y-2">
<Label htmlFor={`ki-prompt-${index}`}>
{promptLabel || t("keyboard.interactive.response")}
</Label>
<div className="relative">
<Input
id={`ki-prompt-${index}`}
type={isPassword && !showPassword ? "password" : "text"}
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isPassword
? t("keyboard.interactive.enterCode")
: t("keyboard.interactive.enterResponse")
}
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
/>
{isPassword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("keyboard.interactive.verifying")}
</>
) : (
t("keyboard.interactive.submit")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default KeyboardInteractiveModal;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,428 @@
/**
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, 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 { Host, 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 { Input } from './ui/input';
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;
onSaveHost?: (host: Host) => 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,
onSaveHost,
}) => {
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);
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
const [configLabel, setConfigLabel] = useState('');
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
// Generate a default label when port is selected
useEffect(() => {
if (selectedPort && !configLabel) {
const portName = selectedPort.split('/').pop() || selectedPort;
setConfigLabel(`Serial: ${portName}`);
}
}, [selectedPort, configLabel]);
const handleConnect = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
// Save as host if checkbox is checked and onSaveHost is provided
if (saveConfig && onSaveHost) {
const portName = selectedPort.split('/').pop() || selectedPort;
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort,
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
username: '',
os: 'linux',
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
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/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
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>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</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>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</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>
{/* Save Configuration */}
{onSaveHost && (
<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="save-config" className="text-sm font-medium cursor-pointer">
{t('serial.field.saveConfig')}
</Label>
<p className="text-xs text-muted-foreground">
{t('serial.field.saveConfigDesc')}
</p>
</div>
<input
type="checkbox"
id="save-config"
checked={saveConfig}
onChange={(e) => setSaveConfig(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</div>
{saveConfig && (
<div className="space-y-2">
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
<Input
id="config-label"
value={configLabel}
onChange={(e) => setConfigLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
{saveConfig ? (
<Save size={14} className="mr-2" />
) : (
<Cpu size={14} className="mr-2" />
)}
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SerialConnectModal;

View File

@@ -0,0 +1,415 @@
/**
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, 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 { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from './ui/aside-panel';
interface SerialPort {
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
type?: 'hardware' | 'pseudo' | 'custom';
}
interface SerialHostDetailsPanelProps {
initialData: Host;
allTags?: string[];
groups?: string[];
onSave: (host: Host) => void;
onCancel: () => 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 SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
initialData,
allTags = [],
groups = [],
onSave,
onCancel,
}) => {
const { t } = useI18n();
const terminalBackend = useTerminalBackend();
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
const loadPorts = useCallback(async () => {
setIsLoadingPorts(true);
try {
const result = await terminalBackend.listSerialPorts();
setPorts(result);
} catch (err) {
console.error('[Serial] Failed to list ports:', err);
} finally {
setIsLoadingPorts(false);
}
}, [terminalBackend]);
useEffect(() => {
loadPorts();
}, [loadPorts]);
const handleSave = () => {
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
baudRate,
dataBits,
stopBits,
parity,
flowControl,
localEcho,
lineMode,
};
const portName = selectedPort.split('/').pop() || selectedPort;
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
tags,
group,
serialConfig: config,
};
onSave(updatedHost);
};
// Convert ports to Combobox options
const portOptions: ComboboxOption[] = useMemo(() => {
return ports.map((port) => ({
value: port.path,
label: port.path,
sublabel: port.manufacturer || undefined,
}));
}, [ports]);
// Tag options for MultiCombobox
const tagOptions: ComboboxOption[] = useMemo(() => {
const allUniqueTags = new Set([...allTags, ...tags]);
return Array.from(allUniqueTags).map((tag) => ({
value: tag,
label: tag,
}));
}, [allTags, tags]);
// Group options for Combobox
const groupOptions: ComboboxOption[] = useMemo(() => {
const allGroups = new Set(groups);
if (group && !allGroups.has(group)) {
allGroups.add(group);
}
return Array.from(allGroups).map((g) => ({
value: g,
label: g,
}));
}, [groups, group]);
// Validation
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && isBaudRateValid;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('serial.edit.title')}
subtitle={initialData.label}
className="z-40"
>
<AsidePanelContent>
{/* Label */}
<div className="space-y-2">
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
<Input
id="serial-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('serial.field.configLabelPlaceholder')}
/>
</div>
{/* Serial Port */}
<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"
>
{t('common.refresh')}
</Button>
</div>
<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>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Tag size={14} />
{t('hostDetails.tags')}
</Label>
<MultiCombobox
options={tagOptions}
values={tags}
onValuesChange={setTags}
placeholder={t('hostDetails.addTag')}
allowCreate
createText={t('hostDetails.createTag')}
/>
</div>
{/* Group */}
<div className="space-y-2">
<Label>{t('hostDetails.group')}</Label>
<Combobox
options={groupOptions}
value={group}
onValueChange={setGroup}
placeholder={t('hostDetails.selectGroup')}
allowCreate
createText={t('hostDetails.createGroup')}
/>
</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>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</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>
</AsidePanelContent>
<AsidePanelFooter>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} className="flex-1">
{t('common.cancel')}
</Button>
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
<Save size={14} className="mr-2" />
{t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
};
export default SerialHostDetailsPanel;

View File

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

View File

@@ -2,7 +2,7 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useVaultState } from "../application/state/useVaultState";
@@ -10,13 +10,18 @@ import { useWindowControls } from "../application/state/useWindowControls";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
type SettingsState = ReturnType<typeof useSettingsState>;
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
@@ -117,12 +122,24 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
>
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
</TabsTrigger>
<TabsTrigger
value="file-associations"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
</TabsTrigger>
<TabsTrigger
value="sync"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
>
<Cloud size={14} /> {t("settings.tab.syncCloud")}
</TabsTrigger>
<TabsTrigger
value="system"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
>
<HardDrive size={14} /> {t("settings.tab.system")}
</TabsTrigger>
</TabsList>
</div>
@@ -158,6 +175,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
)}
@@ -173,11 +191,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
/>
)}
{mountedTabs.has("file-associations") && (
<SettingsFileAssociationsTab />
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault />
</React.Suspense>
)}
{mountedTabs.has("system") && <SettingsSystemTab />}
</div>
</Tabs>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
/**
* SyncStatusButton - Cloud Sync Status Indicator for Top Bar
*
*
* Shows current sync state with cloud icon and colored indicators:
* - Green dot: All synced
* - Blue dot + spin: Syncing in progress
* - Blue dot + spin: Syncing in progress
* - Red dot: Error
* - Gray dot: No providers connected
*
*
* Clicking opens a popover with sync status details and history.
*/
@@ -239,7 +239,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
<CloudOff size={32} className="mx-auto mb-2 text-muted-foreground" />
<p className="text-sm font-medium mb-1">{t('sync.notConfigured')}</p>
<p className="text-xs text-muted-foreground mb-3">
Connect a cloud provider to sync your data across devices.
{t('sync.autoSync.noProvider')}
</p>
<Button
size="sm"
@@ -249,7 +249,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
onOpenSettings?.();
}}
>
Configure Cloud Sync
{t('sync.settings')}
</Button>
</div>
) : (

View File

@@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -12,6 +13,7 @@ import {
Host,
Identity,
KnownHost,
SerialConfig,
SSHKey,
Snippet,
TerminalSession,
@@ -25,7 +27,7 @@ import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { toast } from "./ui/toast";
import { TERMINAL_FONTS } from "../infrastructure/config/fonts";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
@@ -58,6 +60,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 +106,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
@@ -126,6 +130,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}) => {
const CONNECTION_TIMEOUT = 12000;
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -138,6 +143,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;
@@ -165,6 +171,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
@@ -174,6 +182,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -280,6 +289,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startupCommand,
terminalSettings,
terminalBackend,
serialConfig,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
@@ -336,6 +346,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
commandBufferRef,
setIsSearchOpen,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
});
xtermRuntimeRef.current = runtime;
@@ -346,7 +360,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 +425,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 +470,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;
@@ -529,7 +554,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
@@ -539,7 +564,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit(), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme]);
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
@@ -709,6 +734,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
const handleCancelConnect = () => {
setIsCancelling(true);
auth.setNeedsAuth(false);
@@ -787,7 +840,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onOpenSFTP={handleOpenSFTP}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -822,11 +875,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 +900,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 +919,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>
@@ -907,7 +960,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
ref={containerRef}
className="absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "40px",
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
}}
@@ -972,6 +1025,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,10 +1077,13 @@ 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"}
onClose={() => setShowSFTP(false)}
initialPath={sftpInitialPath}
/>
</div>
</TerminalContextMenu>

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

@@ -0,0 +1,310 @@
/**
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
*/
import {
CloudUpload,
Loader2,
Search,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Combobox } from './ui/combobox';
import { toast } from './ui/toast';
interface TextEditorModalProps {
open: boolean;
onClose: () => void;
fileName: string;
initialContent: string;
onSave: (content: string) => Promise<void>;
}
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
'python': 'python',
'shell': 'shell',
'batch': 'bat',
'powershell': 'powershell',
'c': 'c',
'cpp': 'cpp',
'java': 'java',
'kotlin': 'kotlin',
'go': 'go',
'rust': 'rust',
'ruby': 'ruby',
'php': 'php',
'perl': 'perl',
'lua': 'lua',
'r': 'r',
'swift': 'swift',
'dart': 'dart',
'csharp': 'csharp',
'fsharp': 'fsharp',
'vb': 'vb',
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'jsonc': 'json',
'json5': 'json',
'xml': 'xml',
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
'plaintext': 'plaintext',
'vue': 'html',
'svelte': 'html',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'diff': 'diff',
};
return mapping[langId] || 'plaintext';
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
initialContent,
onSave,
}) => {
const { t } = useI18n();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
);
// Listen for theme changes via MutationObserver on <html> class
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
setIsDarkTheme(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
// Reset content when file changes
useEffect(() => {
setContent(initialContent);
setHasChanges(false);
setLanguageId(getLanguageId(fileName));
}, [initialContent, fileName]);
// Track changes
useEffect(() => {
setHasChanges(content !== initialContent);
}, [content, initialContent]);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
try {
await onSave(content);
setHasChanges(false);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
'SFTP'
);
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
// Keep the ref updated with the latest handleSave function
useEffect(() => {
handleSaveRef.current = handleSave;
}, [handleSave]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
if (!confirmed) return;
}
onClose();
}, [hasChanges, onClose, t]);
const handleEditorChange = useCallback((value: string | undefined) => {
setContent(value || '');
}, []);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
// Add save shortcut - use ref to avoid stale closure
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSaveRef.current();
});
// Add find shortcut (Ctrl+F / Cmd+F)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
}, []);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
editorRef.current.trigger('keyboard', 'actions.find', null);
editorRef.current.focus();
}
}, []);
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
);
const handleLanguageChange = useCallback((nextValue: string) => {
setLanguageId(nextValue || 'plaintext');
}, []);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold truncate">
{fileName}
{hasChanges && <span className="text-primary ml-1">*</span>}
</DialogTitle>
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
value={languageId}
onValueChange={handleLanguageChange}
placeholder={t('sftp.editor.syntaxHighlight')}
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
/>
{/* Save button */}
<Button
variant="default"
size="sm"
className="h-7"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<CloudUpload size={14} className="mr-1.5" />
)}
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
</Button>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleClose}
>
<X size={14} />
</Button>
</div>
</div>
</DialogHeader>
{/* Monaco Editor */}
<div className="flex-1 min-h-0 relative">
<Editor
height="100%"
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={monacoTheme}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}
options={{
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
roundedSelection: false,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'selection',
},
}}
/>
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{getLanguageName(languageId)}
</span>
<span>
{content.split('\n').length} lines {content.length} characters
</span>
</div>
</DialogContent>
</Dialog>
);
};
export default TextEditorModal;

View File

@@ -16,6 +16,7 @@ import {
TerminalSquare,
Trash2,
Upload,
Usb,
Zap,
} from "lucide-react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
@@ -33,6 +34,7 @@ import {
HostProtocol,
Identity,
KnownHost,
SerialConfig,
SSHKey,
ShellHistoryEntry,
Snippet,
@@ -46,7 +48,9 @@ import KeychainManager from "./KeychainManager";
import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInput } from "../domain/quickConnect";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { Button } from "./ui/button";
@@ -90,6 +94,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 +129,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onOpenSettings,
onOpenQuickSwitcher,
onCreateLocalTerminal,
onConnectSerial,
onDeleteHost,
onConnect,
onUpdateHosts,
@@ -156,6 +162,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [renameGroupName, setRenameGroupName] = useState("");
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
// Handle external navigation requests
useEffect(() => {
@@ -184,6 +191,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
port?: number;
} | null>(null);
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false);
const [quickConnectWarnings, setQuickConnectWarnings] = useState<string[]>([]);
// Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
@@ -198,9 +206,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Handle connect button click - detect quick connect or regular search
const handleConnectClick = useCallback(() => {
if (isSearchQuickConnect) {
const target = parseQuickConnectInput(search);
if (target) {
setQuickConnectTarget(target);
const parsed = parseQuickConnectInputWithWarnings(search);
if (parsed.target) {
setQuickConnectTarget(parsed.target);
setQuickConnectWarnings(parsed.warnings);
setIsQuickConnectOpen(true);
}
} else {
@@ -268,6 +277,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect(host);
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
setSearch("");
},
[onConnect],
@@ -957,6 +967,18 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
</Button>
<Button
size="sm"
variant="secondary"
className={cn(
"h-10 px-3 app-no-drag",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
)}
onClick={() => setIsSerialModalOpen(true)}
>
<Usb size={14} className="mr-2" /> {t("serial.button")}
</Button>
</div>
</header>
)}
@@ -1308,6 +1330,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
customGroups={customGroups}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -1339,7 +1362,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{currentSection === "hosts" && isHostPanelOpen && (
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
@@ -1352,6 +1375,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
onUpdateHosts(
editingHost
@@ -1373,6 +1397,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
)}
{/* Serial Host Details Panel - for editing serial port hosts */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
setIsHostPanelOpen(false);
setEditingHost(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
}}
/>
)}
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
setIsNewFolderOpen(open);
if (!open) {
@@ -1483,7 +1532,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClose={() => {
setIsQuickConnectOpen(false);
setQuickConnectTarget(null);
setQuickConnectWarnings([]);
}}
warnings={quickConnectWarnings}
/>
)}
@@ -1497,6 +1548,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
/>
</Suspense>
)}
{/* Serial Connect Modal */}
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config) => {
if (onConnectSerial) {
onConnectSerial(config);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsideP
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
export interface EditPanelProps {
rule: PortForwardingRule;
@@ -152,6 +153,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
</div>
</>
)}
{/* Auto Start Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
</div>
<Switch
checked={draft.autoStart ?? false}
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button

View File

@@ -13,6 +13,7 @@ import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils';
export interface NewFormPanelProps {
@@ -153,6 +154,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
</div>
</>
)}
{/* Auto Start Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
</div>
<Switch
checked={draft.autoStart ?? false}
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button

View File

@@ -0,0 +1,186 @@
/**
* Theme Select Modal
* A modal dialog for selecting terminal themes in settings
*/
import React, { memo, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
interface ThemeSelectModalProps {
open: boolean;
onClose: () => void;
selectedThemeId: string;
onSelect: (themeId: string) => void;
}
export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
open,
onClose,
selectedThemeId,
onSelect,
}) => {
const { t } = useI18n();
// Group themes by type
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
onClose();
}, [onSelect, onClose]);
// Handle ESC key
React.useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
// Handle backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
}, [onClose]);
if (!open) return null;
const modalTitleId = 'theme-select-modal-title';
const modalContent = (
<div
className="fixed inset-0 flex items-center justify-center bg-black/60"
style={{ zIndex: 99999 }}
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby={modalTitleId}
>
<div
className="w-[480px] max-h-[600px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
<Palette size={16} className="text-primary" />
</div>
<h2 id={modalTitleId} className="text-sm font-semibold text-foreground">{t('settings.terminal.themeModal.title')}</h2>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t('common.close')}
>
<X size={16} />
</button>
</div>
{/* Theme List */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end px-5 py-3 shrink-0 border-t border-border bg-muted/20">
<Button
variant="ghost"
onClick={onClose}
>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
);
// Use Portal to render at document root
return createPortal(modalContent, document.body);
};
export default ThemeSelectModal;

View File

@@ -0,0 +1,290 @@
/**
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
*/
import { FileType, Pencil, Trash2 } from "lucide-react";
import React, { useCallback, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { SectionHeader, SettingsTabContent } from "../settings-ui";
const getOpenerLabel = (
openerType: FileOpenerType,
systemApp: SystemAppInfo | undefined,
t: (key: string) => string
): string => {
if (openerType === 'builtin-editor') {
return t('sftp.opener.builtInEditor');
} else if (openerType === 'system-app' && systemApp) {
return systemApp.name;
}
return openerType;
};
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
// Debug log for Settings page
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return;
}
const result = await bridge.selectApplication();
if (result) {
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setEditingExtension(null);
}
}, [setOpenerForExtension]);
return (
<SettingsTabContent value="file-associations">
<div className="space-y-8">
{/* Double-click behavior section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDoubleClickBehavior('open')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'open'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDoubleClickBehavior === 'open'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'open' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.doubleClickBehavior.open')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.openDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDoubleClickBehavior('transfer')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'transfer'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDoubleClickBehavior === 'transfer'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'transfer' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.doubleClickBehavior.transfer')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.transferDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.desc')}
</p>
<button
onClick={() => setSftpAutoSync(!sftpAutoSync)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoSync
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoSync
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoSync && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoSync.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* Show hidden files section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.desc')}
</p>
<button
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpShowHiddenFiles
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpShowHiddenFiles
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpShowHiddenFiles && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.showHiddenFiles.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftpFileAssociations.desc')}
</p>
{associations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
</div>
) : (
<div className="border border-border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.extension')}
</th>
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.application')}
</th>
<th className="text-right px-4 py-2 font-medium w-28">
{/* Actions */}
</th>
</tr>
</thead>
<tbody>
{associations.map(({ extension, openerType, systemApp }) => (
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-3">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<span title={systemApp.path}>{systemApp.name}</span>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
title={t('common.edit')}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
title={t('settings.sftpFileAssociations.remove')}
>
<Trash2 size={14} />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,180 @@
/**
* Settings System Tab - System information and temp file management
*/
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
interface TempDirInfo {
path: string;
fileCount: number;
totalSize: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const SettingsSystemTab: React.FC = () => {
const { t } = useI18n();
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
setIsLoading(true);
try {
const info = await bridge.getTempDirInfo();
setTempDirInfo(info);
} catch (err) {
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTempDirInfo();
}, [loadTempDirInfo]);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
setIsClearing(true);
setClearResult(null);
try {
const result = await bridge.clearTempDir();
setClearResult(result);
// Refresh info after clearing
await loadTempDirInfo();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
} finally {
setIsClearing(false);
}
}, [loadTempDirInfo]);
const handleOpenTempDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
await bridge.openTempDir();
}, [tempDirInfo]);
return (
<TabsContent
value="system"
className="data-[state=inactive]:hidden h-full flex flex-col"
>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("settings.system.description")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
{/* Path */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
<p className="text-sm font-mono mt-1 break-all">
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
<span className="font-medium">
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
</span>
</div>
<div>
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
<span className="font-medium">
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadTempDirInfo}
disabled={isLoading}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearTempFiles}
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
</Button>
</div>
{/* Clear Result */}
{clearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.clearResult", {
deleted: clearResult.deletedCount,
failed: clearResult.failedCount,
})}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.tempDirectoryHint")}
</p>
</div>
</div>
</div>
</TabsContent>
);
};
export default SettingsSystemTab;

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Minus, Plus, RotateCcw } from "lucide-react";
import React, { useCallback, useEffect, useState, useMemo } from "react";
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
import type {
CursorShape,
LinkModifier,
@@ -9,65 +9,63 @@ import type {
} from "../../../domain/models";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TERMINAL_FONTS, MAX_FONT_SIZE, MIN_FONT_SIZE } from "../../../infrastructure/config/fonts";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
// Helper: render terminal preview
const renderTerminalPreview = (theme: (typeof TERMINAL_THEMES)[0]) => {
// Theme preview button component
const ThemePreviewButton: React.FC<{
theme: (typeof TERMINAL_THEMES)[0];
onClick: () => void;
buttonLabel: string;
}> = ({ theme, onClick, buttonLabel }) => {
const c = theme.colors;
const lines = [
{ prompt: "~", cmd: "ssh prod-server", color: c.foreground },
{ prompt: "prod", cmd: "ls -la", color: c.green },
{ prompt: "prod", cmd: "cat config.json", color: c.cyan },
];
return (
<div
className="font-mono text-[9px] leading-tight p-1.5 rounded overflow-hidden h-full"
style={{ backgroundColor: c.background, color: c.foreground }}
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all text-left",
)}
>
{lines.map((l, i) => (
<div key={i} className="flex gap-1 truncate">
<span style={{ color: c.blue }}>{l.prompt}</span>
<span style={{ color: c.magenta }}>$</span>
<span style={{ color: l.color }}>{l.cmd}</span>
{/* Theme preview swatch */}
<div
className="w-20 h-14 rounded-lg flex-shrink-0 flex flex-col justify-center items-start pl-2 gap-0.5 border border-border/50"
style={{ backgroundColor: c.background }}
>
<div className="flex gap-1 items-center">
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
<span className="font-mono text-[8px]" style={{ color: c.blue }}>ls</span>
</div>
<div className="flex gap-0.5">
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: c.cyan }} />
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: c.magenta }} />
</div>
<div className="flex gap-1 items-center">
<span className="font-mono text-[8px]" style={{ color: c.green }}>$</span>
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
</div>
))}
<div className="flex gap-1">
<span style={{ color: c.blue }}>~</span>
<span style={{ color: c.magenta }}>$</span>
<span className="inline-block w-1.5 h-2.5 animate-pulse" style={{ backgroundColor: c.cursor }} />
</div>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{theme.name}</div>
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
</div>
{/* Action button area */}
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs">{buttonLabel}</span>
<ChevronRight size={16} />
</div>
</button>
);
};
const TerminalThemeCard: React.FC<{
theme: (typeof TERMINAL_THEMES)[0];
active: boolean;
onClick: () => void;
}> = ({ theme, active, onClick }) => (
<button
onClick={onClick}
className={cn(
"relative flex flex-col rounded-lg border-2 transition-all overflow-hidden text-left",
active ? "border-primary ring-2 ring-primary/20" : "border-border hover:border-primary/50",
)}
>
<div className="h-16">{renderTerminalPreview(theme)}</div>
<div className="px-2 py-1.5 text-xs font-medium border-t bg-card">{theme.name}</div>
{active && (
<div className="absolute top-1 right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
<Check size={10} className="text-primary-foreground" />
</div>
)}
</button>
);
export default function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
@@ -80,6 +78,7 @@ export default function SettingsTerminalTab(props: {
key: K,
value: TerminalSettings[K],
) => void;
availableFonts: TerminalFont[];
}) {
const {
terminalThemeId,
@@ -90,9 +89,97 @@ export default function SettingsTerminalTab(props: {
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
availableFonts,
} = 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);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Get current selected theme
const currentTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
}, [terminalThemeId]);
// 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);
@@ -101,16 +188,18 @@ export default function SettingsTerminalTab(props: {
return (
<SettingsTabContent value="terminal">
<SectionHeader title={t("settings.terminal.section.theme")} />
<div className="grid grid-cols-2 gap-3">
{TERMINAL_THEMES.map((t) => (
<TerminalThemeCard
key={t.id}
theme={t}
active={terminalThemeId === t.id}
onClick={() => setTerminalThemeId(t.id)}
/>
))}
</div>
<ThemePreviewButton
theme={currentTheme}
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<ThemeSelectModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
selectedThemeId={terminalThemeId}
onSelect={setTerminalThemeId}
/>
<SectionHeader title={t("settings.terminal.section.font")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
@@ -120,7 +209,7 @@ export default function SettingsTerminalTab(props: {
>
<Select
value={terminalFontFamilyId}
options={TERMINAL_FONTS.map((f) => ({ value: f.id, label: f.name }))}
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
onChange={(id) => setTerminalFontFamilyId(id)}
className="w-40"
/>
@@ -443,6 +532,82 @@ 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>
<SectionHeader title={t("settings.terminal.section.connection")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.connection.keepaliveInterval")}
description={t("settings.terminal.connection.keepaliveInterval.desc")}
>
<Input
type="number"
min={0}
max={3600}
value={terminalSettings.keepaliveInterval}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
if (val >= 0 && val <= 3600) {
updateTerminalSetting("keepaliveInterval", val);
}
}}
className="w-24"
/>
</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 ? /[\\/]/ : /\//;
@@ -21,32 +31,83 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({ path, onNavigate,
// For Windows, first part might be drive letter like "C:"
const buildPath = (index: number) => {
if (isWindowsPath) {
return parts.slice(0, index + 1).join('\\');
const builtPath = parts.slice(0, index + 1).join('\\');
// If this is just a drive letter (e.g., "C:"), add trailing backslash
if (/^[A-Za-z]:$/.test(builtPath)) {
return builtPath + '\\';
}
return builtPath;
}
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

@@ -3,10 +3,10 @@
*/
import { AlertCircle } from 'lucide-react';
import React,{ useState } from 'react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface ConflictItem {
transferId: string;
@@ -25,7 +25,7 @@ interface SftpConflictDialogProps {
formatFileSize: (size: number) => string;
}
export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
const conflict = conflicts[0]; // Handle first conflict
@@ -135,3 +135,6 @@ export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflict
</Dialog>
);
};
export const SftpConflictDialog = memo(SftpConflictDialogInner);
SftpConflictDialog.displayName = 'SftpConflictDialog';

View File

@@ -0,0 +1,174 @@
/**
* SftpContext - Provides stable callback references to SFTP components
*
* This context eliminates props drilling of callback functions through
* the component tree, significantly reducing re-renders caused by
* callback reference changes.
*/
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry } from "../../types";
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onCreateFile: (name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onEditPermissions?: (file: SftpFileEntry) => void;
// File operations
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
}
export interface SftpDragCallbacks {
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
onDragEnd: () => void;
}
// Store for activeTabId - allows subscription without re-rendering parent
type ActiveTabStore = {
left: string | null;
right: string | null;
};
type ActiveTabListener = () => void;
let activeTabState: ActiveTabStore = { left: null, right: null };
const activeTabListeners = new Set<ActiveTabListener>();
export const activeTabStore = {
getSnapshot: () => activeTabState,
getLeftActiveTabId: () => activeTabState.left,
getRightActiveTabId: () => activeTabState.right,
setActiveTabId: (side: "left" | "right", tabId: string | null) => {
if (activeTabState[side] !== tabId) {
activeTabState = { ...activeTabState, [side]: tabId };
activeTabListeners.forEach((listener) => listener());
}
},
subscribe: (listener: ActiveTabListener) => {
activeTabListeners.add(listener);
return () => activeTabListeners.delete(listener);
},
};
// Hook to subscribe to active tab changes for a specific side
export const useActiveTabId = (side: "left" | "right"): string | null => {
return useSyncExternalStore(
activeTabStore.subscribe,
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
);
};
// Hook to check if a specific pane is active (for CSS control)
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
const activeTabId = useActiveTabId(side);
return activeTabId === paneId || (activeTabId === null && paneId !== null);
};
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Drag state (shared between panes)
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
if (!context) {
throw new Error("useSftpContext must be used within SftpContextProvider");
}
return context;
};
// Hook to get callbacks for a specific side
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
const context = useSftpContext();
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
// Hook to get drag-related values
export const useSftpDrag = () => {
const context = useSftpContext();
return {
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
};
};
// Hook to get hosts
export const useSftpHosts = () => {
const context = useSftpContext();
return context.hosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
return context.showHiddenFiles;
};
interface SftpContextProviderProps {
hosts: Host[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
showHiddenFiles: boolean;
children: React.ReactNode;
}
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
// Note: The callbacks objects should be stable (created with useMemo in parent)
const value = useMemo<SftpContextValue>(
() => ({
hosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
};

View File

@@ -2,28 +2,30 @@
* SFTP File row component for file list
*/
import { Folder } from 'lucide-react';
import React,{ memo } from 'react';
import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } 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;
index: number;
isSelected: boolean;
isDragOver: boolean;
columnWidths: ColumnWidths;
onSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
onDragStart: (e: React.DragEvent) => void;
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
onOpen: (entry: SftpFileEntry) => void;
onDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent) => void;
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
}
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
entry,
index,
isSelected,
isDragOver,
columnWidths,
@@ -36,43 +38,96 @@ 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';
const modifiedLabel = entry.lastModifiedFormatted || formatDate(entry.lastModified);
const sizeLabel = entry.sizeFormatted || formatBytes(entry.size);
const handleSelect = useCallback((e: React.MouseEvent) => {
onSelect(entry, index, e);
}, [entry, index, onSelect]);
const handleOpen = useCallback(() => {
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
onOpen(entry);
}, [entry, onOpen]);
const handleDragStart = useCallback((e: React.DragEvent) => {
onDragStart(entry, e);
}, [entry, onDragStart]);
const handleDragOver = useCallback((e: React.DragEvent) => {
onDragOver(entry, e);
}, [entry, onDragOver]);
const handleDrop = useCallback((e: React.DragEvent) => {
onDrop(entry, e);
}, [entry, onDrop]);
return (
<div
data-sftp-row="true"
draggable={!isParentDir}
onDragStart={onDragStart}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragOver={handleDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onSelect}
onDoubleClick={onOpen}
onDrop={handleDrop}
onClick={handleSelect}
onDoubleClick={handleOpen}
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 pr-1")}>
{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">{modifiedLabel}</span>
<span className="text-xs text-muted-foreground truncate text-right">
{entry.type === 'directory' ? '--' : formatBytes(entry.size)}
{isNavDir ? '--' : sizeLabel}
</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>
);
};
export const SftpFileRow = memo(SftpFileRowInner);
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.index !== next.index) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.columnWidths.name !== next.columnWidths.name) return false;
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
if (prev.columnWidths.size !== next.columnWidths.size) return false;
if (prev.columnWidths.type !== next.columnWidths.type) return false;
// Compare callbacks - important for ".." entry which has static properties
if (prev.onOpen !== next.onOpen) return false;
if (prev.onSelect !== next.onSelect) return false;
const prevEntry = prev.entry;
const nextEntry = next.entry;
return (
prevEntry.name === nextEntry.name &&
prevEntry.type === nextEntry.type &&
prevEntry.size === nextEntry.size &&
prevEntry.lastModified === nextEntry.lastModified &&
prevEntry.linkTarget === nextEntry.linkTarget &&
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted
);
};
export const SftpFileRow = memo(SftpFileRowInner, areEqual);
SftpFileRow.displayName = 'SftpFileRow';

View File

@@ -3,7 +3,7 @@
*/
import { Monitor, Search } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
@@ -22,7 +22,7 @@ interface SftpHostPickerProps {
onSelectHost: (host: Host) => void;
}
export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
open,
onOpenChange,
hosts,
@@ -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 {
@@ -175,3 +178,6 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
</Dialog>
);
};
export const SftpHostPicker = memo(SftpHostPickerInner);
SftpHostPicker.displayName = 'SftpHostPicker';

View File

@@ -2,11 +2,11 @@
* SFTP Permissions Editor Dialog
*/
import React,{ useEffect,useState } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SftpFileEntry } from '../../types';
import { Button } from '../ui/button';
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface SftpPermissionsDialogProps {
open: boolean;
@@ -15,7 +15,7 @@ interface SftpPermissionsDialogProps {
onSave: (file: SftpFileEntry, permissions: string) => void;
}
export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
const SftpPermissionsDialogInner: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
const { t } = useI18n();
const [permissions, setPermissions] = useState({
owner: { read: false, write: false, execute: false },
@@ -24,10 +24,38 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
});
// Parse permissions from file
// Supports both symbolic format (rwxr-xr-x) and octal format (755)
useEffect(() => {
if (file?.permissions) {
const perms = file.permissions;
// Parse rwxrwxrwx format (skip first char for type)
// Check if it's octal format (e.g., "755", "644")
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
setPermissions({
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
});
return;
}
// Parse symbolic rwxrwxrwx format (skip first char for type)
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
setPermissions({
@@ -139,3 +167,6 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
</Dialog>
);
};
export const SftpPermissionsDialog = memo(SftpPermissionsDialogInner);
SftpPermissionsDialog.displayName = 'SftpPermissionsDialog';

View File

@@ -0,0 +1,420 @@
/**
* SFTP Tab Bar Component
*
* A tab bar for managing multiple SFTP connections in a single pane.
* Features:
* - Tab items with close button
* - Add button (+) to open HostSelectModal
* - Scrollable when many tabs are open
* - Drag-and-drop reordering of tabs
*/
import { HardDrive, Monitor, Plus, X } from "lucide-react";
import React, {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
export interface SftpTab {
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
}
interface SftpTabBarProps {
tabs: SftpTab[];
side: "left" | "right";
onSelectTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onAddTab: () => void;
onReorderTabs: (
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
/** Called when a tab is dragged to the other side */
onMoveTabToOtherSide?: (tabId: string) => void;
}
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
tabs,
side,
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
}) => {
// Subscribe to activeTabId from store (isolated subscription)
const activeTabId = useActiveTabId(side);
// 渲染追踪 - 追踪所有 props 包括回调函数
useRenderTracker(`SftpTabBar[${side}]`, {
side,
tabsCount: tabs.length,
activeTabId,
// 追踪回调函数引用是否变化
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
});
const { t } = useI18n();
// Refs for scrollable tab container
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Drag state
const [dropIndicator, setDropIndicator] = useState<{
tabId: string;
position: "before" | "after";
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCrossPaneDragOver, setIsCrossPaneDragOver] = useState(false);
const draggedTabIdRef = useRef<string | null>(null);
// Global dragend listener to ensure state is reset even if the dragged element is removed
useEffect(() => {
const handleGlobalDragEnd = () => {
if (draggedTabIdRef.current) {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
setIsCrossPaneDragOver(false);
}
};
document.addEventListener("dragend", handleGlobalDragEnd);
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
}, []);
// Check scroll state
const updateScrollState = useCallback(() => {
const container = tabsContainerRef.current;
if (container) {
setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(
container.scrollLeft < container.scrollWidth - container.clientWidth - 1,
);
}
}, []);
// Update scroll state on mount and resize
useEffect(() => {
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
container.addEventListener("scroll", updateScrollState);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
};
}
}, [updateScrollState, tabs]);
// Scroll to active tab when it changes
useLayoutEffect(() => {
if (!activeTabId) return;
const container = tabsContainerRef.current;
if (!container) return;
const activeTabElement = container.querySelector(
`[data-tab-id="${activeTabId}"]`,
) as HTMLElement | null;
if (activeTabElement) {
const containerRect = container.getBoundingClientRect();
const tabRect = activeTabElement.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
container.scrollLeft -= containerRect.left - tabRect.left + 8;
} else if (tabRect.right > containerRect.right) {
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
setTimeout(updateScrollState, 100);
}, [activeTabId, updateScrollState]);
// Drag handlers
const handleTabDragStart = useCallback(
(e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("sftp-tab-id", tabId);
e.dataTransfer.setData("sftp-tab-side", side);
draggedTabIdRef.current = tabId;
setTimeout(() => {
setIsDragging(true);
}, 0);
},
[side],
);
const handleTabDragEnd = useCallback(() => {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
}, []);
const handleTabDragOver = useCallback(
(e: React.DragEvent, tabId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const position: "before" | "after" =
e.clientX < midpoint ? "before" : "after";
setDropIndicator({ tabId, position });
},
[],
);
const handleTabDrop = useCallback(
(e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
const draggedId =
e.dataTransfer.getData("sftp-tab-id") || draggedTabIdRef.current;
if (draggedId && draggedId !== targetTabId && dropIndicator) {
onReorderTabs(draggedId, targetTabId, dropIndicator.position);
}
setDropIndicator(null);
setIsDragging(false);
},
[dropIndicator, onReorderTabs],
);
const handleCloseTab = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onCloseTab(tabId);
},
[onCloseTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
const draggedFromSide = e.dataTransfer.types.includes("sftp-tab-side");
if (!draggedFromSide) return;
// Check if this is from the other side (we can't read the data during dragover due to browser security)
// We'll set the indicator and validate on drop
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsCrossPaneDragOver(true);
},
[],
);
const handleCrossPaneDragLeave = useCallback(() => {
setIsCrossPaneDragOver(false);
}, []);
const handleCrossPaneDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsCrossPaneDragOver(false);
const draggedId = e.dataTransfer.getData("sftp-tab-id");
const draggedFromSide = e.dataTransfer.getData("sftp-tab-side");
// Only accept drops from the other side
if (draggedId && draggedFromSide && draggedFromSide !== side && onMoveTabToOtherSide) {
logger.info("[SftpTabBar] Cross-pane drop", {
tabId: draggedId,
fromSide: draggedFromSide,
toSide: side,
});
onMoveTabToOtherSide(draggedId);
}
// Always reset drag state on drop
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
},
[side, onMoveTabToOtherSide],
);
return (
<div
className={cn(
"flex items-stretch h-8 bg-secondary/30 border-b border-border/40 transition-colors",
isCrossPaneDragOver && "bg-primary/10 ring-1 ring-inset ring-primary/40",
)}
onDragOver={handleCrossPaneDragOver}
onDragLeave={handleCrossPaneDragLeave}
onDrop={handleCrossPaneDrop}
>
{/* Scrollable tabs container */}
<div className="relative flex-1 min-w-0 flex">
{/* Left fade mask */}
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
<div
ref={tabsContainerRef}
className="flex items-stretch overflow-x-auto scrollbar-none max-w-full"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const isBeingDragged =
isDragging && draggedTabIdRef.current === tab.id;
const showDropIndicatorBefore =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "before";
const showDropIndicatorAfter =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "after";
return (
<div
key={tab.id}
data-tab-id={tab.id}
onClick={() => onSelectTab(tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
<span className="truncate">{tab.label}</span>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
);
})}
</div>
{/* Right fade mask */}
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
</div>
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={onAddTab}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
</button>
</div>
);
};
// Custom comparison - only re-render when data props change, ignore callback refs
// Note: activeTabId is now subscribed internally, not passed as prop
const sftpTabBarAreEqual = (
prev: SftpTabBarProps,
next: SftpTabBarProps,
): boolean => {
// Compare data props only
if (prev.side !== next.side) return false;
if (prev.tabs.length !== next.tabs.length) return false;
// Deep compare tabs array
for (let i = 0; i < prev.tabs.length; i++) {
const prevTab = prev.tabs[i];
const nextTab = next.tabs[i];
if (
prevTab.id !== nextTab.id ||
prevTab.label !== nextTab.label ||
prevTab.isLocal !== nextTab.isLocal ||
prevTab.hostId !== nextTab.hostId
) {
return false;
}
}
// Ignore callback function refs - they may change but behavior is stable
return true;
};
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -7,14 +7,31 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,type ColumnWidths,type SortField,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
type SortOrder
} from './utils';
// Context
export {
SftpContextProvider,
useSftpContext,
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,
type SftpPaneCallbacks,
type SftpDragCallbacks,
type SftpContextValue,
} from './SftpContext';
// Components
export { SftpBreadcrumb } from './SftpBreadcrumb';
export { SftpConflictDialog } from './SftpConflictDialog';
export { SftpFileRow } from './SftpFileRow';
export { SftpHostPicker } from './SftpHostPicker';
export { SftpPermissionsDialog } from './SftpPermissionsDialog';
export { SftpTabBar, type SftpTab } from './SftpTabBar';
export { SftpTransferItem } from './SftpTransferItem';

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,41 @@ 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');
};
/**
* Check if a file is hidden on Windows
* Only applies to local Windows filesystem where the hidden attribute is set
* The ".." parent directory entry is never considered hidden
*
* Note: On Unix/Linux, there's no system-level hidden file concept.
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
};
/**
* Filter files based on Windows hidden file visibility setting
* Only filters files with the Windows hidden attribute set
* Always preserves ".." parent directory entry
*
* This setting only affects local Windows filesystem browsing.
* On Unix/Linux systems and remote SFTP connections, all files are shown
* because there's no system-level hidden file concept (dotfiles are just a convention).
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
};

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

@@ -2,7 +2,7 @@
* Terminal Theme Customize Modal
* Left-right split design: list on left, large preview on right
* Uses React Portal to render at document root for proper z-index
*
*
* Features:
* - Real-time preview: changes are applied immediately to the terminal
* - Save: persists the current settings
@@ -13,8 +13,9 @@ import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from '
import { createPortal } from 'react-dom';
import { Check, Minus, Palette, Plus, Type, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
@@ -265,6 +266,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
onSave,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
@@ -294,8 +296,8 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
const currentFont = useMemo(
() => TERMINAL_FONTS.find(f => f.id === selectedFont) || TERMINAL_FONTS[0],
[selectedFont]
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
[selectedFont, availableFonts]
);
const currentTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
@@ -430,7 +432,7 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
)}
{activeTab === 'font' && (
<div className="space-y-1">
{TERMINAL_FONTS.map(font => (
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}

View File

@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -32,7 +33,7 @@ export const useTerminalContextActions = ({
if (!term) return;
try {
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}

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?.();
@@ -329,6 +343,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: termEnv,
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
});
};
@@ -521,10 +536,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 +605,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

@@ -10,13 +10,14 @@ import {
getAppLevelActions,
getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { TERMINAL_FONTS } from "../../../infrastructure/config/fonts";
import { fontStore } from "../../../application/state/fontStore";
import {
XTERM_PERFORMANCE_CONFIG,
type XTermPlatform,
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -38,6 +39,8 @@ export type XTermRuntime = {
serializeAddon: SerializeAddon;
searchAddon: SearchAddon;
dispose: () => void;
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
};
export type CreateXTermRuntimeContext = {
@@ -71,6 +74,14 @@ export type CreateXTermRuntimeContext = {
) => void;
commandBufferRef: RefObject<string>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
};
const detectPlatform = (): XTermPlatform => {
@@ -96,7 +107,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const platform = detectPlatform();
const deviceMemoryGb =
typeof navigator !== "undefined" &&
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
typeof (navigator as { deviceMemory?: number }).deviceMemory === "number"
? (navigator as { deviceMemory?: number }).deviceMemory
: undefined;
@@ -106,7 +117,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
const fontObj = TERMINAL_FONTS.find((f) => f.id === hostFontId) || TERMINAL_FONTS[0];
// Use fontStore for font lookup - guarantees non-empty result
const fontObj = fontStore.getFontById(hostFontId);
const fontFamily = fontObj.family;
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
@@ -347,7 +359,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "paste": {
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, text);
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
});
break;
}
@@ -379,7 +391,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, text);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);
@@ -397,7 +409,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);
@@ -423,6 +492,36 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
});
// Track current working directory via OSC 7 escape sequences
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
term.parser.registerOscHandler(7, (data) => {
try {
// data is the content after "7;" - typically "file://hostname/path"
if (data.startsWith('file://')) {
// Extract path from file:// URL
const url = new URL(data);
const path = decodeURIComponent(url.pathname);
if (path && path.length > 0) {
currentCwd = path;
ctx.onCwdChange?.(path);
logger.debug('[XTerm] OSC 7 CWD update:', path);
}
} else if (data.startsWith('/')) {
// Some shells send just the path without file:// prefix
currentCwd = data;
ctx.onCwdChange?.(data);
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
}
} catch (err) {
logger.warn('[XTerm] Failed to parse OSC 7:', err);
}
return true; // Indicate we handled the sequence
});
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
@@ -468,5 +567,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
logger.warn("[XTerm] webglAddon dispose failed", err);
}
},
get currentCwd() {
return currentCwd;
},
};
};

View File

@@ -115,7 +115,7 @@ export function Combobox({
<PopoverTrigger asChild disabled={disabled}>
<div
className={cn(
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm",
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
"hover:bg-secondary/50 transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName
@@ -129,7 +129,7 @@ export function Combobox({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
className="flex-1 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
disabled={disabled}
/>
{inputValue && (

View File

@@ -30,8 +30,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, hideCloseButton, ...props }, ref) => {
const { t } = useI18n()
return (
@@ -47,10 +47,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)

View File

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

View File

@@ -47,7 +47,7 @@ const OPTIONS: ImportOption[] = [
format: "ssh_config",
label: "ssh_config",
iconSrc: "/import/file.png",
accept: ".conf,.config,.txt",
accept: "*",
},
];

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;
@@ -73,6 +88,8 @@ export interface Host {
telnetEnabled?: boolean; // Is Telnet enabled for this host
telnetUsername?: string; // Telnet-specific username
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -345,6 +362,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 +376,13 @@ 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)
// SSH Connection
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
}
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
@@ -381,6 +408,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 +418,9 @@ 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
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
};
export interface TerminalTheme {
@@ -428,16 +461,21 @@ 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
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export type WorkspaceNode =
@@ -476,6 +514,8 @@ export interface SftpFileEntry {
permissions?: string;
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export interface SftpConnection {
@@ -540,6 +580,8 @@ export interface PortForwardingRule {
remotePort?: number;
// Host to tunnel through
hostId?: string;
// Auto-start: if true, this rule will automatically start when the app launches
autoStart?: boolean;
// Runtime state
status: PortForwardingStatus;
error?: string;
@@ -577,7 +619,7 @@ export interface ConnectionLog {
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
hostname: string; // Target hostname or 'localhost'
username: string; // SSH username or system username
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user

View File

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

View File

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

View File

@@ -1,6 +1,82 @@
import { Host, HostProtocol } from "./models";
import { Host, HostChainConfig, HostProtocol } from "./models";
import { parseQuickConnectInput } from "./quickConnect";
interface ParsedJumpHost {
hostname: string;
username?: string;
port?: number;
}
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
const trimmed = spec.trim();
if (!trimmed || trimmed.toLowerCase() === "none") return null;
if (trimmed.startsWith("ssh://")) {
try {
const url = new URL(trimmed);
return {
hostname: url.hostname,
username: url.username || undefined,
port: url.port ? parseInt(url.port, 10) : undefined,
};
} catch {
return null;
}
}
let username: string | undefined;
let hostname: string;
let port: number | undefined;
let rest = trimmed;
const atIndex = rest.indexOf("@");
if (atIndex !== -1) {
username = rest.slice(0, atIndex);
rest = rest.slice(atIndex + 1);
}
if (rest.startsWith("[")) {
const bracketEnd = rest.indexOf("]");
if (bracketEnd !== -1) {
hostname = rest.slice(1, bracketEnd);
const portPart = rest.slice(bracketEnd + 1);
if (portPart.startsWith(":")) {
const p = parseInt(portPart.slice(1), 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
}
} else {
hostname = rest;
}
} else {
const colonIndex = rest.lastIndexOf(":");
if (colonIndex !== -1) {
const portStr = rest.slice(colonIndex + 1);
const p = parseInt(portStr, 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
port = p;
hostname = rest.slice(0, colonIndex);
} else {
hostname = rest;
}
} else {
hostname = rest;
}
}
if (!hostname) return null;
return { hostname, username, port };
};
const parseProxyJump = (value: string): ParsedJumpHost[] => {
if (!value || value.toLowerCase() === "none") return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseJumpHostSpec)
.filter((h): h is ParsedJumpHost => h !== null);
};
export type VaultImportFormat =
| "putty"
| "mobaxterm"
@@ -442,6 +518,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
hostname?: string;
username?: string;
port?: number;
proxyJump?: string;
};
const blocks: Block[] = [];
@@ -479,16 +556,23 @@ const importFromSshConfig = (text: string): VaultImportResult => {
if (keyword === "hostname") current.hostname = value;
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
}
flush();
const parsedHosts: Host[] = [];
// Use hostname+port as key instead of host.id to survive deduplication
const hostProxyJumpMap = new Map<string, string>();
let parsed = 0;
let skipped = 0;
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
// Helper to create a stable key for ProxyJump mapping
const makeHostKey = (hostname: string, port?: number) =>
`${hostname.toLowerCase()}:${port ?? 22}`;
for (const block of blocks) {
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
if (patterns.length === 0) continue;
@@ -505,24 +589,146 @@ const importFromSshConfig = (text: string): VaultImportResult => {
continue;
}
parsedHosts.push(
createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
}),
);
const host = createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
});
parsedHosts.push(host);
// Store ProxyJump using hostname key (survives deduplication)
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
const hostKey = makeHostKey(hostname, block.port);
hostProxyJumpMap.set(hostKey, block.proxyJump);
}
}
}
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hostnameToId = new Map<string, string>();
const labelToId = new Map<string, string>();
for (const host of dedupedHosts) {
hostnameToId.set(host.hostname.toLowerCase(), host.id);
labelToId.set(host.label.toLowerCase(), host.id);
}
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
const hostnameKey = jumpHost.hostname.toLowerCase();
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
return null;
};
// Collect inline hosts separately to avoid modifying array during iteration
const inlineHosts: Host[] = [];
// Process ProxyJump for each host (iterate over a copy to avoid issues)
const hostsToProcess = [...dedupedHosts];
for (const host of hostsToProcess) {
const hostKey = makeHostKey(host.hostname, host.port);
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
if (!proxyJumpValue) continue;
const jumpHosts = parseProxyJump(proxyJumpValue);
if (jumpHosts.length === 0) continue;
const resolvedIds: string[] = [];
const unresolvedJumps: string[] = [];
for (const jumpHost of jumpHosts) {
const existingId = resolveJumpHostToId(jumpHost);
if (existingId) {
// Avoid duplicate IDs in the chain
if (!resolvedIds.includes(existingId)) {
resolvedIds.push(existingId);
}
} else {
// Check if we already created an inline host for this
const inlineKey = jumpHost.hostname.toLowerCase();
let inlineId = hostnameToId.get(inlineKey);
if (!inlineId) {
const inlineHost = createHost({
label: jumpHost.hostname,
hostname: jumpHost.hostname,
username: jumpHost.username,
port: jumpHost.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
inlineId = inlineHost.id;
unresolvedJumps.push(jumpHost.hostname);
}
if (!resolvedIds.includes(inlineId)) {
resolvedIds.push(inlineId);
}
}
}
if (resolvedIds.length > 0) {
// Cycle detection: check if this host appears in its own chain
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
continue;
}
const hostChain: HostChainConfig = { hostIds: resolvedIds };
host.hostChain = hostChain;
}
if (unresolvedJumps.length > 0) {
issues.push({
level: "warning",
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
});
}
}
// Add inline hosts to the final result
const allHosts = [...dedupedHosts, ...inlineHosts];
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const host = allHosts.find(h => h.id === hostId);
if (host?.hostChain?.hostIds) {
for (const chainId of host.hostChain.hostIds) {
if (detectCycle(chainId, visited)) return true;
}
}
visited.delete(hostId);
return false;
};
// Remove chains that form cycles
for (const host of allHosts) {
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
if (detectCycle(host.id, new Set())) {
issues.push({
level: "warning",
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
}
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
return {
hosts,
hosts: allHosts,
groups: [],
issues,
stats: { parsed, imported: hosts.length, skipped, duplicates },
stats: { parsed, imported: allHosts.length, skipped, duplicates },
};
};

110
electron-builder.config.cjs Normal file
View File

@@ -0,0 +1,110 @@
/* global __dirname */
const path = require('path');
/**
* @type {import('electron-builder').Configuration}
*/
module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
directories: {
buildResources: 'build',
output: 'release'
},
files: [
'dist/**/*',
'electron/**/*',
'!electron/.dev-config.json',
'public/**/*',
'node_modules/**/*'
],
asarUnpack: [
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*'
],
mac: {
target: [
{
target: 'dmg',
arch: ['arm64', 'x64']
},
{
target: 'zip',
arch: ['arm64', 'x64']
}
],
category: 'public.app-category.developer-tools',
hardenedRuntime: false,
gatekeeperAssess: false,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',
extendInfo: {
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
}
},
dmg: {
title: '${productName}',
background: 'public/dmg-background.jpg',
iconSize: 100,
iconTextSize: 12,
window: {
width: 672,
height: 500
},
contents: [
{ x: 150, y: 158 },
{ x: 550, y: 158, type: 'link', path: '/Applications' },
{
x: 350,
y: 330,
type: 'file',
// Use absolute path resolved at build time
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
name: '已损坏修复.app'
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
},
{
target: 'dir',
arch: ['x64']
}
]
},
nsis: {
oneClick: false,
perMachine: false,
allowElevation: true,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Netcatty'
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64', 'arm64']
},
{
target: 'deb',
arch: ['x64', 'arm64']
},
{
target: 'rpm',
arch: ['x64', 'arm64']
}
],
category: 'Development'
}
};

View File

@@ -1,83 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.netcatty.app",
"productName": "Netcatty",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "public/icon.png",
"directories": {
"buildResources": "build",
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"!electron/.dev-config.json",
"public/**/*",
"node_modules/**/*"
],
"asarUnpack": [
"node_modules/node-pty/**/*",
"node_modules/ssh2/**/*",
"node_modules/cpu-features/**/*"
],
"mac": {
"target": [
{
"target": "dmg",
"arch": ["arm64", "x64"]
},
{
"target": "zip",
"arch": ["arm64", "x64"]
}
],
"category": "public.app-category.developer-tools",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"extendInfo": {
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
}
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
},
{
"target": "dir",
"arch": ["x64"]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Netcatty"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "deb",
"arch": ["x64"]
},
{
"target": "dir",
"arch": ["x64"]
}
],
"category": "Development"
}
}

View File

@@ -0,0 +1,379 @@
/**
* File Watcher Bridge - Watches local temp files for changes to sync back to remote
*
* This bridge enables auto-sync functionality for files opened with external applications.
* When a file is downloaded to temp and opened with an external app, we watch for changes
* and automatically upload them back to the remote server.
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
const activeWatchers = new Map();
// Debounce map to prevent multiple rapid syncs
const debounceTimers = new Map();
// Map of sftpId -> Set<localPath> to track temp files even without watching
// This allows cleanup when SFTP session closes, regardless of auto-sync setting
const tempFilesMap = new Map();
let sftpClients = null;
let electronModule = null;
/**
* Initialize the file watcher bridge with dependencies
*/
function init(deps) {
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
}
/**
* Register a temp file for cleanup when SFTP session closes
* Called regardless of whether auto-sync is enabled
*/
function registerTempFile(sftpId, localPath) {
if (!tempFilesMap.has(sftpId)) {
tempFilesMap.set(sftpId, new Set());
}
tempFilesMap.get(sftpId).add(localPath);
console.log(`[FileWatcher] Registered temp file for cleanup: ${localPath} (session: ${sftpId})`);
}
/**
* Show a system notification for file sync events
* Works on macOS, Windows, and Linux
*/
function showSystemNotification(title, body) {
try {
if (!electronModule?.Notification) {
console.warn("[FileWatcher] Electron Notification API not available");
return;
}
const { Notification } = electronModule;
// Check if notifications are supported
if (!Notification.isSupported()) {
console.warn("[FileWatcher] System notifications not supported on this platform");
return;
}
const notification = new Notification({
title,
body,
silent: false, // Allow notification sound
});
notification.show();
} catch (err) {
console.warn("[FileWatcher] Failed to show system notification:", err.message);
}
}
/**
* Start watching a local file for changes
* Returns a watchId that can be used to stop watching
*/
async function startWatching(event, { localPath, remotePath, sftpId }) {
const watchId = `watch-${crypto.randomUUID()}`;
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
// Get initial file stats
let lastModified;
let lastSize;
try {
const stat = await fs.promises.stat(localPath);
lastModified = stat.mtimeMs;
lastSize = stat.size;
console.log(`[FileWatcher] Initial file stats: mtime=${lastModified}, size=${lastSize}`);
} catch (err) {
console.error(`[FileWatcher] Failed to stat file ${localPath}:`, err.message);
throw new Error(`Cannot watch file: ${err.message}`);
}
// Store webContents reference for later notifications
const webContents = event.sender;
// Use fs.watchFile (polling) instead of fs.watch for better reliability on Windows
// fs.watch can miss events when editors use atomic writes (save to temp, then rename)
// fs.watchFile polls the file system at regular intervals
const pollInterval = 1000; // Check every 1 second
fs.watchFile(localPath, { persistent: true, interval: pollInterval }, async (curr, prev) => {
console.log(`[FileWatcher] File stat change detected for ${localPath}`);
console.log(`[FileWatcher] Previous: mtime=${prev.mtimeMs}, size=${prev.size}`);
console.log(`[FileWatcher] Current: mtime=${curr.mtimeMs}, size=${curr.size}`);
// Check if file was deleted
if (curr.nlink === 0) {
console.log(`[FileWatcher] File ${localPath} was deleted, stopping watch`);
stopWatching(null, { watchId });
return;
}
// Check if file was actually modified
if (curr.mtimeMs <= prev.mtimeMs && curr.size === prev.size) {
console.log(`[FileWatcher] File unchanged, skipping`);
return;
}
// Debounce rapid changes (e.g., multiple saves in quick succession)
const existingTimer = debounceTimers.get(watchId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
debounceTimers.delete(watchId);
await handleFileChange(watchId, webContents);
}, 500); // 500ms debounce
debounceTimers.set(watchId, timer);
});
activeWatchers.set(watchId, {
watcher: null, // fs.watchFile doesn't return a watcher object
localPath,
remotePath,
sftpId,
lastModified,
lastSize,
webContents,
useWatchFile: true, // Flag to indicate we're using fs.watchFile
});
console.log(`[FileWatcher] Watch started with ID: ${watchId} (using fs.watchFile polling every ${pollInterval}ms)`);
return { watchId };
}
/**
* Handle file change event - sync to remote
*/
async function handleFileChange(watchId, webContents) {
const watchInfo = activeWatchers.get(watchId);
if (!watchInfo) return;
const { localPath, remotePath, sftpId, lastModified: previousModified, lastSize: previousSize } = watchInfo;
// Extract file name once for notifications and logging
const fileName = path.basename(remotePath);
console.log(`[FileWatcher] File change detected: ${localPath}`);
try {
// Check if file was actually modified (compare mtime and size)
const stat = await fs.promises.stat(localPath);
// Skip if neither mtime nor size changed (prevents spurious events on some platforms)
if (stat.mtimeMs <= previousModified && stat.size === previousSize) {
console.log(`[FileWatcher] File unchanged (mtime and size same), skipping sync`);
return;
}
// Update lastModified and lastSize
watchInfo.lastModified = stat.mtimeMs;
watchInfo.lastSize = stat.size;
// Get the SFTP client
if (!sftpClients) {
throw new Error("SFTP clients not initialized");
}
const client = sftpClients.get(sftpId);
if (!client) {
throw new Error("SFTP session not found or expired");
}
// Read the local file
const content = await fs.promises.readFile(localPath);
console.log(`[FileWatcher] Syncing ${content.length} bytes to ${remotePath}`);
// Upload to remote
await client.put(content, remotePath);
console.log(`[FileWatcher] Sync complete: ${remotePath}`);
// Show system notification for successful sync
showSystemNotification(
"Netcatty",
`File synced to remote: ${fileName}`
);
// Notify the renderer about successful sync
if (webContents && !webContents.isDestroyed()) {
webContents.send("netcatty:filewatch:synced", {
watchId,
localPath,
remotePath,
bytesWritten: content.length,
});
}
} catch (err) {
console.error(`[FileWatcher] Sync failed for ${localPath}:`, err.message);
// Show system notification for sync failure
showSystemNotification(
"Netcatty",
`Failed to sync ${fileName}: ${err.message}`
);
// Notify the renderer about sync failure
if (webContents && !webContents.isDestroyed()) {
webContents.send("netcatty:filewatch:error", {
watchId,
localPath,
remotePath,
error: err.message,
});
}
}
}
/**
* Stop watching a file and optionally clean up the temp file
*/
function stopWatching(event, { watchId, cleanupTempFile = false }) {
const watchInfo = activeWatchers.get(watchId);
if (!watchInfo) {
console.log(`[FileWatcher] Watch ID not found: ${watchId}`);
return { success: false };
}
console.log(`[FileWatcher] Stopping watch: ${watchInfo.localPath}`);
// Clear debounce timer if any
const timer = debounceTimers.get(watchId);
if (timer) {
clearTimeout(timer);
debounceTimers.delete(watchId);
}
// Stop the watcher
try {
if (watchInfo.useWatchFile) {
// Using fs.watchFile - need to use fs.unwatchFile
fs.unwatchFile(watchInfo.localPath);
} else if (watchInfo.watcher) {
// Using fs.watch - close the watcher
watchInfo.watcher.close();
}
} catch (err) {
console.warn(`[FileWatcher] Error stopping watcher:`, err.message);
}
// Clean up temp file if requested
if (cleanupTempFile && watchInfo.localPath) {
cleanupTempFileAsync(watchInfo.localPath);
}
activeWatchers.delete(watchId);
return { success: true };
}
/**
* Asynchronously delete a temp file, logging success and silently handling failures
*/
async function cleanupTempFileAsync(filePath) {
try {
await fs.promises.unlink(filePath);
console.log(`[FileWatcher] Temp file cleaned up: ${filePath}`);
} catch (err) {
// Silently ignore deletion failures (file may be in use or already deleted)
console.log(`[FileWatcher] Could not delete temp file (may be in use): ${filePath}`);
}
}
/**
* Stop all watchers for a specific SFTP session and clean up temp files
* Called when SFTP connection is closed
*/
function stopWatchersForSession(sftpId, cleanupTempFiles = true) {
let watcherCount = 0;
// Stop active watchers
for (const [watchId, watchInfo] of activeWatchers.entries()) {
if (watchInfo.sftpId === sftpId) {
stopWatching(null, { watchId, cleanupTempFile: cleanupTempFiles });
watcherCount++;
}
}
if (watcherCount > 0) {
console.log(`[FileWatcher] Stopped ${watcherCount} watcher(s) for SFTP session: ${sftpId}`);
}
// Clean up any registered temp files that weren't being watched
if (cleanupTempFiles && tempFilesMap.has(sftpId)) {
const tempFiles = tempFilesMap.get(sftpId);
let cleanedCount = 0;
for (const filePath of tempFiles) {
cleanupTempFileAsync(filePath);
cleanedCount++;
}
tempFilesMap.delete(sftpId);
if (cleanedCount > 0) {
console.log(`[FileWatcher] Queued cleanup for ${cleanedCount} temp file(s) for SFTP session: ${sftpId}`);
}
}
}
/**
* Get list of active watchers
*/
function listWatchers() {
const watchers = [];
for (const [watchId, info] of activeWatchers.entries()) {
watchers.push({
watchId,
localPath: info.localPath,
remotePath: info.remotePath,
sftpId: info.sftpId,
});
}
return watchers;
}
/**
* Register IPC handlers for file watching operations
*/
function registerHandlers(ipcMain) {
console.log("[FileWatcher] Registering IPC handlers");
ipcMain.handle("netcatty:filewatch:start", (event, args) => {
console.log("[FileWatcher] IPC netcatty:filewatch:start received", args);
return startWatching(event, args);
});
ipcMain.handle("netcatty:filewatch:stop", stopWatching);
ipcMain.handle("netcatty:filewatch:list", listWatchers);
ipcMain.handle("netcatty:filewatch:registerTempFile", (_event, { sftpId, localPath }) => {
registerTempFile(sftpId, localPath);
return { success: true };
});
}
/**
* Cleanup all watchers on shutdown
*/
function cleanup() {
console.log(`[FileWatcher] Cleaning up ${activeWatchers.size} watcher(s)`);
for (const [watchId] of activeWatchers.entries()) {
stopWatching(null, { watchId });
}
}
module.exports = {
init,
registerHandlers,
startWatching,
stopWatching,
stopWatchersForSession,
listWatchers,
registerTempFile,
cleanup,
};

View File

@@ -0,0 +1,104 @@
/**
* Keyboard Interactive Handler - Shared state for keyboard-interactive authentication
* This module provides a centralized storage for keyboard-interactive auth requests
* used by SSH, SFTP, and Port Forwarding bridges.
*/
// Keyboard-interactive authentication pending requests
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
const keyboardInteractiveRequests = new Map();
// TTL for abandoned requests (5 minutes)
const REQUEST_TTL_MS = 5 * 60 * 1000;
/**
* Generate a unique request ID for keyboard-interactive requests
*/
function generateRequestId(prefix = 'ki') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
/**
* Store a keyboard-interactive request with TTL cleanup
*/
function storeRequest(requestId, finishCallback, webContentsId, sessionId) {
// Set up TTL timeout to clean up abandoned requests
const timeoutId = setTimeout(() => {
const pending = keyboardInteractiveRequests.get(requestId);
if (pending) {
console.warn(`[KeyboardInteractive] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s, cleaning up`);
keyboardInteractiveRequests.delete(requestId);
// Call finish with empty responses to abort the authentication
try {
pending.finishCallback([]);
} catch (err) {
console.warn(`[KeyboardInteractive] Failed to call finishCallback for timed out request:`, err.message);
}
}
}, REQUEST_TTL_MS);
keyboardInteractiveRequests.set(requestId, {
finishCallback,
webContentsId,
sessionId,
createdAt: Date.now(),
timeoutId,
});
}
/**
* Handle keyboard-interactive authentication response from renderer
*/
function handleResponse(_event, payload) {
console.log(`[KeyboardInteractive] handleResponse called with payload:`, JSON.stringify(payload));
const { requestId, responses, cancelled } = payload;
const pending = keyboardInteractiveRequests.get(requestId);
console.log(`[KeyboardInteractive] Looking for request ${requestId}, found:`, !!pending);
console.log(`[KeyboardInteractive] Current pending requests:`, Array.from(keyboardInteractiveRequests.keys()));
if (!pending) {
console.warn(`[KeyboardInteractive] No pending request for ${requestId}`);
return { success: false, error: 'Request not found' };
}
// Clear the TTL timeout since we received a response
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
keyboardInteractiveRequests.delete(requestId);
if (cancelled) {
console.log(`[KeyboardInteractive] Auth cancelled for ${requestId}`);
pending.finishCallback([]); // Empty responses to cancel
} else {
console.log(`[KeyboardInteractive] Auth response received for ${requestId}, responses count:`, responses?.length);
pending.finishCallback(responses);
}
return { success: true };
}
/**
* Get the requests map (for debugging/testing)
*/
function getRequests() {
return keyboardInteractiveRequests;
}
/**
* Register IPC handler for keyboard-interactive responses
*/
function registerHandler(ipcMain) {
ipcMain.handle("netcatty:keyboard-interactive:respond", handleResponse);
}
module.exports = {
generateRequestId,
storeRequest,
handleResponse,
getRequests,
registerHandler,
};

View File

@@ -6,13 +6,35 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
*/
function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
return false;
}
}
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
* On Windows, also detects hidden files using the hidden attribute
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const isWindows = process.platform === "win32";
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
@@ -27,19 +49,59 @@ 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";
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type: entry.isDirectory()
? "directory"
: entry.isSymbolicLink()
? "symlink"
: "file",
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
hidden,
};
} 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
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
hidden,
};
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

@@ -5,18 +5,31 @@
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Start a port forwarding tunnel
*/
async function startPortForward(event, payload) {
const {
tunnelId,
const {
tunnelId,
type, // 'local' | 'remote' | 'dynamic'
localPort,
localPort,
bindAddress = '127.0.0.1',
remoteHost,
remotePort,
@@ -26,34 +39,125 @@ async function startPortForward(event, payload) {
password,
privateKey,
} = payload;
return new Promise((resolve, reject) => {
const conn = new SSHClient();
const sender = event.sender;
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:portforward:status", { tunnelId, status, error });
}
};
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 30000,
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
};
if (privateKey) {
connectOpts.privateKey = privateKey;
} else if (password) {
}
if (password) {
connectOpts.password = password;
}
// Build auth handler with keyboard-interactive support
const authMethods = [];
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[PortForward] Auto-answering password prompt at index ${i}`);
responses[i] = password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[PortForward] Merged responses, finishing keyboard-interactive`);
finish(responses);
}, sender.id, tunnelId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: tunnelId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
});
});
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
if (type === 'local') {
// LOCAL FORWARDING: Listen on local port, forward to remote
const server = net.createServer((socket) => {
@@ -69,13 +173,13 @@ async function startPortForward(event, payload) {
return;
}
socket.pipe(stream).pipe(socket);
socket.on('error', (e) => console.warn('[PortForward] Socket error:', e.message));
stream.on('error', (e) => console.warn('[PortForward] Stream error:', e.message));
}
);
});
server.on('error', (err) => {
console.error(`[PortForward] Server error:`, err.message);
sendStatus('error', err.message);
@@ -83,19 +187,19 @@ async function startPortForward(event, payload) {
portForwardingTunnels.delete(tunnelId);
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
portForwardingTunnels.set(tunnelId, {
type: 'local',
conn,
server,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
});
} else if (type === 'remote') {
// REMOTE FORWARDING: Listen on remote port, forward to local
conn.forwardIn(bindAddress, localPort, (err) => {
@@ -106,24 +210,24 @@ async function startPortForward(event, payload) {
reject(err);
return;
}
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
portForwardingTunnels.set(tunnelId, {
type: 'remote',
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
});
// Handle incoming connections from remote
conn.on('tcp connection', (info, accept, rejectConn) => {
const stream = accept();
const socket = net.connect(remotePort, remoteHost || '127.0.0.1', () => {
stream.pipe(socket).pipe(stream);
});
socket.on('error', (e) => {
console.warn('[PortForward] Local socket error:', e.message);
stream.end();
@@ -133,7 +237,7 @@ async function startPortForward(event, payload) {
socket.end();
});
});
} else if (type === 'dynamic') {
// DYNAMIC FORWARDING (SOCKS5 Proxy)
const server = net.createServer((socket) => {
@@ -143,10 +247,10 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Reply: version, no auth required
socket.write(Buffer.from([0x05, 0x00]));
// Wait for connection request
socket.once('data', (request) => {
if (request[0] !== 0x05 || request[1] !== 0x01) {
@@ -154,10 +258,10 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
let targetHost, targetPort;
const addressType = request[3];
if (addressType === 0x01) {
// IPv4
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
@@ -177,7 +281,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Forward through SSH tunnel
conn.forwardOut(
bindAddress,
@@ -190,7 +294,7 @@ async function startPortForward(event, payload) {
socket.end();
return;
}
// Success reply
const reply = Buffer.alloc(10);
reply[0] = 0x05;
@@ -199,9 +303,9 @@ async function startPortForward(event, payload) {
reply[3] = 0x01;
reply.writeUInt16BE(0, 8);
socket.write(reply);
socket.pipe(stream).pipe(socket);
socket.on('error', () => stream.end());
stream.on('error', () => socket.end());
}
@@ -209,7 +313,7 @@ async function startPortForward(event, payload) {
});
});
});
server.on('error', (err) => {
console.error(`[PortForward] SOCKS server error:`, err.message);
sendStatus('error', err.message);
@@ -217,14 +321,14 @@ async function startPortForward(event, payload) {
portForwardingTunnels.delete(tunnelId);
reject(err);
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
portForwardingTunnels.set(tunnelId, {
type: 'dynamic',
conn,
server,
webContentsId: sender.id
webContentsId: sender.id
});
sendStatus('active');
resolve({ tunnelId, success: true });
@@ -233,26 +337,26 @@ async function startPortForward(event, payload) {
reject(new Error(`Unknown forwarding type: ${type}`));
}
});
conn.on('error', (err) => {
console.error(`[PortForward] SSH error:`, err.message);
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
reject(err);
});
conn.on('close', () => {
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId);
if (tunnel) {
if (tunnel.server) {
try { tunnel.server.close(); } catch {}
try { tunnel.server.close(); } catch { }
}
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
});
sendStatus('connecting');
conn.connect(connectOpts);
});
@@ -264,11 +368,11 @@ async function startPortForward(event, payload) {
async function stopPortForward(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, success: false, error: 'Tunnel not found' };
}
try {
if (tunnel.server) {
tunnel.server.close();
@@ -277,7 +381,7 @@ async function stopPortForward(event, payload) {
tunnel.conn.end();
}
portForwardingTunnels.delete(tunnelId);
return { tunnelId, success: true };
} catch (err) {
return { tunnelId, success: false, error: err.message };
@@ -290,11 +394,11 @@ async function stopPortForward(event, payload) {
async function getPortForwardStatus(event, payload) {
const { tunnelId } = payload;
const tunnel = portForwardingTunnels.get(tunnelId);
if (!tunnel) {
return { tunnelId, status: 'inactive' };
}
return { tunnelId, status: 'active', type: tunnel.type };
}
@@ -313,6 +417,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 +455,5 @@ module.exports = {
stopPortForward,
getPortForwardStatus,
listPortForwards,
stopAllPortForwards,
};

View File

@@ -0,0 +1,135 @@
/**
* Proxy Utilities - Shared proxy socket creation for SSH connections
* Extracted from sshBridge.cjs and sftpBridge.cjs to eliminate code duplication
*/
const net = require("node:net");
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
* @param {Object} proxy - Proxy configuration
* @param {string} proxy.type - 'http' or 'socks5'
* @param {string} proxy.host - Proxy host
* @param {number} proxy.port - Proxy port
* @param {string} [proxy.username] - Optional username for auth
* @param {string} [proxy.password] - Optional password for auth
* @param {string} targetHost - Target host to connect through proxy
* @param {number} targetPort - Target port to connect through proxy
* @returns {Promise<net.Socket>} Connected socket through proxy
*/
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}`));
}
});
}
module.exports = {
createProxySocket,
};

View File

@@ -6,13 +6,33 @@
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");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.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 }
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Initialize the SFTP bridge with dependencies
*/
@@ -21,18 +41,203 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* 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();
// Increase max listeners to prevent Node.js warning
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
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",
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
};
// 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;
@@ -60,26 +265,204 @@ async function openSftp(event, options) {
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
}
await client.connect(connectOpts);
sftpClients.set(connId, client);
return { sftpId: connId };
// Add keyboard-interactive authentication support
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[SFTP] Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[SFTP] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[SFTP] Merged responses, finishing keyboard-interactive`);
finish(responses);
}, event.sender.id, connId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: connId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
});
};
// Add keyboard-interactive listener BEFORE connecting
client.on("keyboard-interactive", kiHandler);
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
// Create authHandler with keyboard-interactive support
const authMethods = [];
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
}
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
await client.connect(connectOpts);
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
if (client.client && typeof client.client.setMaxListeners === 'function') {
client.client.setMaxListeners(0); // 0 means unlimited
}
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";
}
// Extract permissions from longname or rights
let permissions = undefined;
if (item.rights) {
// ssh2-sftp-client returns rights object with user/group/other
permissions = `${item.rights.user || '---'}${item.rights.group || '---'}${item.rights.other || '---'}`;
} else if (item.longname) {
// Fallback: parse from longname (e.g., "-rwxr-xr-x 1 root root ...")
const match = item.longname.match(/^[dlsbc-]([rwxsStT-]{9})/);
if (match) {
permissions = match[1];
}
}
return {
name: item.name,
type,
linkTarget,
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
permissions,
};
}));
return results;
}
/**
@@ -88,18 +471,30 @@ async function listSftp(event, payload) {
async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
}
/**
* Read file as binary (returns ArrayBuffer for binary files like images)
*/
async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
// Convert Node.js Buffer to ArrayBuffer
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Write file content
*/
async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
return true;
}
@@ -110,14 +505,14 @@ async function writeSftp(event, payload) {
async function writeSftpBinaryWithProgress(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
const buffer = Buffer.from(content);
const totalBytes = buffer.length;
let transferredBytes = 0;
let lastProgressTime = Date.now();
let lastTransferredBytes = 0;
const { Readable } = require("stream");
const readableStream = new Readable({
read() {
@@ -126,7 +521,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
const end = Math.min(transferredBytes + chunkSize, totalBytes);
const chunk = buffer.slice(transferredBytes, end);
transferredBytes = end;
const now = Date.now();
const elapsed = (now - lastProgressTime) / 1000;
let speed = 0;
@@ -135,7 +530,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
lastProgressTime = now;
lastTransferredBytes = transferredBytes;
}
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
@@ -143,20 +538,20 @@ async function writeSftpBinaryWithProgress(event, payload) {
totalBytes,
speed,
});
this.push(chunk);
} else {
this.push(null);
}
}
});
try {
await client.put(readableStream, remotePath);
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:complete", { transferId });
return { success: true, transferId };
} catch (err) {
const contents = electronModule.webContents.fromId(event.sender.id);
@@ -167,17 +562,35 @@ async function writeSftpBinaryWithProgress(event, payload) {
/**
* Close an SFTP connection
* Also cleans up any jump host connections and file watchers if present
*/
async function closeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) return;
// Stop file watchers and clean up temp files for this SFTP session
try {
fileWatcherBridge.stopWatchersForSession(payload.sftpId, true);
} catch (err) {
console.warn("[SFTP] Error stopping file watchers:", err.message);
}
try {
await client.end();
} catch (err) {
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}`);
}
}
/**
@@ -186,7 +599,7 @@ async function closeSftp(event, payload) {
async function mkdirSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.mkdir(payload.path, true);
return true;
}
@@ -197,7 +610,7 @@ async function mkdirSftp(event, payload) {
async function deleteSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const stat = await client.stat(payload.path);
if (stat.isDirectory) {
await client.rmdir(payload.path, true);
@@ -213,7 +626,7 @@ async function deleteSftp(event, payload) {
async function renameSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.rename(payload.oldPath, payload.newPath);
return true;
}
@@ -224,7 +637,7 @@ async function renameSftp(event, payload) {
async function statSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const stat = await client.stat(payload.path);
return {
name: path.basename(payload.path),
@@ -241,7 +654,7 @@ async function statSftp(event, payload) {
async function chmodSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.chmod(payload.path, parseInt(payload.mode, 8));
return true;
}
@@ -253,6 +666,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:open", openSftp);
ipcMain.handle("netcatty:sftp:list", listSftp);
ipcMain.handle("netcatty:sftp:read", readSftp);
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:close", closeSftp);
@@ -263,12 +677,21 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
}
/**
* Get the SFTP clients map (for external access)
*/
function getSftpClients() {
return sftpClients;
}
module.exports = {
init,
registerHandlers,
getSftpClients,
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
closeSftp,

View File

@@ -8,12 +8,14 @@ const fs = require("node:fs");
const path = require("node:path");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch {}
try { fs.appendFileSync(logFile, line); } catch { }
console.log("[SSH]", msg, data || "");
};
@@ -32,6 +34,15 @@ function resolveLangFromCharset(charset) {
return trimmed;
}
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Initialize the SSH bridge with dependencies
*/
@@ -40,122 +51,6 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Create a socket through a proxy (HTTP CONNECT or SOCKS5)
*/
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
*/
@@ -163,35 +58,39 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const sender = event.sender;
const connections = [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
}
};
try {
const totalHops = jumpHosts.length;
// 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}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
const conn = new SSHClient();
// Build connection options
const connOpts = {
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000, // Reduced from 60s for faster failure detection
keepaliveInterval: 10000,
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
@@ -200,7 +99,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
compress: ['none'],
},
};
// Auth - support agent (certificate), key, and password fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -230,7 +129,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
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);
@@ -243,7 +142,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
delete connOpts.host;
delete connOpts.port;
}
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
@@ -263,9 +162,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
connections.push(conn);
// Determine next target
let nextHost, nextPort;
if (isLast) {
@@ -278,7 +177,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
nextHost = nextJump.hostname;
nextPort = nextJump.port || 22;
}
// Create forward stream to next hop
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
@@ -294,17 +193,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
});
});
}
// Return the final forwarded stream and all connections for cleanup
return {
socket: currentSocket,
return {
socket: currentSocket,
connections,
sendProgress
sendProgress
};
} catch (err) {
// Cleanup on error
for (const conn of connections) {
try { conn.end(); } catch {}
try { conn.end(); } catch { }
}
throw err;
}
@@ -321,7 +220,7 @@ async function startSSHSession(event, options) {
const cols = options.cols || 80;
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
@@ -332,13 +231,13 @@ async function startSSHSession(event, options) {
const conn = new SSHClient();
let chainConnections = [];
let connectionSocket = null;
// Determine if we have jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
const hasProxy = !!options.proxy;
const totalHops = jumpHosts.length + 1; // +1 for final target
// Build base connection options for final target
const connectOpts = {
host: options.hostname,
@@ -346,8 +245,12 @@ async function startSSHSession(event, options) {
username: options.username || "root",
// `readyTimeout` covers the entire connection + authentication flow in ssh2.
readyTimeout: 20000, // Fast failure for non-interactive auth
keepaliveInterval: 10000,
// Use user-configured keepalive interval (in seconds -> convert to ms)
// If 0 or not provided, use 10000ms as default
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
@@ -365,13 +268,16 @@ async function startSSHSession(event, options) {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
hasPassword: !!options.password,
hasEffectivePassphrase: !!effectivePassphrase,
});
log("Auth configuration", {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
});
let authAgent = null;
@@ -421,25 +327,25 @@ async function startSSHSession(event, options) {
// Handle chain/proxy connections
if (hasJumpHosts) {
const chainResult = await connectThroughChain(
event,
options,
jumpHosts,
options.hostname,
event,
options,
jumpHosts,
options.hostname,
options.port || 22
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
} else if (hasProxy) {
sendProgress(1, 1, options.hostname, 'connecting');
connectionSocket = await createProxySocket(
options.proxy,
options.hostname,
options.proxy,
options.hostname,
options.port || 22
);
connectOpts.sock = connectionSocket;
@@ -448,12 +354,13 @@ async function startSSHSession(event, options) {
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
console.log(`[Chain] Final target ${options.hostname} ready`);
console.log(`${logPrefix} ${options.hostname} ready`);
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
conn.shell(
{
term: "xterm-256color",
@@ -461,7 +368,7 @@ async function startSSHSession(event, options) {
rows,
},
{
env: {
env: {
LANG: resolveLangFromCharset(options.charset),
COLORTERM: "truecolor",
...(options.env || {}),
@@ -471,7 +378,7 @@ async function startSSHSession(event, options) {
if (err) {
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch {}
try { c.end(); } catch { }
}
reject(err);
return;
@@ -490,16 +397,16 @@ async function startSSHSession(event, options) {
let flushTimeout = null;
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
const flushBuffer = () => {
if (dataBuffer.length > 0) {
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:data", { sessionId, data: dataBuffer });
const contents = event.sender;
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
dataBuffer = '';
}
flushTimeout = null;
};
const bufferData = (data) => {
dataBuffer += data;
// Immediate flush for large chunks
@@ -529,12 +436,12 @@ async function startSSHSession(event, options) {
clearTimeout(flushTimeout);
}
flushBuffer();
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch {}
try { c.end(); } catch { }
}
});
@@ -551,58 +458,161 @@ async function startSSHSession(event, options) {
});
conn.on("error", (err) => {
console.error(`[Chain] Final target ${options.hostname} error:`, err.message);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
const contents = event.sender;
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
if (isAuthError) {
contents?.send("netcatty:auth:failed", {
sessionId,
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
error: err.message,
hostname: options.hostname
hostname: options.hostname
});
} else {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
try { c.end(); } catch { }
}
reject(err);
});
conn.on("timeout", () => {
console.error(`[Chain] Final target ${options.hostname} connection timeout`);
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
try { c.end(); } catch { }
}
reject(err);
});
conn.on("close", () => {
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
try { c.end(); } catch { }
}
});
console.log(`[Chain] Connecting to final target ${options.hostname}...`);
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`${logPrefix} Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// But only send the prompts that need user input
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`);
finish(responses);
}, sender.id, sessionId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
});
});
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
// Create authHandler with keyboard-interactive support
const authMethods = [];
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
}
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});
} catch (err) {
console.error("[Chain] SSH chain connection error:", err.message);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
throw err;
}
}
@@ -711,11 +721,11 @@ async function execCommand(event, payload) {
*/
async function generateKeyPair(event, options) {
const { type, bits, comment } = options;
try {
let keyType;
let keyBits = bits;
switch (type) {
case 'ED25519':
keyType = 'ed25519';
@@ -731,15 +741,15 @@ async function generateKeyPair(event, options) {
keyBits = bits || 4096;
break;
}
const result = sshUtils.generateKeyPairSync(keyType, {
bits: keyBits,
comment: comment || 'netcatty-generated-key',
});
const privateKey = result.private;
const publicKey = result.public;
return {
success: true,
privateKey,
@@ -754,13 +764,113 @@ async function generateKeyPair(event, options) {
}
}
/**
* Wrapper for SSH session handler to suppress noisy auth error stack traces
* Auth failures are expected when fallback to password is available
*/
async function startSSHSessionWrapper(event, options) {
try {
return await startSSHSession(event, options);
} catch (err) {
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
if (isAuthError) {
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
const authError = new Error(err.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
throw authError;
}
throw err;
}
}
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
*/
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
return new Promise((resolve) => {
const stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
let buffer = '';
const onData = (data) => {
const str = data.toString();
buffer += str;
// We need to find the ACTUAL output markers, not the command echo
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
//
// We look for the marker at the START of a line (after newline) to avoid the echo
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
const startMatch = buffer.match(startMarkerRegex);
const endMatch = buffer.match(endMarkerRegex);
if (startMatch && endMatch) {
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
const endIdx = buffer.indexOf(endMatch[0]);
if (startIdx <= endIdx) {
clearTimeout(timeout);
stream.removeListener('data', onData);
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
// The pwd output should be a valid absolute path
if (pwdOutput && pwdOutput.startsWith('/')) {
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
resolve({ success: true, cwd: pwdOutput });
} else {
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
resolve({ success: false, error: 'Invalid pwd output' });
}
}
}
};
stream.on('data', onData);
// Send pwd command with short unique markers
// Using 'S' and 'E' as suffixes to make markers shorter
// After the command, send ANSI escape sequences to clear the output lines:
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
});
}
/**
* Register IPC handlers for SSH operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:start", startSSHSession);
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
}
module.exports = {
@@ -769,5 +879,6 @@ module.exports = {
createProxySocket,
startSSHSession,
execCommand,
getSessionPwd,
generateKeyPair,
};

View File

@@ -0,0 +1,183 @@
/**
* Temp Directory Bridge - Manages Netcatty's dedicated temp directory
*
* All temporary files (SFTP downloads, etc.) are stored in a dedicated
* Netcatty folder within the system temp directory for easier cleanup.
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// Netcatty temp directory name
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
// Cached temp directory path
let cachedTempDir = null;
/**
* Get the Netcatty temp directory path
* Creates the directory if it doesn't exist
*/
function getTempDir() {
if (cachedTempDir) {
// Verify it still exists
try {
if (fs.existsSync(cachedTempDir)) {
return cachedTempDir;
}
} catch {
// Directory was deleted, recreate it
}
}
const systemTempDir = os.tmpdir();
const netcattyTempDir = path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
try {
if (!fs.existsSync(netcattyTempDir)) {
fs.mkdirSync(netcattyTempDir, { recursive: true });
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
}
cachedTempDir = netcattyTempDir;
return netcattyTempDir;
} catch (err) {
console.error(`[TempDir] Failed to create temp directory:`, err.message);
// Fallback to system temp dir
return systemTempDir;
}
}
/**
* Ensure the temp directory exists (call on app startup)
*/
function ensureTempDir() {
const tempDir = getTempDir();
console.log(`[TempDir] Netcatty temp directory: ${tempDir}`);
return tempDir;
}
/**
* Get temp directory info (path, size, file count)
*/
async function getTempDirInfo() {
const tempDir = getTempDir();
try {
const files = await fs.promises.readdir(tempDir);
let totalSize = 0;
let fileCount = 0;
for (const file of files) {
try {
const filePath = path.join(tempDir, file);
const stat = await fs.promises.stat(filePath);
if (stat.isFile()) {
totalSize += stat.size;
fileCount++;
}
} catch {
// Skip files that can't be stat'd
}
}
return {
path: tempDir,
totalSize,
fileCount,
};
} catch (err) {
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
return {
path: tempDir,
totalSize: 0,
fileCount: 0,
};
}
}
/**
* Clear all files in the temp directory
* Returns the number of files deleted
*/
async function clearTempDir() {
const tempDir = getTempDir();
let deletedCount = 0;
let failedCount = 0;
try {
const files = await fs.promises.readdir(tempDir);
for (const file of files) {
try {
const filePath = path.join(tempDir, file);
const stat = await fs.promises.stat(filePath);
if (stat.isFile()) {
await fs.promises.unlink(filePath);
deletedCount++;
console.log(`[TempDir] Deleted: ${file}`);
} else if (stat.isDirectory()) {
// Recursively delete subdirectories
await fs.promises.rm(filePath, { recursive: true, force: true });
deletedCount++;
console.log(`[TempDir] Deleted directory: ${file}`);
}
} catch (err) {
failedCount++;
console.log(`[TempDir] Could not delete ${file}: ${err.message}`);
}
}
console.log(`[TempDir] Cleanup complete: ${deletedCount} deleted, ${failedCount} failed`);
return { deletedCount, failedCount };
} catch (err) {
console.error(`[TempDir] Failed to clear temp dir:`, err.message);
return { deletedCount: 0, failedCount: 0, error: err.message };
}
}
/**
* Generate a unique temp file path for a given filename
*/
function getTempFilePath(fileName) {
const tempDir = getTempDir();
const timestamp = Date.now();
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
return path.join(tempDir, `${timestamp}_${safeFileName}`);
}
/**
* Register IPC handlers
*/
function registerHandlers(ipcMain, shell) {
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
return getTempDirInfo();
});
ipcMain.handle("netcatty:tempdir:clear", async () => {
return clearTempDir();
});
ipcMain.handle("netcatty:tempdir:getPath", () => {
return getTempDir();
});
ipcMain.handle("netcatty:tempdir:open", async () => {
const tempDir = getTempDir();
if (shell?.openPath) {
await shell.openPath(tempDir);
return { success: true };
}
return { success: false };
});
}
module.exports = {
getTempDir,
ensureTempDir,
getTempDirInfo,
clearTempDir,
getTempFilePath,
registerHandlers,
};

View File

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

View File

@@ -27,11 +27,15 @@ let currentTheme = "light";
let currentLanguage = "en";
let handlersRegistered = false; // Prevent duplicate IPC handler registration
let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
const rendererReadyCallbacksByWebContentsId = new Map();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
function debugLog(...args) {
if (!DEBUG_WINDOWS) return;
@@ -43,6 +47,78 @@ function debugLog(...args) {
}
}
/**
* Get the path to the window state file
*/
function getWindowStatePath() {
try {
if (!electronApp) return null;
return path.join(electronApp.getPath("userData"), WINDOW_STATE_FILE);
} catch {
return null;
}
}
/**
* Load saved window state from disk
*/
function loadWindowState() {
try {
const statePath = getWindowStatePath();
if (!statePath || !fs.existsSync(statePath)) {
return null;
}
const data = fs.readFileSync(statePath, "utf8");
const state = JSON.parse(data);
// Validate the loaded state has required properties
if (
typeof state.width === "number" &&
typeof state.height === "number" &&
state.width > 0 &&
state.height > 0
) {
return state;
}
return null;
} catch (err) {
debugLog("Failed to load window state:", err?.message || err);
return null;
}
}
/**
* Save window state to disk
*/
function saveWindowState(state) {
try {
const statePath = getWindowStatePath();
if (!statePath) return false;
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return true;
} catch (err) {
debugLog("Failed to save window state:", err?.message || err);
return false;
}
}
/**
* Get the current window bounds state for saving
* @param {BrowserWindow} win - The window to get bounds from
* @param {Object} overrideBounds - Optional bounds to use instead of current window bounds (for normal bounds tracking)
*/
function getWindowBoundsState(win, overrideBounds) {
if (!win || win.isDestroyed()) return null;
const bounds = overrideBounds || win.getBounds();
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen(),
};
}
const MENU_LABELS = {
en: { edit: "Edit", view: "View", window: "Window" },
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
@@ -420,18 +496,58 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme } = electronModule;
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
electronApp = app;
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
const backgroundColor = frontendBackground || "#1a1a1a";
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
// Load saved window state
const savedState = loadWindowState();
let windowBounds = {
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
};
if (savedState) {
// Use saved dimensions
windowBounds.width = savedState.width;
windowBounds.height = savedState.height;
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
try {
// Check if the saved position is within any available display
const displays = screen?.getAllDisplays?.() || [];
const isPositionVisible = displays.some((display) => {
const { x, y, width, height } = display.bounds;
// Check if at least part of the window would be visible on this display
return (
savedState.x < x + width &&
savedState.x + savedState.width > x &&
savedState.y < y + height &&
savedState.y + savedState.height > y
);
});
if (isPositionVisible) {
windowBounds.x = savedState.x;
windowBounds.y = savedState.y;
}
} catch {
// Ignore screen check errors, just don't set position
}
}
}
const win = new BrowserWindow({
width: 1400,
height: 900,
...windowBounds,
backgroundColor,
icon: appIcon,
show: false,
@@ -448,12 +564,70 @@ 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);
// Close settings window when main window closes
closeSettingsWindow();
});
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.
@@ -559,9 +733,10 @@ async function openSettingsWindow(electronModule, options) {
backgroundColor,
icon: appIcon,
fullscreenable: !isMac,
// NOTE: Do NOT set parent on Windows - it can cause the main window to close
// when the settings window is closed in some edge cases.
parent: isMac ? mainWindow : undefined,
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
// the window to a different screen (the window becomes invisible while still
// appearing in "Show All Windows" in the Dock). On Windows it can cause the
// main window to close when the settings window is closed.
modal: false,
show: false,
frame: isMac,

View File

@@ -36,7 +36,7 @@ try {
electronModule = require("electron");
}
const { app, BrowserWindow, Menu, protocol } = electronModule || {};
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
if (!app || !BrowserWindow) {
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
}
@@ -76,6 +76,8 @@ const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -359,6 +361,10 @@ const registerBridges = (win) => {
sftpBridge.init(deps);
transferBridge.init(deps);
terminalBridge.init(deps);
fileWatcherBridge.init(deps);
// Initialize temp directory (synchronously)
tempDirBridge.ensureTempDir();
// Register all IPC handlers
sshBridge.registerHandlers(ipcMain);
@@ -372,6 +378,8 @@ const registerBridges = (win) => {
googleAuthBridge.registerHandlers(ipcMain, electronModule);
onedriveAuthBridge.registerHandlers(ipcMain, electronModule);
cloudSyncBridge.registerHandlers(ipcMain);
fileWatcherBridge.registerHandlers(ipcMain);
tempDirBridge.registerHandlers(ipcMain, shell);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
@@ -432,6 +440,175 @@ const registerBridges = (win) => {
};
});
// Select an application from system file picker
ipcMain.handle("netcatty:selectApplication", async () => {
const { dialog } = electronModule;
let filters = [];
let defaultPath;
if (process.platform === "darwin") {
filters = [{ name: "Applications", extensions: ["app"] }];
defaultPath = "/Applications";
} else if (process.platform === "win32") {
filters = [{ name: "Executables", extensions: ["exe", "com", "bat", "cmd"] }];
defaultPath = "C:\\Program Files";
} else {
// Linux - no specific filter, user can pick any executable
filters = [{ name: "All Files", extensions: ["*"] }];
defaultPath = "/usr/bin";
}
const result = await dialog.showOpenDialog({
title: "Select Application",
defaultPath,
filters,
properties: ["openFile"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
const appPath = result.filePaths[0];
const appName = path.basename(appPath).replace(/\.[^.]+$/, "");
return { path: appPath, name: appName };
});
// Open a file with a specific application
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
const { spawn: cpSpawn } = require("node:child_process");
console.log(`[Main] Opening file with application:`);
console.log(`[Main] File: ${filePath}`);
console.log(`[Main] App: ${appPath}`);
console.log(`[Main] Platform: ${process.platform}`);
try {
let child;
if (process.platform === "darwin") {
// On macOS, use 'open' command with -a flag for specific app
const args = ["-a", appPath, filePath];
console.log(`[Main] Command: open ${args.join(' ')}`);
child = cpSpawn("open", args, { detached: true, stdio: "pipe" });
} else if (process.platform === "win32") {
// On Windows, use cmd /c start to properly handle paths with spaces
// The empty string "" as window title is required when the first arg has quotes
const args = ["/c", "start", "\"\"", `"${appPath}"`, `"${filePath}"`];
console.log(`[Main] Command: cmd ${args.join(' ')}`);
child = cpSpawn("cmd", args, { detached: true, stdio: "pipe", windowsVerbatimArguments: true });
} else {
// On Linux, spawn the app with the file
console.log(`[Main] Command: ${appPath} ${filePath}`);
child = cpSpawn(appPath, [filePath], { detached: true, stdio: "pipe" });
}
// Log any errors from the child process
child.on("error", (err) => {
console.error(`[Main] Failed to start application:`, err.message);
});
child.stderr?.on("data", (data) => {
// On Windows, stderr may be encoded in GBK/CP936, try to decode
if (process.platform === "win32") {
try {
// Try decoding as GBK (code page 936) for Chinese Windows
const { TextDecoder } = require("node:util");
const decoder = new TextDecoder("gbk");
const decoded = decoder.decode(data);
console.log(`[Main] Application stderr: ${decoded}`);
} catch {
// Fallback to hex dump if decoding fails
console.log(`[Main] Application stderr (hex): ${data.toString("hex")}`);
}
} else {
console.error(`[Main] Application stderr:`, data.toString());
}
});
child.on("exit", (code, signal) => {
// On Windows, many apps (like Notepad++) pass the file to an existing instance
// and immediately exit with code 1, this is normal behavior
if (code !== 0 && code !== null) {
if (process.platform === "win32") {
console.log(`[Main] Application exited with code: ${code}, signal: ${signal} (this may be normal for single-instance apps)`);
} else {
console.warn(`[Main] Application exited with code: ${code}, signal: ${signal}`);
}
} else {
console.log(`[Main] Application started successfully`);
}
});
child.unref();
return true;
} catch (err) {
console.error(`[Main] Error opening file with application:`, err);
throw err;
}
});
// Download SFTP file to temp and return local path
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName }) => {
console.log(`[Main] Downloading SFTP file to temp:`);
console.log(`[Main] SFTP ID: ${sftpId}`);
console.log(`[Main] Remote path: ${remotePath}`);
console.log(`[Main] File name: ${fileName}`);
const client = require("./bridges/sftpBridge.cjs");
// Use tempDirBridge for dedicated Netcatty temp directory
const localPath = await tempDirBridge.getTempFilePath(fileName);
console.log(`[Main] Local temp path: ${localPath}`);
// Get the sftp client and download file
const sftpClients = client.getSftpClients ? client.getSftpClients() : null;
if (!sftpClients) {
console.log(`[Main] Using fallback readSftp method`);
// Fallback: use readSftp and write to temp file
const content = await client.readSftp(null, { sftpId, path: remotePath });
if (typeof content === "string") {
await fs.promises.writeFile(localPath, content, "utf-8");
} else {
await fs.promises.writeFile(localPath, content);
}
console.log(`[Main] File downloaded successfully (fallback)`);
return localPath;
}
const sftpClient = sftpClients.get(sftpId);
if (!sftpClient) {
console.error(`[Main] SFTP session not found: ${sftpId}`);
throw new Error("SFTP session not found");
}
await sftpClient.fastGet(remotePath, localPath);
console.log(`[Main] File downloaded successfully`);
return localPath;
});
// Delete a temp file (for cleanup when editors close)
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = tempDirBridge.getTempDir();
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(netcattyTempDir)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
return { success: false };
}
await fs.promises.unlink(resolvedPath);
console.log(`[Main] Temp file deleted: ${filePath}`);
return { success: true };
} catch (err) {
// Silently handle failures (file may be in use or already deleted)
console.log(`[Main] Could not delete temp file: ${filePath} (${err.message})`);
return { success: false };
}
});
console.log('[Main] All bridges registered successfully');
};
@@ -539,13 +716,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

@@ -9,6 +9,7 @@ const chainProgressListeners = new Map();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
const fullscreenChangeListeners = new Set();
const keyboardInteractiveListeners = new Set();
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
@@ -86,6 +87,17 @@ ipcRenderer.on("netcatty:auth:failed", (_event, payload) => {
}
});
// Keyboard-interactive authentication events (2FA/MFA)
ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
keyboardInteractiveListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Keyboard-interactive callback failed", err);
}
});
});
// Transfer progress events
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
const cb = transferProgressListeners.get(payload.transferId);
@@ -198,6 +210,30 @@ ipcRenderer.on("netcatty:portforward:status", (_event, payload) => {
}
});
// File watcher listeners (for auto-sync feature)
const fileWatchSyncedListeners = new Set();
const fileWatchErrorListeners = new Set();
ipcRenderer.on("netcatty:filewatch:synced", (_event, payload) => {
fileWatchSyncedListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("File watch synced callback failed", err);
}
});
});
ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
fileWatchErrorListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("File watch error callback failed", err);
}
});
});
const api = {
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("netcatty:start", options);
@@ -215,12 +251,28 @@ 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 });
},
execCommand: async (options) => {
return ipcRenderer.invoke("netcatty:ssh:exec", options);
},
getSessionPwd: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ssh:pwd", { sessionId });
},
generateKeyPair: async (options) => {
return ipcRenderer.invoke("netcatty:key:generate", options);
},
@@ -245,6 +297,18 @@ const api = {
authFailedListeners.get(sessionId).add(cb);
return () => authFailedListeners.get(sessionId)?.delete(cb);
},
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive: (cb) => {
keyboardInteractiveListeners.add(cb);
return () => keyboardInteractiveListeners.delete(cb);
},
respondKeyboardInteractive: async (requestId, responses, cancelled = false) => {
return ipcRenderer.invoke("netcatty:keyboard-interactive:respond", {
requestId,
responses,
cancelled,
});
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;
@@ -255,6 +319,9 @@ const api = {
readSftp: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
},
readSftpBinary: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
},
writeSftp: async (sftpId, path, content) => {
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
},
@@ -488,6 +555,46 @@ const api = {
ipcRenderer.invoke("netcatty:onedrive:drive:downloadSyncFile", options),
onedriveDeleteSyncFile: (options) =>
ipcRenderer.invoke("netcatty:onedrive:drive:deleteSyncFile", options),
// File opener helpers (for "Open With" feature)
selectApplication: () =>
ipcRenderer.invoke("netcatty:selectApplication"),
openWithApplication: (filePath, appPath) =>
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
downloadSftpToTemp: (sftpId, remotePath, fileName) =>
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName }),
// File watcher for auto-sync feature
startFileWatch: (localPath, remotePath, sftpId) =>
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId }),
stopFileWatch: (watchId, cleanupTempFile = false) =>
ipcRenderer.invoke("netcatty:filewatch:stop", { watchId, cleanupTempFile }),
listFileWatches: () =>
ipcRenderer.invoke("netcatty:filewatch:list"),
registerTempFile: (sftpId, localPath) =>
ipcRenderer.invoke("netcatty:filewatch:registerTempFile", { sftpId, localPath }),
onFileWatchSynced: (cb) => {
fileWatchSyncedListeners.add(cb);
return () => fileWatchSyncedListeners.delete(cb);
},
onFileWatchError: (cb) => {
fileWatchErrorListeners.add(cb);
return () => fileWatchErrorListeners.delete(cb);
},
// Temp file cleanup
deleteTempFile: (filePath) =>
ipcRenderer.invoke("netcatty:deleteTempFile", { filePath }),
// Temp directory management
getTempDirInfo: () =>
ipcRenderer.invoke("netcatty:tempdir:getInfo"),
clearTempDir: () =>
ipcRenderer.invoke("netcatty:tempdir:clear"),
getTempDirPath: () =>
ipcRenderer.invoke("netcatty:tempdir:getPath"),
openTempDir: () =>
ipcRenderer.invoke("netcatty:tempdir:open"),
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**"],
},
{
files: ["**/*.{ts,tsx}"],

74
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
@@ -61,6 +61,8 @@ interface NetcattySSHOptions {
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
}
interface SftpStatResult {
@@ -134,7 +136,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;
@@ -149,6 +170,8 @@ interface NetcattyBridge {
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
@@ -162,6 +185,23 @@ interface NetcattyBridge {
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
@@ -261,6 +301,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>;
@@ -381,6 +429,28 @@ interface NetcattyBridge {
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;
}
interface Window {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
<title>netcatty SSH</title>
<style>
/* Load extended Unicode ranges for terminal box drawing characters */
@@ -206,4 +206,4 @@
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>

View File

@@ -30,7 +30,7 @@ const CJK_FALLBACK_FONTS = [
const CJK_FALLBACK_STACK = CJK_FALLBACK_FONTS.join(', ');
const withCjkFallback = (family: string) => {
export const withCjkFallback = (family: string) => {
const trimmed = family.trim();
if (!CJK_FALLBACK_STACK) return trimmed;
// Avoid double-appending if a custom stack already includes one of these fonts.

View File

@@ -31,5 +31,17 @@ export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
// Update check
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

View File

@@ -217,6 +217,26 @@ export class CloudSyncManager {
const key = event.key;
if (!key) return;
// Handle master key config changes (e.g., when set up in settings window)
if (key === SYNC_STORAGE_KEYS.MASTER_KEY_CONFIG) {
const nextConfig = this.safeJsonParse<MasterKeyConfig>(event.newValue);
if (nextConfig && !this.state.masterKeyConfig) {
// Master key was set up in another window - update our state
this.state.masterKeyConfig = nextConfig;
this.state.securityState = 'LOCKED';
this.notifyStateChange();
} else if (!nextConfig && this.state.masterKeyConfig) {
// Master key was removed in another window
this.state.masterKeyConfig = null;
this.state.securityState = 'NO_KEY';
this.state.unlockedKey = null;
this.masterPassword = null;
this.notifyStateChange();
}
return;
}
// Sync versions + auto-sync settings
if (key === SYNC_STORAGE_KEYS.SYNC_CONFIG) {
const next = this.safeJsonParse<{

View File

@@ -14,11 +14,84 @@ export interface PortForwardingConnection {
status: 'inactive' | 'connecting' | 'active' | 'error';
error?: string;
unsubscribe?: () => void;
// Reconnect state
reconnectAttempts?: number;
reconnectTimeoutId?: ReturnType<typeof setTimeout>;
}
// Map to track active connections
const activeConnections = new Map<string, PortForwardingConnection>();
// Reconnect configuration
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY_MS = 3000; // 3 seconds between reconnection attempts
// Callbacks for auto-reconnect - will be set by the state hook
let reconnectCallback: ((
ruleId: string,
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
) => Promise<{ success: boolean; error?: string }>) | null = null;
/**
* Set the reconnect callback (called by state hook to enable auto-reconnect)
*/
export const setReconnectCallback = (
callback: typeof reconnectCallback
): void => {
reconnectCallback = callback;
};
/**
* Clear any pending reconnect for a rule
*/
export const clearReconnectTimer = (ruleId: string): void => {
const conn = activeConnections.get(ruleId);
if (conn?.reconnectTimeoutId) {
clearTimeout(conn.reconnectTimeoutId);
conn.reconnectTimeoutId = undefined;
}
};
/**
* Helper function to schedule a reconnection attempt
* Returns true if a reconnect was scheduled, false otherwise
*/
const scheduleReconnectIfNeeded = (
ruleId: string,
enableReconnect: boolean,
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
): boolean => {
if (!enableReconnect || !reconnectCallback) {
return false;
}
const currentConn = activeConnections.get(ruleId);
const attempts = (currentConn?.reconnectAttempts ?? 0) + 1;
if (attempts <= MAX_RECONNECT_ATTEMPTS) {
logger.info(`[PortForwardingService] Scheduling reconnect ${attempts}/${MAX_RECONNECT_ATTEMPTS}`);
if (currentConn) {
currentConn.reconnectAttempts = attempts;
currentConn.reconnectTimeoutId = setTimeout(() => {
if (reconnectCallback) {
reconnectCallback(ruleId, onStatusChange);
}
}, RECONNECT_DELAY_MS);
}
onStatusChange('connecting', `Reconnecting (${attempts}/${MAX_RECONNECT_ATTEMPTS})...`);
return true;
}
logger.warn(`[PortForwardingService] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached for rule ${ruleId}`);
// Reset reconnect attempts
if (currentConn) {
currentConn.reconnectAttempts = 0;
}
return false;
};
/**
* Get active connection info for a rule
*/
@@ -35,17 +108,91 @@ 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
* @param enableReconnect - If true, will automatically attempt to reconnect on disconnect
*/
export const startPortForward = async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect = false
): Promise<{ success: boolean; error?: string }> => {
const bridge = netcattyBridge.get();
// Clear any existing reconnect timer
clearReconnectTimer(rule.id);
if (!bridge?.startPortForward) {
// Fallback for browser/dev mode - simulate the connection
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
@@ -72,15 +219,26 @@ export const startPortForward = async (
conn.status = status;
conn.error = error;
}
// Handle auto-reconnect on error/disconnect
if (status === 'error') {
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
if (reconnectScheduled) {
return;
}
}
onStatusChange(status, error ?? undefined);
});
// Store connection info
// Store connection info (preserve reconnect attempts if this is a reconnect)
const existingConn = activeConnections.get(rule.id);
activeConnections.set(rule.id, {
ruleId: rule.id,
tunnelId,
status: 'connecting',
unsubscribe,
reconnectAttempts: existingConn?.reconnectAttempts ?? 0,
});
onStatusChange('connecting');
@@ -101,16 +259,35 @@ export const startPortForward = async (
});
if (!result.success) {
// Check if we should attempt reconnect
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
if (reconnectScheduled) {
return { success: false, error: result.error };
}
activeConnections.delete(rule.id);
unsubscribe?.();
onStatusChange('error', result.error);
return { success: false, error: result.error };
}
// Reset reconnect attempts on successful connection
const conn = activeConnections.get(rule.id);
if (conn) {
conn.reconnectAttempts = 0;
}
return { success: true };
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
// Check if we should attempt reconnect
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
if (reconnectScheduled) {
return { success: false, error };
}
onStatusChange('error', error);
activeConnections.delete(rule.id);
return { success: false, error };
@@ -127,6 +304,9 @@ export const stopPortForward = async (
const bridge = netcattyBridge.get();
const conn = activeConnections.get(ruleId);
// Clear any pending reconnect timer
clearReconnectTimer(ruleId);
if (!conn) {
onStatusChange('inactive');
return { success: true };
@@ -180,16 +360,19 @@ export const isBackendAvailable = (): boolean => {
export const stopAllPortForwards = async (): Promise<void> => {
const bridge = netcattyBridge.get();
for (const [_ruleId, conn] of activeConnections) {
try {
if (bridge?.stopPortForward) {
await bridge.stopPortForward(conn.tunnelId);
}
conn.unsubscribe?.();
} catch (err) {
logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
}
}
for (const [ruleId, conn] of activeConnections) {
// Clear any pending reconnect timer
clearReconnectTimer(ruleId);
try {
if (bridge?.stopPortForward) {
await bridge.stopPortForward(conn.tunnelId);
}
conn.unsubscribe?.();
} catch (err) {
logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
}
}
activeConnections.clear();
};
@@ -230,4 +413,6 @@ export default {
getPortForwardStatus,
isBackendAvailable,
stopAllPortForwards,
setReconnectCallback,
clearReconnectTimer,
};

View File

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

127
lib/localFonts.ts Normal file
View File

@@ -0,0 +1,127 @@
import { TerminalFont, withCjkFallback } from "../infrastructure/config/fonts"
/**
* Type definition for Local Font Access API
* @see https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API
*/
interface LocalFontData {
family: string;
}
/**
* Known monospace font families that don't follow naming conventions.
* These are popular programming/terminal fonts that should be included.
*/
const KNOWN_MONOSPACE_FONTS = new Set([
// Popular programming fonts
'iosevka',
'hack',
'consolas',
'menlo',
'monaco',
'inconsolata',
'mononoki',
'fantasque sans mono',
'anonymous pro',
'liberation mono',
'dejavu sans mono',
'droid sans mono',
'ubuntu mono',
'roboto mono',
'source code pro',
'fira code',
'fira mono',
'jetbrains mono',
'cascadia code',
'cascadia mono',
'victor mono',
'ibm plex mono',
'sf mono',
'operator mono',
'input mono',
'pragmata pro',
'berkeley mono',
'monaspace',
'geist mono',
'comic mono',
'courier',
'courier new',
'lucida console',
'pt mono',
'overpass mono',
'space mono',
'go mono',
'noto sans mono',
'sarasa mono',
'maple mono',
]);
/**
* Suffix indicators that suggest a font is monospace
*/
const MONO_SUFFIX_INDICATORS = ['mono', 'monospace', 'code', 'terminal', 'console'];
/**
* Checks if a font family name indicates a monospace font.
* Uses both known font list and suffix matching for comprehensive detection.
*/
function isMonospaceFont(familyName: string): boolean {
const familyLower = familyName.toLowerCase().trim();
// Check against known monospace fonts (exact or partial match)
for (const knownFont of KNOWN_MONOSPACE_FONTS) {
if (familyLower === knownFont || familyLower.startsWith(knownFont + ' ')) {
return true;
}
}
// Check suffix indicators with word boundary
return MONO_SUFFIX_INDICATORS.some(indicator => {
return (
familyLower === indicator ||
familyLower.endsWith(' ' + indicator) ||
familyLower.endsWith('-' + indicator) ||
familyLower.includes(' ' + indicator + ' ')
);
});
}
/**
* Queries local monospace fonts from the system using the Font Access API.
* Returns an empty array if the API is not available or permission is denied.
*/
export async function getMonospaceFonts(): Promise<TerminalFont[]> {
// Check if the Font Access API is available
if (typeof window === "undefined" || !("queryLocalFonts" in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Filter monospace fonts using robust word boundary matching
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
// Deduplicate by family name (API may return multiple entries per family)
const uniqueFamilies = new Set<string>();
const dedupedFonts = monoFonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
return true;
});
// Map to TerminalFont structure with CJK fallback applied
return dedupedFonts.map(f => ({
id: f.family,
name: f.family,
family: withCjkFallback(f.family + ', monospace'),
description: `Local font: ${f.family}`,
category: 'monospace' as const,
}));
} catch (error) {
// Handle permission denied or other errors gracefully
console.warn('Failed to query local fonts:', error);
return [];
}
}

585
lib/sftpFileUtils.ts Normal file
View File

@@ -0,0 +1,585 @@
/**
* SFTP File Utilities
* Helper functions for file type detection and extension handling
*/
// Common text file extensions
const TEXT_EXTENSIONS = new Set([
// Code/Scripts
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'vue', 'svelte',
'py', 'pyw', 'pyi',
'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'psm1',
'c', 'cpp', 'h', 'hpp', 'cc', 'cxx', 'hh', 'hxx',
'java', 'scala', 'kt', 'kts', 'groovy', 'gradle',
'go', 'rs', 'rb', 'php', 'pl', 'pm', 'lua', 'r', 'R',
'swift', 'dart', 'cs', 'fs', 'vb',
'ex', 'exs', 'erl', 'hrl', 'clj', 'cljs', 'cljc',
'hs', 'lhs', 'elm', 'ml', 'mli', 'nim',
// Web
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl',
// Config/Data
'json', 'json5', 'jsonc', 'xml', 'xsl', 'xslt', 'xsd',
'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'config', 'properties',
'env', 'gitignore', 'gitattributes', 'editorconfig', 'eslintrc', 'prettierrc',
'sql', 'graphql', 'gql',
// Text/Docs
'md', 'markdown', 'mdx', 'txt', 'text', 'log', 'rst', 'adoc', 'asciidoc',
'tex', 'latex', 'bib',
// Data formats
'csv', 'tsv', 'psv',
// System
'rc', 'bashrc', 'zshrc', 'profile', 'vimrc', 'tmux', 'nanorc',
'dockerfile', 'containerfile', 'makefile', 'cmake', 'mak',
// Version control & Git
'gitconfig', 'gitmodules', 'gitkeep',
// Other common text formats
'diff', 'patch', 'htaccess', 'lock', 'sum',
// Service/System files
'service', 'socket', 'timer', 'mount', 'automount', 'target',
// Shell history and data
'history', 'zsh_history', 'bash_history',
]);
// Additional filenames (no extension) that are always text
const TEXT_FILENAMES = new Set([
'readme', 'license', 'licence', 'changelog', 'authors', 'contributors',
'copying', 'install', 'news', 'todo', 'history', 'makefile', 'dockerfile',
'gemfile', 'rakefile', 'brewfile', 'procfile', 'vagrantfile',
'cmakelists.txt', 'cmakelists',
]);
// Common image file extensions
const IMAGE_EXTENSIONS = new Set([
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg',
'ico', 'tiff', 'tif', 'heic', 'heif', 'avif', 'jfif',
]);
// Known binary file extensions - files that should never be opened as text
const BINARY_EXTENSIONS = new Set([
// Images
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff', 'tif',
'heic', 'heif', 'avif', 'jfif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
// Audio
'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'aiff', 'opus',
// Video
'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg',
// Archives
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lz', 'lzma', 'zst',
'tgz', 'tbz2', 'txz', 'cab', 'iso', 'dmg',
// Executables
'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'msi', 'deb', 'rpm',
'apk', 'ipa', 'jar', 'war', 'ear',
// Documents (binary formats)
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
// Fonts
'ttf', 'otf', 'woff', 'woff2', 'eot',
// Database
'db', 'sqlite', 'sqlite3', 'mdb', 'accdb',
// Object files
'o', 'obj', 'pyc', 'pyo', 'class', 'beam',
// Other binary
'swf', 'fla', 'blend', 'unity3d', 'unitypackage',
]);
// MIME types for images (for creating blob URLs)
const IMAGE_MIME_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
jfif: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
tiff: 'image/tiff',
tif: 'image/tiff',
heic: 'image/heic',
heif: 'image/heif',
avif: 'image/avif',
};
// Language IDs for syntax highlighting
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
tsx: 'typescript',
py: 'python',
pyw: 'python',
pyi: 'python',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
bat: 'batch',
cmd: 'batch',
ps1: 'powershell',
psm1: 'powershell',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
go: 'go',
rs: 'rust',
rb: 'ruby',
php: 'php',
pl: 'perl',
lua: 'lua',
r: 'r',
R: 'r',
swift: 'swift',
dart: 'dart',
cs: 'csharp',
fs: 'fsharp',
vb: 'vb',
html: 'html',
htm: 'html',
xhtml: 'html',
css: 'css',
scss: 'scss',
sass: 'sass',
less: 'less',
json: 'json',
jsonc: 'jsonc',
json5: 'json5',
xml: 'xml',
xsl: 'xml',
xslt: 'xml',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
ini: 'ini',
conf: 'ini',
cfg: 'ini',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
md: 'markdown',
markdown: 'markdown',
mdx: 'markdown',
txt: 'plaintext',
log: 'plaintext',
vue: 'vue',
svelte: 'svelte',
dockerfile: 'dockerfile',
makefile: 'makefile',
diff: 'diff',
patch: 'diff',
};
/**
* Get the file extension from a filename
* For files without extension, returns 'file'
*/
export function getFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot === -1 || lastDot === 0) {
return 'file'; // No extension or hidden file without extension
}
return fileName.slice(lastDot + 1).toLowerCase();
}
/**
* Check if a file is a text file based on its extension and name
*/
export function isTextFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
// Check known text extensions
if (TEXT_EXTENSIONS.has(ext)) {
return true;
}
// Check common filenames that are text but have no extension
const baseName = fileName.toLowerCase().split('/').pop() || '';
const nameWithoutExt = baseName.replace(/\.[^.]+$/, '');
// Check exact filename matches
if (TEXT_FILENAMES.has(baseName) || TEXT_FILENAMES.has(nameWithoutExt)) {
return true;
}
// Check dot-files that are typically text config files
if (baseName.startsWith('.')) {
const dotConfigPatterns = [
/^\.(git|npm|yarn|docker|eslint|prettier|babel|env)/,
/^\.(nvmrc|ruby-version|python-version|node-version)$/,
/rc$/, // Files ending with 'rc' like .bashrc, .vimrc
];
if (dotConfigPatterns.some(pattern => pattern.test(baseName))) {
return true;
}
}
return false;
}
/**
* Check if binary data appears to be text by analyzing byte patterns
* This provides a more accurate detection than extension-only checking
*
* @param data - First chunk of file data (ArrayBuffer or Uint8Array)
* @param maxBytes - Maximum bytes to check (default 512)
* @returns true if data appears to be text
*/
export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 512): boolean {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const checkLength = Math.min(bytes.length, maxBytes);
if (checkLength === 0) return true; // Empty file is considered text
let controlChars = 0;
let nullBytes = 0;
let highBytes = 0;
let totalBytes = 0;
for (let i = 0; i < checkLength; i++) {
const byte = bytes[i];
totalBytes++;
// Null bytes are strong indicators of binary files
if (byte === 0) {
nullBytes++;
if (nullBytes > 0) return false; // Even one null byte suggests binary
}
// Control characters (except common ones like \t, \n, \r)
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
controlChars++;
}
// High-bit characters (non-ASCII) - some are OK for UTF-8
if (byte > 127) {
highBytes++;
}
}
// If more than 30% are control chars or more than 95% are high-bit chars, likely binary
const controlRatio = controlChars / totalBytes;
const highRatio = highBytes / totalBytes;
if (controlRatio > 0.3) return false;
if (highRatio > 0.95) return false;
return true;
}
/**
* Enhanced text file detection combining extension and content analysis
* Use this when you have access to file data for better accuracy
*/
export function isTextFileEnhanced(fileName: string, data?: ArrayBuffer | Uint8Array): boolean {
// First check by extension
const extCheck = isTextFile(fileName);
// If we have data, verify it's actually text
if (data && data.byteLength > 0) {
return extCheck && isTextData(data);
}
// Fall back to extension-only check
return extCheck;
}
/**
* Check if a file is definitely a binary file based on its extension.
* Used to exclude files from "Edit" option in context menu.
*/
export function isKnownBinaryFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
return BINARY_EXTENSIONS.has(ext);
}
/**
* Check if a file could potentially be opened as text.
* This is more permissive than isTextFile - it returns true for any file
* that is not a known binary file. Used for showing "Edit" in context menu.
* Actual text detection should be done by reading file content.
*/
export function couldBeTextFile(fileName: string): boolean {
// If it's a known binary file, definitely not text
if (isKnownBinaryFile(fileName)) {
return false;
}
// Otherwise, it could be text - we'll verify when actually opening
return true;
}
/**
* Check if a file is an image file based on its extension
*/
export function isImageFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
return IMAGE_EXTENSIONS.has(ext);
}
/**
* Get MIME type for an image file
*/
export function getImageMimeType(fileName: string): string {
const ext = getFileExtension(fileName);
return IMAGE_MIME_TYPES[ext] || 'application/octet-stream';
}
/**
* Get language ID for syntax highlighting
*/
export function getLanguageId(fileName: string): string {
const ext = getFileExtension(fileName);
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
}
/**
* Get a user-friendly name for a language
*/
export function getLanguageName(languageId: string): string {
const names: Record<string, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
python: 'Python',
shell: 'Shell',
batch: 'Batch',
powershell: 'PowerShell',
c: 'C',
cpp: 'C++',
java: 'Java',
kotlin: 'Kotlin',
go: 'Go',
rust: 'Rust',
ruby: 'Ruby',
php: 'PHP',
perl: 'Perl',
lua: 'Lua',
r: 'R',
swift: 'Swift',
dart: 'Dart',
csharp: 'C#',
fsharp: 'F#',
vb: 'Visual Basic',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
sass: 'Sass',
less: 'Less',
json: 'JSON',
jsonc: 'JSON with Comments',
json5: 'JSON5',
xml: 'XML',
yaml: 'YAML',
toml: 'TOML',
ini: 'INI',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
plaintext: 'Plain Text',
vue: 'Vue',
svelte: 'Svelte',
dockerfile: 'Dockerfile',
makefile: 'Makefile',
diff: 'Diff',
};
return names[languageId] || languageId.charAt(0).toUpperCase() + languageId.slice(1);
}
/**
* File opener application types
* - 'builtin-editor': Built-in text editor (Monaco)
* - 'system-app': External system application (stores path)
*/
export type FileOpenerType = 'builtin-editor' | 'system-app';
/**
* System application info for file associations
*/
export interface SystemAppInfo {
path: string; // Path to the executable/app
name: string; // Display name
}
/**
* File association record
*/
export interface FileAssociation {
extension: string;
openerType: FileOpenerType;
systemApp?: SystemAppInfo; // Only set when openerType is 'system-app'
}
/**
* Get all supported language IDs for syntax highlighting dropdown
*/
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Represents a file or directory entry from drag-and-drop
* This includes the relative path for nested files in folders
*/
export interface DropEntry {
file: File | null; // null for directory entries
relativePath: string; // Path relative to the root of the drop (e.g., "folder/subfolder/file.txt")
isDirectory: boolean;
}
/**
* Read entries from a FileSystemDirectoryEntry recursively
* Uses the webkitGetAsEntry API to access folder contents
*/
function readDirectoryEntries(
directoryReader: FileSystemDirectoryReader
): Promise<FileSystemEntry[]> {
return new Promise((resolve, reject) => {
const allEntries: FileSystemEntry[] = [];
const readBatch = () => {
directoryReader.readEntries(
(entries) => {
if (entries.length === 0) {
resolve(allEntries);
} else {
allEntries.push(...entries);
// Continue reading (readEntries may not return all entries at once)
readBatch();
}
},
(error) => reject(error)
);
};
readBatch();
});
}
/**
* Convert a FileSystemEntry to a File
*/
function entryToFile(entry: FileSystemFileEntry): Promise<File> {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
/**
* Recursively process a FileSystemEntry and collect all files
* @param entry - The file system entry to process
* @param basePath - The base path (folder name) to prepend
* @returns Array of DropEntry objects with files and their relative paths
*/
async function processEntry(
entry: FileSystemEntry,
basePath: string = ""
): Promise<DropEntry[]> {
const results: DropEntry[] = [];
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
try {
const file = await entryToFile(fileEntry);
results.push({
file,
relativePath: basePath ? `${basePath}/${entry.name}` : entry.name,
isDirectory: false,
});
} catch (error) {
console.warn(`Failed to read file entry: ${entry.name}`, error);
}
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const currentPath = basePath ? `${basePath}/${entry.name}` : entry.name;
// Add a placeholder for the directory itself (to ensure it gets created)
results.push({
file: null, // Directories don't have file content
relativePath: currentPath,
isDirectory: true,
});
try {
const reader = dirEntry.createReader();
const entries = await readDirectoryEntries(reader);
// Helper to yield to main thread - prevents UI freezing during large folder parsing
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
// Process all entries in the directory with periodic yielding
for (let i = 0; i < entries.length; i++) {
// Yield every 10 entries to keep UI responsive
if (i > 0 && i % 10 === 0) {
await yieldToMain();
}
const childEntry = entries[i];
const childResults = await processEntry(childEntry, currentPath);
results.push(...childResults);
}
} catch (error) {
console.warn(`Failed to read directory: ${entry.name}`, error);
}
}
return results;
}
/**
* Extract all files and directories from a DataTransfer object
* Supports both regular files and folders dropped from the OS
*
* Uses the webkitGetAsEntry() API for folder access, with fallback
* to regular FileList for browsers that don't support it.
*
* @param dataTransfer - The DataTransfer object from a drop event
* @returns Array of DropEntry objects with files and relative paths
*/
export async function extractDropEntries(
dataTransfer: DataTransfer
): Promise<DropEntry[]> {
const items = dataTransfer.items;
const results: DropEntry[] = [];
// Check if webkitGetAsEntry is supported (for folder access)
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
// Collect all entries first (getAsEntry must be called synchronously)
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry();
if (entry) {
entries.push(entry);
}
}
}
// Now process entries asynchronously
for (const entry of entries) {
const entryResults = await processEntry(entry);
results.push(...entryResults);
}
} else {
// Fallback: use regular FileList (no folder support)
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
results.push({
file,
relativePath: file.name,
isDirectory: false,
});
}
}
return results;
}

90
lib/useRenderTracker.ts Normal file
View File

@@ -0,0 +1,90 @@
import { useRef } from "react";
import { logger } from "./logger";
/**
* 追踪组件渲染次数和原因
* 在开发环境下帮助识别不必要的重渲染
*
* @param componentName 组件名称
* @param props 当前 props用于比较变化
* @param enabled 是否启用追踪,默认 true
*/
export function useRenderTracker(
componentName: string,
props: Record<string, unknown>,
enabled: boolean = true
): void {
const renderCountRef = useRef(0);
const prevPropsRef = useRef<Record<string, unknown>>({});
renderCountRef.current += 1;
if (!enabled) return;
const renderCount = renderCountRef.current;
const prevProps = prevPropsRef.current;
// 找出变化的 props
const changedProps: string[] = [];
const allKeys = new Set([...Object.keys(props), ...Object.keys(prevProps)]);
for (const key of allKeys) {
if (prevProps[key] !== props[key]) {
changedProps.push(key);
}
}
// 只在有变化时打印(减少日志噪音)
if (renderCount === 1) {
logger.info(`[Render] ${componentName} - 首次渲染`);
} else if (changedProps.length > 0) {
logger.info(`[Render] ${componentName} - 第${renderCount}次渲染`, {
changedProps,
details: changedProps.reduce((acc, key) => {
acc[key] = {
prev: summarizeValue(prevProps[key]),
curr: summarizeValue(props[key]),
};
return acc;
}, {} as Record<string, { prev: string; curr: string }>),
});
}
// 不再打印 "props未变化" 的警告 - 这是正常的 React 行为
// 更新 prevProps
prevPropsRef.current = { ...props };
}
/**
* 简化值的显示,避免日志过长
*/
function summarizeValue(value: unknown): string {
if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "function") return `fn:${value.name || "anonymous"}`;
if (typeof value === "object") {
if (Array.isArray(value)) return `Array(${value.length})`;
const keys = Object.keys(value);
if (keys.length <= 3) {
return `{${keys.join(", ")}}`;
}
return `Object(${keys.length} keys)`;
}
if (typeof value === "string" && value.length > 30) {
return `"${value.slice(0, 30)}..."`;
}
return String(value);
}
/**
* 简单的渲染计数器,只记录渲染次数不做详细分析
*/
export function useRenderCount(componentName: string): number {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
// 每次渲染都打印
logger.info(`[Render] ${componentName} - 第${renderCountRef.current}次渲染`);
return renderCountRef.current;
}

View File

@@ -1,6 +1,15 @@
import { type ClassValue,clsx } from "clsx"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Normalize line endings to LF (Unix style).
* Converts CRLF (Windows) and standalone CR (old Mac) to LF.
* Used for clipboard paste operations in terminal to avoid extra blank lines.
*/
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}

4485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,25 @@
{
"name": "netcatty",
"description": "Netcatty is a modern SSH manager and terminal app with host grouping, SFTP, keychain, port forwarding, and a rich UI.",
"homepage": "https://github.com/binaricat/Netcatty",
"private": true,
"version": "0.0.0",
"type": "module",
"author": "binaricat",
"author": "binaricat <support@netcatty.com>",
"license": "GPL-3.0-or-later",
"main": "electron/main.cjs",
"scripts": {
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
"prebuild": "node scripts/copy-monaco.cjs",
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-builder install-app-deps",
"lint": "eslint .",
@@ -28,6 +30,7 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
@@ -44,9 +47,11 @@
"@xterm/xterm": "^5.5.0",
"clsx": "2.1.1",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
"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",
@@ -72,4 +77,4 @@
"vite": "^7.2.7",
"wait-on": "^9.0.3"
}
}
}

BIN
public/dmg-background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
public/dmg-fix-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 KiB

Some files were not shown because too many files have changed in this diff Show More