Compare commits

...

149 Commits

Author SHA1 Message Date
bincxz
6edc4213f4 feat(sftp): show download progress in SftpView transfer queue
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
- Expose addExternalUpload and updateExternalUpload methods from useSftpState
- Add download task to transfer queue when starting stream download
- Update progress during download with transferred bytes, total, and speed
- Update task status on completion, failure, or cancellation

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-31 02:08:11 +08:00
陈大猫
4313977bd4 feat: stream-based SFTP download for large files (#151)
* feat: stream-based SFTP download for large files

- Add showSaveDialog API for native file save dialog
- Modify handleDownload to use streaming transfer for remote files
- Show save dialog first, then stream directly to disk
- Avoid loading entire file into memory
- Fallback to memory-based download for local files or when streaming unavailable

This fixes the issue where downloading large files would cause high memory
usage as the entire file was loaded into memory before saving.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* feat: stream-based download for SftpView

- Add getSftpIdForConnection to useSftpState for accessing SFTP session IDs
- Modify handleDownloadFileForSide in useSftpViewFileOps to use streaming
- Pass showSaveDialog, startStreamTransfer to SftpView hook chain
- For remote SFTP files: show save dialog then stream directly to disk
- For local files: fallback to memory-based download

This extends the stream download optimization to SftpView (dual-pane browser),
not just SFTPModal.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: improve download task handling and cancellation

- Add per-task cancellation for downloads via onCancelTask prop
- Add i18n translation keys for download status messages
- Prevent duplicate error toasts with errorHandled flag
- Add bridge capability check (result === undefined)
- Make direction field required in TransferTask interface

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

---------

Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-31 02:03:18 +08:00
陈大猫
dae58ef64f Merge pull request #150 from binaricat/fix/reset-upload-trigger-on-new-entries
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: reset upload trigger when new drop entries arrive
2026-01-30 22:26:29 +08:00
bincxz
945a09bdef fix: reset upload trigger when new drop entries arrive
Track the previous initialEntriesToUpload reference and reset the
upload trigger flag when a new array is provided. This fixes the issue
where subsequent drag-to-upload operations would be ignored if the
SFTP modal remained open after the first upload completed.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 22:05:43 +08:00
Copilot
4711fea969 Add drag-and-drop support for terminal file path insertion and SFTP upload (#149)
* Initial plan

* Add drag-and-drop functionality for terminal

- Local terminal: Insert absolute file paths when files are dropped
- Remote terminal: Open SFTP modal and trigger file upload
- Add visual feedback with drag overlay
- Support both files and folders
- Add translations for English and Chinese

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Remove unintended package-lock.json changes

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Improve folder path handling in drag-and-drop

- Extract folder path from nested files when folder is dropped
- Show folder path once instead of individual file paths
- Handle both Windows and Unix path separators

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: address code review feedback for drag-and-drop

- Replace fixed 500ms delay with proper connection state check
  (wait for files to load instead of arbitrary timeout)
- Add duplicate upload prevention with ref flag
- Simplify redundant ternary (dropEffect was always 'copy')
- Improve folder path extraction logic for better reliability

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: open SFTP at current directory when dropping files

Get the current working directory from terminal session before opening
SFTP modal for file uploads, matching the behavior of the SFTP toolbar button.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: preserve directory structure when uploading folders via drag-drop

- Pass DropEntry[] instead of File[] to preserve relativePath info
- Add handleUploadEntries function that uses uploadEntriesDirect
- This maintains folder structure when uploading directories to remote

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: support drag-drop upload to empty remote directories

Use loading state transition detection instead of files.length check
to determine when SFTP connection is ready. This fixes the issue where
drag-drop uploads to empty directories would silently fail because
files.length was always 0.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: preserve empty directories in terminal drop uploads

Pass full dropEntries array including directory markers to SFTP upload
instead of filtering to only file entries. This ensures empty folders
are created on the remote side via uploadEntriesDirect which uses
isDirectory entries to call ensureDirectory.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: remove unused handleUploadMultiple import

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* refactor: improve drag-drop code readability

- Extract path extraction logic into extractRootPathsFromDropEntries function
- Add comment explaining flushSync usage for state synchronization
- Remove redundant dropEntries.length check (already checked earlier)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 21:41:26 +08:00
陈大猫
f59ca56e23 Merge pull request #148 from binaricat/fix/keyword-highlight-scroll
fix: refresh keyword decorations after scroll stops
2026-01-30 17:48:27 +08:00
bincxz
3d1ab2de05 fix: refresh keyword decorations after scroll stops
The onScroll event may not fire for all scroll methods (e.g., mouse wheel).
Add onRender listener to detect viewport position changes and trigger
decoration refresh when scrolling stops.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:27:04 +08:00
陈大猫
adc3343d76 Merge pull request #147 from binaricat/fix/local-terminal-connection-dialog
fix: skip connection dialog for local terminal and show correct protocol label
2026-01-30 17:17:04 +08:00
bincxz
944d590162 fix: use telnetPort for telnet connections in dialog
Telnet connections store their port in host.telnetPort, not host.port.
Refactored getProtocolInfo to return the correct port for each protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:16:13 +08:00
bincxz
15ae17f918 fix: detect Mosh via moshEnabled flag for protocol label
Mosh sessions use protocol: "ssh" with moshEnabled: true, so check
moshEnabled first before falling back to host.protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:12:28 +08:00
bincxz
65c15d8931 fix: only use protocol to determine local connection
Remove hostname === "localhost" check to avoid incorrectly treating
SSH connections to localhost as local terminal connections.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:59:31 +08:00
bincxz
cbd1c84cdf fix: address code review feedback
- Reuse component-level isLocalConnection/isSerialConnection in useEffect
- Add i18n support for protocol labels (en/zh-CN)
- Use correct default port per protocol (SSH: 22, Telnet: 23, Mosh: 22)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:50:34 +08:00
bincxz
0839e41b07 fix: skip connection dialog for local terminal and show correct protocol label
- Local and serial connections no longer show connection dialog during connecting phase
- Connection dialog now displays correct protocol label based on host.protocol
  (SSH, Telnet, Mosh, Local Shell, Serial) instead of hardcoded "SSH"
- Removed unnecessary timeout/progress UI for local terminal connections

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:36:58 +08:00
陈大猫
c27788280c Merge pull request #145 from RiceWays/feature/folder-upload
Feature/folder upload
2026-01-30 16:09:25 +08:00
bincxz
fd7f516b00 fix: P3 review issues - icon consistency and popover auto-close
- Use FolderUp icon for folder upload in context menu (matches toolbar)
- Auto-close encoding popover when an option is selected
- Add trailing newline to uploadService.ts

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:02:20 +08:00
bincxz
33780fecde refactor(sftp): compact encoding selector with icon button and popover
Replace wide dropdown encoding selector with a compact icon button (Languages)
that opens a popover menu. Also add tooltips to navigation buttons (up, home,
refresh).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:55:12 +08:00
bincxz
89ea1c43c5 refactor(sftp): convert toolbar buttons to icon-only with tooltips
Replace text+icon buttons with icon-only buttons and tooltips for Upload,
Upload Folder, New Folder, and New File actions. Uses more distinctive
icons (FolderPlus, FilePlus) and adds a new Tooltip component.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:49:40 +08:00
bincxz
bd2936aab2 refactor: move compressed upload setting from Terminal to SFTP tab
The folder compression transfer setting is SFTP-specific functionality,
so it makes more sense to place it alongside other SFTP settings like
double-click behavior, auto-sync, and show hidden files.

- Move setting from SettingsTerminalTab to SettingsFileAssociationsTab
- Add new i18n keys under settings.sftp.compressedUpload namespace
- Remove unused settings.terminal.uploadDownload translations
- Update SettingsPage props accordingly

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:34:22 +08:00
bincxz
c48ac93500 chore: clean up P3 review issues
- Remove debug console.log statements from preload.cjs
- Remove redundant try-catch block in compressUploadBridge.cjs
- Remove unused stdout variable in extractRemoteArchive
- Add missing trailing newlines to files

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:17:54 +08:00
bincxz
34a94df831 refactor: clean up debug logs and fix review issues
- Remove verbose console.log statements from upload components
- Add maximum timeout cap (10 min) for extraction to prevent hangs
- Fix cleanup race condition by checking connection state
- Prefix unused speed parameter with underscore
- Fix duplicate return statement and unnecessary hook dependencies

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:08:04 +08:00
bincxz
e87ce831b4 refactor(compressedUpload): simplify to auto-detect with toggle setting
- Replace 3-option setting (ask/enabled/disabled) with boolean toggle
- Remove CompressedUploadDialog component - no more user prompts
- Auto-detect tar availability and silently fallback to regular upload
- Apply compressed upload setting to drag-and-drop uploads
- Fix upload progress updates for compressed uploads via callback
- Add i18n for upload phase labels (compressing/uploading/extracting)
- Fix empty folder handling - fallback to regular mkdir
- Preserve error message on failed upload tasks for UI display
- Fix fallback logic to only re-upload failed folders, not successful ones
- Handle cancellation during all phases (compression/transfer/extraction)
- Fix filename display to use explicit phase markers instead of substring match
- Fix Toggle onChange prop for settings to work correctly
- Use DropEntry.relativePath for correct drag-drop folder paths
- Dynamic extraction timeout based on archive size (60s base + 30s per 10MB)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 14:35:47 +08:00
Rice
11c0c744f5 fix(compressUploadBridge): cancel associated transfer when compression is cancelled
- Add transfer cancellation logic to cancelCompression function
- Cancel the associated transfer using transferId pattern `compress-{compressionId}`
- Check for transferBridge availability before attempting cancellation
- Add error handling and logging for transfer cancellation failures
- Ensures cleanup of both compression process and related file transfer operations
2026-01-29 21:25:34 +08:00
陈大猫
9546f27ca1 Merge pull request #144 from Nightsuki/feat/managed-source-sync-improvements
feat(vault): improve managed source sync and host management
2026-01-29 21:17:38 +08:00
bincxz
8a465a9adf chore: remove unused currentHostLine variable
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 21:12:14 +08:00
Rice
f3b28d2283 feat(uploadService): add standalone file upload after compressed folder uploads
- Extract standalone file entries separately from folder entries during compressed uploads
- Add logic to upload standalone files using regular upload after compressed folders complete
- Combine results from both compressed folder uploads and standalone file uploads
- Ensure all files are uploaded correctly when mixed with compressed folder uploads
- This allows proper handling of mixed file and folder uploads in a single operation
2026-01-29 20:57:10 +08:00
bincxz
8cfa62d945 fix(vault): remove managed sources atomically when clearing multiple
Add clearAndRemoveSources function that clears multiple SSH config files
in parallel but removes all sources in a single atomic update. This
prevents race conditions where concurrent removals could re-add sources
that were already deleted.

When deleting a group path with multiple managed sources, the batch
removal is now used instead of Promise.all with individual removals.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:52:02 +08:00
bincxz
b31ea0b9ca fix(vault): preserve Match blocks when merging SSH config
Match blocks were being dropped because they have no host patterns,
causing flush() to treat them as fully-managed blocks. Now explicitly
track Match blocks and always preserve them since we don't manage them.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:21:12 +08:00
bincxz
b2f6cabd75 fix(vault): thread managedSources through to HostDetailsPanel in all contexts
Pass managedSources prop through SelectHostPanel and all components that
use it (SnippetsManager, PortForwarding, KeychainManager) so hosts created
in these contexts can properly receive managedSourceId when placed in a
managed group.

This ensures hosts added from any panel will sync back to managed SSH
config files.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:13:14 +08:00
bincxz
92af5a5675 fix(vault): check all group sources when generating managed group name
When generating a unique managed group name, now check against:
- Existing managed sources
- Custom groups
- Existing host groups

This prevents accidentally reusing an existing group name which could
merge unrelated hosts into the managed group.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 19:41:20 +08:00
bincxz
d50e854cbe fix(vault): batch lastSyncedAt updates to prevent race conditions
When multiple managed sources sync concurrently via Promise.all, each
sync was independently updating the managedSources array, causing race
conditions where one source's lastSyncedAt would be overwritten.

Now syncManagedSource returns success status, and lastSyncedAt updates
are batched in a single update after all syncs complete.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 19:28:44 +08:00
bincxz
d92dbd6091 fix(vault): include managedSources in VaultView memo comparator
Add managedSources to vaultViewAreEqual so managed source changes
(import, unmanage, rename) trigger proper re-renders and update
managed badges/actions in the UI.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 18:25:46 +08:00
bincxz
3732bce989 fix(vault): only strip label spaces for SSH hosts in managed groups
Non-SSH hosts (telnet, etc.) in managed groups should keep their labels
unchanged since they cannot be synced to SSH config. The label space
sanitization now checks canBeManaged before modifying the label.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 18:10:40 +08:00
bincxz
ec0994288f fix(vault): preserve SSH config file contents when unmanaging group
The "Unmanage" action now only removes the source association without
modifying the SSH config file. This prevents data loss when users want
to stop syncing but keep their host entries in the file.

Use onUnmanageSource instead of onClearAndRemoveManagedSource for the
unmanage flow. The clearAndRemove variant is still available for cases
where file cleanup is explicitly desired (e.g., deleting a managed group).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 17:57:04 +08:00
bincxz
b14c5d6147 fix(vault): preserve SSH config comments and validate managed file path
- Preserve top-level comments and blank lines when merging SSH config
  by tracking preamble content before the first Host/Match block
- Validate file path availability before enabling managed sync; show
  error if getPathForFile and file.path are both unavailable instead
  of falling back to bare filename which would sync to wrong location

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 17:07:34 +08:00
bincxz
c55f5dbdb8 fix(vault): preserve non-managed patterns in multi-host SSH blocks
When a Host line contains multiple patterns (e.g., "Host prod1 prod2")
and only some are managed, now only the managed patterns are removed
while non-managed patterns are preserved with the block's directives.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:50:44 +08:00
bincxz
a7f3008904 fix(vault): preserve non-managed SSH config and bracket IPv6 in ProxyJump
- Keep preserved SSH config content outside managed block markers to
  prevent data loss when first bringing existing config under management
- Always bracket IPv6 addresses in ProxyJump values regardless of port,
  as OpenSSH requires brackets to disambiguate colon separators

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:40:49 +08:00
bincxz
6833000038 fix(vault): remove duplicate Host blocks when managing existing SSH config
When creating a managed source from an existing SSH config file without
Netcatty markers, the code was appending the managed block without removing
original Host entries. This left duplicate Host blocks, and since OpenSSH
uses the first matching block, edits via Netcatty wouldn't take effect.

Now uses mergeWithExistingSshConfig to filter out existing Host blocks
that match managed hostnames before wrapping with markers.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:30:32 +08:00
bincxz
485f28160d fix(vault): clear managed SSH config block when unmanaging group
Use onClearAndRemoveManagedSource to write an empty managed block
before removing the source, preventing stale entries in the SSH
config file after unmanaging a group.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:49:29 +08:00
bincxz
9df9f9fdfb fix(vault): wrap IPv6 addresses in brackets for ProxyJump with port
IPv6 addresses like 2001:db8::1 need brackets when appending a port,
otherwise SSH cannot parse the address correctly (colons are ambiguous).
Now outputs [2001:db8::1]:2222 instead of 2001:db8::1:2222.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:30:58 +08:00
陈大猫
b2720d1fd5 Merge pull request #146 from RiceWays/feature/Start-command-package
feat(HostDetailsPanel): replace Input with Textarea for startup command
2026-01-29 15:28:51 +08:00
bincxz
71419b65cd fix(vault): unique managed group names and correct space stripping
- Generate unique managed group names by adding suffix when conflicts exist
- Only strip spaces from label based on target group, not form.managedSourceId
- Check protocol when determining if label spaces should be stripped

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:19:42 +08:00
bincxz
ba935099c4 fix(vault): handle duplicate hosts and initial sync on managed import
- Filter duplicates on managed import and convert existing hosts to managed
- Trigger initial sync when prevHosts is empty but managed sources exist
- Update previousHostsRef even when no managed sources to maintain baseline

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 14:40:47 +08:00
Rice
1a45d39c98 feat(HostDetailsPanel): replace Input with Textarea for startup command
- Replace Input component with Textarea for startup command field
- Update className to use min-h-[80px] with font-mono and text-sm styling
- Add rows={3} prop to Textarea for better multi-line command support
- Import Textarea component from ui/textarea module
- Improve UX for entering longer or multi-line startup commands
2026-01-29 14:17:43 +08:00
bincxz
3f06cb638a fix(vault): detect changes to external jump hosts in managed sync
When a managed host references a jump host outside its managed source,
changes to that external jump host now trigger a sync. This ensures
ProxyJump entries stay up-to-date when jump hosts are edited.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 14:01:09 +08:00
bincxz
a225f0e207 fix(vault): sanitize ProxyJump aliases and fall back to hostname
- Sanitize Host aliases and ProxyJump references by removing spaces
- Only use label as ProxyJump alias if jump host is in managed hosts
- Fall back to hostname for jump hosts outside managed config

This ensures ProxyJump directives reference valid, resolvable hosts.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:54:56 +08:00
bincxz
3438f4bc88 fix(vault): include hostChain in managed source change detection
ProxyJump changes now trigger sync since hostChain.hostIds is compared
alongside other host fields like hostname, port, username, etc.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:45:10 +08:00
bincxz
9343cfda84 fix(vault): only allow SSH hosts to be managed in SSH config sync
Non-SSH protocol hosts (telnet, serial, local) are now correctly
excluded from managed source assignment since SSH config files only
support the SSH protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:41:06 +08:00
Rice
89b5b2f6b1 fix(compressUploadBridge): escape shell arguments to prevent injection attacks
- Add escapeShellArg() utility function to safely wrap arguments in single quotes
- Escape targetDir and archivePath in extractRemoteArchive() command construction
- Escape targetPath in cleanup command for ._* files removal
- Escape remoteArchivePath in archive cleanup command
- Replace double-quoted arguments with properly escaped single-quoted arguments throughout shell commands
- Prevents potential shell injection vulnerabilities when paths contain special characters or malicious input
2026-01-29 13:40:04 +08:00
bincxz
d080c805c2 feat(vault): serialize ProxyJump directive in managed SSH config sync
When syncing managed hosts to SSH config files, properly serialize the
hostChain to ProxyJump directive instead of just adding a comment.

- Add serializeJumpHost() to format jump host as [user@]host[:port]
- Add buildProxyJumpValue() to convert hostChain.hostIds to ProxyJump
- Pass allHosts to serializer for looking up jump host details
- Supports multiple jump hosts (comma-separated)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:30:22 +08:00
bincxz
4b41b2c20f fix(vault): sync managed file before removing source on group delete
When deleting a managed group, clear the managed block in the SSH config
file before removing the source. This prevents stale host entries from
remaining in the file after the group is deleted.

- Add clearAndRemoveSource function to useManagedSourceSync
- Pass callback to VaultView and call it before removing managed sources
- Ensures SSH config files stay in sync when managed groups are deleted

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:21:06 +08:00
Rice
62c4aa3ea6 fix(uploadService): improve folder path extraction and cross-platform compatibility
- Declare tempArchivePath in outer scope for proper cleanup access in error handlers
- Add path separator normalization to handle both forward and backslash separators
- Improve folder path extraction logic to correctly identify the target folder path
- Add fallback pattern matching for both Unix and Windows path separators
- Preserve original path separator style when reconstructing folder paths
- Enhance robustness of path parsing for cross-platform file uploads
2026-01-29 13:13:03 +08:00
Rice
5d164b4150 feat(uploadService): add compression cancellation tracking to UploadController
- Add activeCompressionIds Set to track ongoing compression operations
- Implement addActiveCompression() and removeActiveCompression() methods for lifecycle management
- Update cancel() method to iterate through active compression IDs and call cancelCompressedUpload()
- Enhance getActiveTransferIds() to include compression IDs alongside file transfer IDs
- Clear activeCompressionIds in reset() method to ensure clean state
- Register compression ID with controller before starting folder compression
- Add cancellation checks before and during compression progress updates
- Remove compression ID from tracking on completion or error
- Distinguish between cancellation and error states in result handling
- Improve logging to separately track file transfer IDs and compression IDs
- Enables proper cancellation of compressed uploads when user cancels the operation
2026-01-29 13:06:49 +08:00
Rice
ac62d571ef fix(uploadService): improve error handling and regex escaping in compressed uploads
- Fix regex pattern escaping in trailing slash removal (use forward slash instead of escaped forward slash)
- Declare taskId outside try block to ensure it's accessible in catch block for proper error handling
- Update onTaskFailed callback to pass taskId instead of folderName for consistency with task tracking
- Add guard condition to only call onTaskFailed when taskId exists (task was successfully created)
- Prevents undefined taskId from being passed to error callbacks and improves error state management
2026-01-29 12:51:19 +08:00
Rice
e8d060c62f fix(uploadService): improve folder path extraction logic for compressed uploads
- Refactor folder path calculation to handle nested directory structures correctly
- Remove the filename-based extraction approach in favor of relativePath-based logic
- Add fallback mechanisms to handle edge cases where relativePath doesn't match localFilePath
- Implement folder name-based path detection as secondary fallback strategy
- Preserve original logic as last resort for single file scenarios
- Fix issue where deeply nested folders were not correctly identified during compression
2026-01-29 12:31:59 +08:00
bincxz
653164bee8 fix(vault): choose most specific managed source for nested groups
When multiple managed sources match a group path (nested managed groups),
select the one with the longest groupName (deepest/most specific match)
instead of the first match. This ensures hosts are assigned to the
correct managed source and sync to the right SSH config file.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 12:27:48 +08:00
Rice
2a67667c0b feat(uploadService): add fallback handling for compressed upload failures
- Add try-catch wrapper around uploadFoldersCompressed to handle compression failures gracefully
- Implement fallback to regular upload when compressed upload is not supported
- Check for failed folders in compressed results and trigger full fallback if needed
- Return error indicator from uploadFoldersCompressed instead of attempting inline fallback
- Improve error logging to distinguish between compression support issues and other failures
- Ensure all entries are uploaded via regular upload path when compression is unavailable
- This prevents upload failures when the server doesn't support compressed folder uploads
2026-01-29 12:27:05 +08:00
Rice
e07e5cf442 feat(sftp): add compressed folder upload with settings
- Add CompressedUploadDialog component to let users choose between compressed and regular transfer methods
- Implement compressUploadService for handling folder compression and extraction on the server
- Add compressUploadBridge to expose compression functionality to the renderer process
- Add sftpUseCompressedUpload setting with three modes: ask, enabled, disabled
- Add new upload progress states: compressing, extracting, scanning, completed
- Add i18n translations for upload dialog and settings in English and Chinese
- Update SFTP modal to support compressed upload workflow with progress tracking
- Add storage key for persisting compressed upload preference across sessions
- Significantly reduces transfer time for folders by using tar compression when available on server
2026-01-29 12:18:02 +08:00
bincxz
92a9eed6bf fix(vault): use ref for managedSources to avoid stale closure in sync
Use a ref to access the latest managedSources when updating lastSyncedAt
after sync completes. This prevents overwriting concurrent changes made
while a sync was in flight (e.g., user unmanaging a source or importing
a new managed file).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 12:16:18 +08:00
bincxz
65afa21711 fix(vault): remove managed sources under deleted parent group
When deleting a parent group, also remove all managed sources whose
groupName is under that path (not just exact matches). This prevents
stale managed entries from remaining after parent group deletion.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:57:49 +08:00
bincxz
f413ccfba1 fix(vault): preserve managedSourceId when managedSources prop is not provided
When HostDetailsPanel is used in contexts that don't pass managedSources
(e.g., SelectHostPanel), preserve the existing managedSourceId instead of
clearing it. This ensures hosts created/edited in managed groups retain
their sync relationship.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:37:13 +08:00
bincxz
58b6879c71 fix(vault): remove leaked config and preserve managedSourceId for subgroups
- Remove config file containing real SSH hosts (security)
- When deleting a subgroup under a managed group, keep managedSourceId
  so hosts remain managed and continue syncing to SSH config

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:21:07 +08:00
bincxz
7fe7193344 fix(vault): preserve SSH config content and sync group renames
- Use marker blocks to preserve existing SSH config directives (Match, Include, Host *, etc.)
- Only replace content between BEGIN/END NETCATTY MANAGED markers
- Update managedSources.groupName when groups are renamed or moved
- Prevents data loss for users with custom SSH config entries

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:10:36 +08:00
bincxz
7a19b73f54 fix(vault): address Codex review feedback for managed source sync
- Add protocol to change detection to trigger sync when protocol changes
- Sanitize labels (remove spaces) when moving hosts to managed groups via drag/drop
- Prevent duplicate managed imports by checking if file is already managed
- Add i18n keys for already managed file warning

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 10:44:34 +08:00
bincxz
5160230426 fix(vault): handle pending syncs and strip spaces from managed host labels
- Add pending sync tracking to process host changes that occur during sync
- Strip spaces from labels when host is/will be in a managed source group
- Remove unused FileSymlink import

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 10:21:33 +08:00
Rice
42b1a808a1 feat(sftp): add folder upload functionality
- Add i18n translations for "Upload folder" in English and Chinese locales
- Add folderInputRef to track folder input element in SFTPModal component
- Implement handleFolderSelect handler for processing folder uploads
- Add "Upload folder" button to SftpModalHeader with FolderUp icon
- Add hidden file input with webkitdirectory attribute for folder selection
- Update SftpModalFileList context menu to include folder upload option
- Pass folderInputRef and folder handlers through component hierarchy
- Enable users to upload entire folder structures via SFTP modal
2026-01-29 09:40:03 +08:00
Nightsuki
9dd3db4c14 feat(vault): improve managed source sync and host management
- Fix managed host sync: add group field to change detection
- Auto-set managedSourceId when moving host to managed group
- Add 'managed' badge to managed hosts in VaultView
- Fix file path resolution for managed sources using webUtils
- Add managedSources prop to HostDetailsPanel for proper sync
- Restrict spaces in label for managed hosts
- Reorder HostDetailsPanel sections: General > Address > Port & Credentials
- Add debug logging for managed source sync troubleshooting
2026-01-29 00:07:14 +08:00
陈大猫
e74f65729c Merge pull request #143 from binaricat/feat/snippet-package-rename
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(snippets): add rename functionality for packages
2026-01-28 20:23:29 +08:00
bincxz
97f53ed87f fix(snippets): sync editingSnippet state when renaming packages
Update editingSnippet.package when the package being edited is renamed
or is nested under a renamed package. This prevents the stale package
path from being persisted when the user saves their edits after a rename.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 20:12:00 +08:00
bincxz
ec4512eb06 feat(snippets): add rename functionality for packages
Add context menu option to rename snippet packages with a modal dialog.
Includes validation for empty names, duplicate names (case-insensitive),
and invalid characters (only letters, numbers, hyphens, underscores allowed).

When a package is renamed, all nested packages and snippets are updated
to reflect the new path.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:52:54 +08:00
陈大猫
93c1f1b427 Merge pull request #142 from RiceWays/fix/code-package-grouping
fix: Fix multiple bugs in code package creation and display
2026-01-28 19:26:50 +08:00
bincxz
58ccd4bfb9 fix(snippets): normalize trailing slashes in package paths
Strip trailing slashes before saving package paths to ensure consistent
path handling across the UI. This prevents issues where 'foo/' would not
match 'foo' in the package browser.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:16:02 +08:00
bincxz
2fb82e1cb7 Update SnippetsManager component 2026-01-28 18:32:22 +08:00
bincxz
159589a09f fix: persist implicit parent paths when selected in package dropdown
Address Codex review: when selecting a parent path from the package
dropdown that was generated from existing child packages (e.g., /foo
derived from /foo/bar), the path is now added to the packages array.
This prevents orphaned snippets when the child package is deleted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 18:18:59 +08:00
Rice
04e1ed569d fix(snippets): preserve absolute path prefix in breadcrumb navigation
- Detect if selected package path is absolute (starts with '/')
- Reconstruct breadcrumb paths with leading slash when applicable
- Prevent loss of absolute path context when navigating package hierarchy
- Ensures consistent path handling between package selection and breadcrumb display
2026-01-28 17:32:54 +08:00
Rice
38fb5e8dd4 fix(snippets): normalize snippet path construction to prevent double slashes
- Strip leading slash from snippet names when creating paths inside packages
- Preserve leading slash for snippets created at root level
- Prevent double slashes in constructed package paths (e.g., "package//snippet")
- Improve path handling consistency between root and nested snippet creation
2026-01-28 17:22:50 +08:00
Rice
6f2b27206a fix(snippets): improve package name validation regex pattern
- Update validation regex to allow hyphens anywhere in package names
- Simplify regex pattern from `^\/?\w+([\w/-]*\w+)*\/?$` to `^\/?([\w-]+(\/[\w-]+)*)\/?$`
- Update HTML input pattern attribute to match validation logic
- Improve comment clarity to reflect hyphen handling in package names
- Ensures consistent validation between JavaScript regex and HTML5 pattern attribute
2026-01-28 17:11:01 +08:00
Rice
f6eb693fac refactor(snippets): improve package path handling and filtering logic
- Separate absolute paths (starting with /) from relative paths for clearer processing
- Process relative and absolute paths independently with distinct handling logic
- Add type annotations to filter callbacks for better type safety
- Simplify path matching logic by removing redundant checks for both slash variants
- Display absolute paths with "/" prefix to distinguish them from relative paths
- Improve code readability by extracting path processing into separate sections
- Maintain backward compatibility while fixing edge cases in package hierarchy
2026-01-28 16:58:42 +08:00
Rice
32935e4e87 fix: Fix multiple bugs in code package creation and display
## Overview
This PR addresses multiple critical bugs in code package creation and display functionality, and includes validation enhancements and performance optimizations to improve overall stability and user experience.

## Fixed Bugs
- Fixed issue where package paths starting with a slash (e.g., /name/xx/xx) failed to display
- Fixed package count showing only direct code snippets instead of including nested package content
- Fixed path conflict bug in movePackage() caused by improper string replacement
- Fixed dropdown selector displaying only full paths (missing parent path options)
- Added package name validation to block invalid characters and duplicate package names
- Optimized package deletion performance by only saving actually modified code snippets
- Added support for creating packages in /100/200/300 format, with dropdown selector showing all hierarchical paths

## Key Improvements
* displayedPackages: Correctly handle slash-leading paths and accurately calculate nested package counts
* createPackage: Added regex validation and duplicate check, support paths starting with a slash
* movePackage: Replaced replace() with substring() to avoid substring-based path conflicts
* packageOptions: Automatically generate all parent path options, sorted by depth and alphabetical order
* deletePackage: Improved performance by only persisting actually modified code snippets (instead of full dataset)
2026-01-28 16:49:45 +08:00
陈大猫
f55c21fc0e Merge pull request #140 from RiceWays/feature/vault-tree-view-mode
feat: Add tree view mode for host list with sorting and persistence
2026-01-28 16:07:06 +08:00
bincxz
26d03ace3f fix: improve tree view UX and address code review feedback
- Display folders above ungrouped hosts in tree view
- Change host connection from double-click to single-click
- Sanitize host before connecting to handle whitespace in hostname
- Guard optional host.tags to prevent crash on legacy data
- Show telnet-specific credentials (telnetUsername/telnetPort) for telnet hosts
- Remove unused groupTree variable and prefix unused moveHostToGroup param

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 15:40:45 +08:00
Rice
d85709d42d fix: apply search and tag filters to grouped hosts in tree view
- Use filtered treeViewHosts instead of all hosts when building tree view group tree
- Ensure grouped hosts respect search queries and tag filters
- Reorder useMemo dependencies to fix circular dependency issue
- Now tree view filtering behavior is consistent with grid and list views

Fixes issue where grouped hosts would still appear even when they didn't
match active search or tag filters, breaking the expected filtering UX.
2026-01-28 12:31:43 +08:00
Rice
5470e19ae0 chore: remove obsolete TODO comment 2026-01-28 11:16:31 +08:00
Rice
cd2c18b77c feat: add tree view mode for host list with sorting and persistence
- Add tree view mode alongside existing grid and list views
- Implement hierarchical display of hosts organized by groups
- Add expand/collapse all controls with Chinese translations
- Support all sorting modes (A-Z, Z-A, newest, oldest) in tree view
- Persist expand/collapse state across view switches and app restarts
- Hide Groups section in tree view to avoid duplication
- Display ungrouped hosts at root level instead of "General" group
- Add missing delete group dialog with proper translations
- Maintain full functionality: search, filtering, drag-drop, context menus

Technical changes:
- Create HostTreeView component with TreeNode recursive structure
- Add useTreeExpandedState hook for persistent state management
- Extend ViewMode type to include "tree" option
- Add sortMode prop to enable dynamic sorting in tree structure
- Separate group tree logic for tree view vs other view modes
- Add comprehensive English and Chinese translations
2026-01-28 11:13:17 +08:00
陈大猫
7355e29b89 Merge pull request #137 from Nightsuki/fix/ssh-jump-host-default-key-auth
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: add default SSH key fallback for jump host connections
2026-01-27 17:07:50 +08:00
bincxz
64686cc237 fix: pass unlocked encrypted keys to jump host auth handler
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.

Previously, options._unlockedEncryptedKeys was only used for the
final target host, so jump hosts requiring encrypted default keys
would still fail even after successful passphrase entry.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:54:54 +08:00
bincxz
d65440ace7 feat: add passphrase modal for encrypted SSH key authentication
- Add PassphraseModal component for interactive passphrase input
- Add passphraseHandler bridge to manage passphrase requests/responses
- Add sshAuthHelper for centralized SSH key decryption with passphrase support
- Update sshBridge, sftpBridge, and portForwardingBridge to use new auth helper
- Add passphrase-related IPC channels in preload and type definitions
- Add i18n translations for passphrase modal UI (en/zh-CN)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:35:03 +08:00
Nightsuki
2dbeddd9aa fix: add default SSH key fallback for jump host connections
Previously, jump host connections (connectThroughChain) did not try
default SSH keys from ~/.ssh/ when no explicit auth was configured.
This caused authentication failures when using jump hosts without
manually specifying SSH keys.

Changes:
- Add ssh-agent support for jump host connections
- Try all default SSH keys (id_ed25519, id_ecdsa, id_rsa) for jump hosts
- Use dynamic authHandler to try each key in sequence
- Match the same fallback behavior as direct connections
2026-01-27 11:55:18 +08:00
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
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: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
陈大猫
a66fcdba02 Merge pull request #133 from binaricat/fix/mfa-partial-success-auth
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: handle partialSuccess in SSH multi-factor authentication
2026-01-26 16:10:08 +08:00
bincxz
73c95fa08e fix: handle partialSuccess in SSH multi-factor authentication
When servers require multi-step authentication (e.g., password + MFA, or
publickey + keyboard-interactive), the previous implementation did not
properly handle the partialSuccess flag from ssh2's authHandler callback.

This caused MFA-only servers to fail connection because keyboard-interactive
was not triggered after the initial auth method succeeded with partialSuccess.

Changes:
- Add partialSuccess handling to try server-requested auth methods
- Track attempted methods to avoid re-trying failed or already-used methods
- Cache the first successful method (not the last) for multi-step flows
  to ensure correct auth order on subsequent connections

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:08:55 +08:00
陈大猫
3337cd620e Merge pull request #132 from binaricat/fix/ssh-key-fallback-auth
fix: improve SSH authentication fallback to system keys
2026-01-26 15:37:04 +08:00
bincxz
97bd105564 fix: reorder auth methods - password before agent
Agent may be auto-configured via SSH_AUTH_SOCK rather than explicit
user choice. On servers with PubkeyAuthentication disabled or low
MaxAuthTries, the agent attempt could exhaust auth tries before the
valid password is attempted.

New order: user key -> password -> agent -> default key -> keyboard-interactive

This follows ssh2's default order (None -> Password -> Private Key -> Agent)
more closely and prioritizes explicit user configuration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:29:53 +08:00
bincxz
554c43dfa8 fix: avoid logging agent object which may contain private keys
When connectOpts.agent is a NetcattyAgent (for certificate auth),
it contains _meta with privateKey/passphrase. Logging the full object
would leak credentials to log files. Now only logs a safe identifier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:19:36 +08:00
bincxz
c678f36504 fix: set privateKey when adding publickey fallback in agent mode
ssh2's simple auth handler (array mode) only enables publickey auth
when connectOpts.privateKey is set. Without setting the key, the
"publickey" entry in auth order would be silently skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:11:40 +08:00
bincxz
f40a3f075b fix: include agent auth method in dynamic authHandler fallback
The dynamic authHandler for fallback authentication was missing the
"agent" type, which broke agentForwarding functionality. This fix:
- Adds "agent" to the default availableMethods list
- Updates methodName mapping to treat "agent" as "publickey" (since
  agent-based auth uses publickey verification under the hood)
- Adds handler case for agent type that returns "agent" string
- Checks both methodName and method.type for availability

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:02:42 +08:00
bincxz
bb40ab464e fix: return string for keyboard-interactive in authHandler
ssh2 requires a prompt function when returning an object for
keyboard-interactive auth. Without it, the method is skipped.

Return the string "keyboard-interactive" instead, which lets ssh2
use its default handling and properly trigger the keyboard-interactive
event for 2FA/MFA prompts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:45:21 +08:00
bincxz
4977add389 fix: avoid retrying same default key twice
When no explicit auth method is configured, the default key was being
promoted to connectOpts.privateKey and then added again as publickey-default.
This caused the same key to be attempted twice, wasting auth slots.

Now track when default key is used as primary to skip redundant fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:39:21 +08:00
bincxz
2d14655af4 fix: improve SSH authentication fallback to system keys
- Always search for default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa)
  as fallback authentication method
- Add dynamic authHandler that tries multiple auth methods in sequence:
  user key -> password -> default system key -> keyboard-interactive
- Cache successful auth methods per host to speed up subsequent connections
- Clear auth cache on failure to retry all methods
- Fix password validation to only use non-empty strings
- Add detailed logging for auth flow debugging

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:30:55 +08:00
陈大猫
025df8788b Merge pull request #131 from binaricat/fix/context-menu-shortcuts-from-settings
fix: display actual user-configured shortcuts in terminal context menu
2026-01-26 14:08:51 +08:00
bincxz
9e6d110766 fix: display actual user-configured shortcuts in terminal context menu
Previously, the keyboard shortcuts shown in the right-click context menu
were hardcoded and did not reflect user's custom keybindings from settings.

Changes:
- Pass keyBindings prop from Terminal to TerminalContextMenu
- Dynamically look up shortcuts from user's configured keybindings
- Format shortcuts with spaces between keys for better readability
- Handle 'Disabled' shortcuts by hiding the shortcut hint

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:01:48 +08:00
陈大猫
347d0a445b Merge pull request #130 from binaricat/feat/copy-tab-context-menu
feat: add Copy Tab option to SSH session context menu
2026-01-26 13:48:28 +08:00
bincxz
e8be0d72de feat: add Copy Tab option to SSH session context menu
Add the ability to duplicate an SSH session by right-clicking on a tab
and selecting "Copy Tab". This creates a new session with the same
connection parameters (host, port, protocol, etc.).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:41:11 +08:00
陈大猫
ce34f1bba8 Merge pull request #129 from binaricat/fix/sftp-large-file-upload-and-cancel
fix: use stream-based transfers to prevent OOM and support cancellation
2026-01-26 13:32:24 +08:00
bincxz
9f4272f83c fix: use getPathForFile directly for nested folder files
The previous approach tried to reconstruct paths for nested files using
filePathMap keyed by f.name (base file names), but for folder drops
rootName is the folder name which doesn't exist in the map.

Now we call getPathForFile directly on each result.file, which should
work for all files in Electron. The filePathMap reconstruction is kept
as a fallback.

This ensures large files inside dropped folders use stream transfers
instead of falling back to arrayBuffer() which causes OOM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:17:46 +08:00
bincxz
c158d52dd5 fix: handle close event for local writeStream cancellation
When fs.createWriteStream is destroyed, it emits 'close' but not 'finish'.
Added close event handlers for downloadWithStreams and local-to-local
copy to properly resolve the promise when cancelled.

The 'finished' flag in cleanup() ensures we don't call resolve/reject twice
when both finish and close fire during normal completion.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:08:50 +08:00
bincxz
ec8dba360c fix: ensure stream cancellation settles the promise
When streams are destroyed during cancellation, the close/finish event
handler was not calling cleanup if transfer.cancelled was true. This left
the promise pending forever, causing the UI to stay stuck in "uploading".

Now we call cleanup(new Error('Transfer cancelled')) when the stream
closes/finishes and the transfer was cancelled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:00:33 +08:00
bincxz
8b5cc5c302 fix: use stream-based transfers to prevent OOM and support cancellation
- Replace memory-based file uploads with stream transfers for large files
- Add uploadWithStreams and downloadWithStreams functions in transferBridge
- Fix cancel transfer by properly destroying streams instead of throwing
  errors in callbacks (which corrupted SSH connection)
- Fix upload button not triggering upload by copying FileList before
  clearing input (clearing input also clears FileList reference)
- Export getPathForFile utility for obtaining local file paths
- Add startStreamTransfer and cancelTransfer bridge methods

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:24:58 +08:00
陈大猫
bae0c078f5 Merge pull request #126 from binaricat/fix/terminal-blackscreen-on-rightclick-setting-change
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: prevent terminal blackscreen when changing right-click behavior
2026-01-23 11:04:58 +08:00
bincxz
e0cda4dc5a fix: prevent terminal blackscreen when changing right-click behavior
The TerminalContextMenu component previously returned different JSX
structures based on rightClickBehavior setting:
- context-menu mode: <ContextMenu> wrapper
- other modes: <div> wrapper

This caused React to unmount and remount the entire Terminal subtree
when the setting changed, destroying the xterm instance and causing
a black screen.

Fix: Always use <ContextMenu> as the wrapper to maintain consistent
React tree structure. Control behavior via:
- disabled prop on ContextMenuTrigger
- onContextMenu handler for non-menu modes
- Conditional rendering of ContextMenuContent

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:57:27 +08:00
陈大猫
4c0fc897a0 Merge pull request #125 from binaricat/fix/sftp-date-format-and-vault-general-group
fix: improve SFTP date format and hide General group in vault
2026-01-23 10:41:50 +08:00
bincxz
9ba150de82 fix: preserve user-created General group in vault
Address Codex review: only hide "General" group at root level when it's
auto-generated (not in customGroups and has no subgroups). This ensures
user-created "General" groups and their subtrees remain accessible.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:33:48 +08:00
bincxz
a78647a2e8 fix: improve SFTP date format and hide General group in vault
- Change SFTP date format from ISO 8601 to readable YYYY-MM-DD HH:mm
- Fix lastModifiedFormatted to use formatDate instead of raw ISO string
- Hide "General" group at root level in vault (hosts already shown below)
- Fix General group filter to match hosts with empty/undefined group
- Exclude .github/** from ESLint (CI scripts don't need local linting)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:18:38 +08:00
陈大猫
4b7812d27f Merge pull request #124 from binaricat/codex/format-date-in-yyyy-mm-dd-hh-mm-ss 2026-01-23 04:25:18 +08:00
陈大猫
62b3cf658e Format SFTP timestamps consistently 2026-01-23 04:13:06 +08:00
陈大猫
74401a2084 Merge pull request #123 from binaricat/codex/fix-github-action-error-in-script 2026-01-23 03:14:24 +08:00
陈大猫
44d25c10e1 Fix release note script for ESM 2026-01-23 03:11:56 +08:00
bincxz
d67c458730 Merge branch 'feat/host-export-and-improvements' 2026-01-23 02:41:45 +08:00
bincxz
44e8167300 refactor: improve toast notifications and credentials copy format
- Use toast.success/warning instead of toast({title:}) for better UX
- Change credentials copy format to labeled multi-line format
- Add ESLint global declarations for Node.js globals

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:40:32 +08:00
陈大猫
02b16dee9b Merge pull request #122 from binaricat/feat/host-export-and-improvements
feat: add host export, password visibility, copy credentials and shortcut fixes
2026-01-23 02:31:54 +08:00
bincxz
adaa8ee524 fix: export telnet username correctly in CSV export
For telnet hosts, use telnetUsername instead of username since the UI
stores telnet credentials separately from SSH credentials.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:16:58 +08:00
bincxz
f429eb8f28 fix: export mosh-enabled hosts as SSH instead of skipping them
Exporting partial data (SSH without mosh flag) is better than completely
losing the host entry. Only serial hosts are now skipped since they truly
cannot be represented in CSV format.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:08:10 +08:00
bincxz
eaae884cd7 fix: also skip moshEnabled hosts in CSV export
Mosh hosts are typically represented as protocol=ssh with moshEnabled=true,
not protocol=mosh. The export filter now also skips hosts with moshEnabled
to prevent data loss when the mosh setting would be silently dropped on import.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:57:57 +08:00
bincxz
363f0ea87f fix: only use telnet credentials when protocol is explicitly telnet
When copying credentials, only treat host as telnet when protocol is
explicitly set to "telnet". Having telnetEnabled=true just means telnet
is available as an alternative protocol, not the primary one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:49:00 +08:00
bincxz
b5533a73b6 fix: bracket IPv6 addresses when copying credentials with non-default port
When copying credentials for hosts with IPv6 addresses and non-default ports,
the address is now properly formatted as [2001:db8::1]:2222 instead of the
ambiguous 2001:db8::1:2222.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:39:45 +08:00
bincxz
6353f2c58a fix: skip mosh hosts in CSV export
Address Codex review: mosh hosts are now filtered out during CSV export
alongside serial hosts, as the CSV import's normalizeProtocol only
recognizes ssh/telnet/local. Exporting mosh hosts would silently convert
them to SSH on re-import.

Updated toast message to say "unsupported hosts" instead of "serial hosts"
since both serial and mosh are now skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:32:51 +08:00
bincxz
7e14f73769 fix: bracket IPv6 hostnames in CSV export for round-trip compatibility
Address Codex review: IPv6 addresses are now wrapped in brackets when
exported to CSV (e.g., [2001:db8::1]). This ensures they can be correctly
parsed on re-import, as the CSV import parser treats unbracketed colons
as port separators.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:22:53 +08:00
bincxz
60c2687144 fix: use telnet-specific credentials when copying
Address Codex review: for telnet protocol hosts, now uses telnetUsername,
telnetPassword, and telnetPort instead of the SSH credentials. This ensures
the copied credentials match what the telnet connection actually uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:15:07 +08:00
bincxz
7a1597bdc1 fix: export telnet hosts with correct telnetPort
Address Codex review: for telnet protocol hosts, now exports telnetPort
instead of port (which is the SSH port). This ensures that re-importing
the CSV will preserve the correct telnet connection port.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:58:20 +08:00
bincxz
d5ae6e5cba fix: reset password visibility when switching hosts
Address Codex review: the showPassword state is now reset to false
when initialData changes, ensuring each host's password defaults
to masked mode. This prevents a privacy issue where switching hosts
would keep the password visible if it was previously shown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:46:02 +08:00
bincxz
a46080a378 fix: exclude serial hosts from CSV export
Address Codex review: serial hosts (protocol === "serial") are now
filtered out during CSV export since the CSV import format doesn't
support serial port configuration. Importing serial hosts would result
in invalid SSH entries.

When serial hosts are skipped, users are notified via a toast message
showing how many were excluded.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:31:12 +08:00
bincxz
f0972cc6c1 fix: prevent CSV formula injection in host export
Address Codex security review: values starting with =, +, -, @, tab or
carriage return are now prefixed with a single quote to prevent
spreadsheet applications from interpreting them as formulas.

This mitigates CSV injection attacks when exported files are opened
in Excel, Google Sheets, or similar applications.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:08:15 +08:00
bincxz
1442b42d66 fix: resolve credentials from identity when copying host credentials
Address Codex review feedback: when a host is configured with an identityId,
the copy credentials function now correctly resolves username and password
from the identity first, falling back to host fields if not available.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:55:28 +08:00
bincxz
24f1dc3f36 feat: add host export, password visibility toggle, and copy credentials
- Add export button to export hosts to CSV file using import template format
- Add eye icon to toggle password visibility in host edit panel
- Add "Copy Credentials" option to host context menu for sharing host info
- Make label field optional when creating new host (defaults to hostname/IP)
- Fix duplicate keyboard shortcuts:
  - clear-buffer: changed from ⌘+K to ⌘+⌃+K (Mac) and Ctrl+L to Ctrl+Shift+K (PC)
  - open-sftp: changed from ⌘+Shift+S to ⌘+Shift+O (Mac) and Ctrl+Shift+O (PC)
  - snippets: changed PC shortcut from Ctrl+Alt+S to Ctrl+Shift+S to match Mac

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:40:23 +08:00
陈大猫
b0251e1eaf Merge pull request #121 from binaricat/fix/release-note-version
fix: align release-note version with built artifacts
2026-01-22 23:19:55 +08:00
bincxz
f55a1a4c15 Revert "fix: accept any v* tag to match build.yml trigger pattern"
This reverts commit d4b64d564b.
2026-01-22 23:07:39 +08:00
bincxz
d4b64d564b fix: accept any v* tag to match build.yml trigger pattern
The workflow triggers on `v*` tags and build.yml extracts version from
any v-prefixed tag. Updated the regex from `^v\d+\.\d+\.\d+` to `^v\d`
to accept tags like v1, v1.2, v1.2.3, etc.

This ensures version parsing stays in sync with build.yml behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:06:12 +08:00
bincxz
64d3b1f26a fix: set version in build job for both tag and workflow_dispatch
- Tag release: use version from tag (e.g., v1.2.3 -> 1.2.3)
- workflow_dispatch: use short commit ID (first 7 chars)

This ensures electron-builder artifacts match the release notes
download links in all scenarios.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
bincxz
f6148d3578 fix: use short commit ID as version fallback before package.json
Version priority is now:
1. VERSION env variable
2. Valid version tag (v1.2.3 format)
3. Short commit ID (first 7 chars)
4. package.json version as final fallback

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
bincxz
c4d6d999c1 fix: align release-note version with built artifact version
When workflow is triggered via workflow_dispatch, GITHUB_REF_NAME is
the branch name (e.g., "main") instead of a version tag. This caused
the generated release notes to have incorrect download URLs.

Now the script:
1. Checks if GITHUB_REF_NAME is a valid version tag (v1.2.3 format)
2. Falls back to reading version from package.json if not

This ensures the release notes always match the actual artifact
filenames produced by electron-builder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
github-actions[bot]
2ca5c730b8 Merge remote-tracking branch 'upstream/main' 2026-01-22 14:06:42 +00:00
TachibanaLolo
b3a2063ca4 ci: add upstream sync workflow 2026-01-22 22:05:44 +08:00
TachibanaLolo
e6f2da48a7 ci: remove unused artifacts (zip, blockmap, yml) from upload 2026-01-22 21:59:35 +08:00
TachibanaLolo
a9fad5295c docs: simplify platform support table in all languages 2026-01-22 21:55:55 +08:00
TachibanaLolo
41822838f1 docs: update readme with platform support and new features 2026-01-22 21:54:13 +08:00
TachibanaLolo
f98c578761 Remove Android download placeholder from release notes 2026-01-22 21:42:57 +08:00
TachibanaLolo
449d63ca3e feat: enhance release workflow and sftp sudo support 2026-01-22 21:40:19 +08:00
71 changed files with 8062 additions and 1039 deletions

104
.github/scripts/generate-release-note.js vendored Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine version priority:
// 1. VERSION env variable
// 2. Valid version tag (v1.2.3 format)
// 3. Short commit ID (first 7 chars of GITHUB_SHA)
// 4. package.json version as fallback
function getVersion() {
if (process.env.VERSION) {
return process.env.VERSION;
}
const refName = process.env.GITHUB_REF_NAME;
// Check if refName is a valid version tag (e.g., v1.2.3)
if (refName && /^v\d+\.\d+\.\d+/.test(refName)) {
return refName.replace(/^v/, '');
}
// Use short commit ID
const sha = process.env.GITHUB_SHA;
if (sha) {
return sha.substring(0, 7);
}
// Fall back to package.json version
try {
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return pkg.version;
} catch {
return '0.0.0';
}
}
const version = getVersion();
const repo = process.env.GITHUB_REPOSITORY || 'binaricat/netcatty';
// For tag releases, use the tag; for workflow_dispatch, create a tag from version
const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.GITHUB_REF_NAME))
? process.env.GITHUB_REF_NAME
: `v${version}`;
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
},
linux: {
appimage: {
x64: `Netcatty-${version}-linux-x64.AppImage`,
arm64: `Netcatty-${version}-linux-arm64.AppImage`
},
deb: {
x64: `Netcatty-${version}-linux-x64.deb`,
arm64: `Netcatty-${version}-linux-arm64.deb`
},
rpm: {
x64: `Netcatty-${version}-linux-x64.rpm`,
arm64: `Netcatty-${version}-linux-arm64.rpm`
}
}
};
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
intel: `[![DMG Intel X64](https://img.shields.io/badge/DMG-Intel_X64-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.x64})`
},
linux: {
appimage_x64: `[![AppImage x64](https://img.shields.io/badge/AppImage-x64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.x64})`,
appimage_arm64: `[![AppImage arm64](https://img.shields.io/badge/AppImage-arm64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.arm64})`,
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
}
};
const content = `
## Download based on your OS:
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;
fs.writeFileSync('release_notes.md', content);
console.log('Generated release_notes.md');

View File

@@ -37,11 +37,16 @@ jobs:
- name: Install deps
run: npm ci
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
- name: Set version
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -70,15 +75,12 @@ jobs:
name: netcatty-${{ matrix.os }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.blockmap
release/latest*.yml
if-no-files-found: ignore
release:
@@ -101,20 +103,23 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.msi
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.tar.gz
artifacts/*.blockmap
artifacts/latest*.yml
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

42
.github/workflows/sync.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Sync Upstream
env:
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
UPSTREAM_BRANCH: "main"
TARGET_BRANCH: "main"
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight
workflow_dispatch: # Allow manual trigger
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge Upstream
run: |
echo "Adding upstream remote..."
git remote add upstream ${{ env.UPSTREAM_URL }}
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
echo "Pushing changes..."
git push origin ${{ env.TARGET_BRANCH }}

97
App.tsx
View File

@@ -1,6 +1,7 @@
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 { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
@@ -21,6 +22,7 @@ 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 { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
@@ -155,6 +157,8 @@ function App({ settings }: { settings: SettingsState }) {
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
@@ -184,6 +188,7 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts,
shellHistory,
connectionLogs,
managedSources,
updateHosts,
updateKeys,
updateIdentities,
@@ -191,6 +196,7 @@ function App({ settings }: { settings: SettingsState }) {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
@@ -242,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews,
openLogView,
closeLogView,
copySession,
} = useSessionState();
// isMacClient is used for window controls styling
@@ -267,6 +274,12 @@ function App({ settings }: { settings: SettingsState }) {
},
});
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
hosts,
managedSources,
onUpdateManagedSources: updateManagedSources,
});
const handleSyncNowManual = useCallback(() => {
return handleSyncNow({ trigger: 'manual' });
}, [handleSyncNow]);
@@ -348,6 +361,76 @@ function App({ settings }: { settings: SettingsState }) {
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Passphrase request event listener for encrypted SSH keys
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
console.log('[App] Passphrase request received:', request);
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
}]);
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseTimeout) return;
const unsubscribe = bridge.onPassphraseTimeout((event) => {
console.log('[App] Passphrase request timed out:', event.requestId);
// Remove from queue - the modal will close automatically
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
// Show a toast notification to inform user
toast.error('Passphrase request timed out. Please try connecting again.');
});
return () => {
unsubscribe?.();
};
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -864,6 +947,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -888,6 +972,7 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts={knownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
@@ -902,6 +987,10 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
onUpdateKnownHosts={updateKnownHosts}
onUpdateManagedSources={updateManagedSources}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
@@ -1081,6 +1170,14 @@ function App({ settings }: { settings: SettingsState }) {
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
</div>
);
}

View File

@@ -98,6 +98,8 @@
### 📁 SFTP
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
- **ドラッグ&ドロップ** アップロードおよびダウンロード
- **ドラッグ&ドロップ**ファイル転送
- **キュー管理**でバッチ転送
- **進捗追跡**、転送速度表示
@@ -278,11 +280,11 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
| OS | サポート状況 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。

View File

@@ -98,7 +98,8 @@
### 📁 SFTP
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
- **Drag & drop** file transfers
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
- **Drag & Drop** uploads and downloads
- **Queue management** for batch transfers
- **Progress tracking** with transfer speed
@@ -278,11 +279,11 @@ Netcatty automatically detects and displays OS icons for connected hosts:
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 |
| OS | Support |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).

View File

@@ -98,6 +98,8 @@
### 📁 SFTP
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
- **拖放操作** —— 支持上传和下载
- **拖放传输** 文件
- **队列管理** 批量传输
- **进度跟踪** 显示传输速度
@@ -278,11 +280,11 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
| 操作系统 | 支持情况 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。

View File

@@ -39,6 +39,7 @@ const en: Messages = {
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
@@ -313,6 +314,11 @@ const en: Messages = {
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
'vault.groups.renameDialogTitle': 'Rename Group',
'vault.groups.renameDialog.desc': 'Rename an existing group.',
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.ungrouped': 'Ungrouped',
'vault.groups.field.name': 'Group Name',
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
@@ -320,6 +326,9 @@ const en: Messages = {
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
'vault.hosts.header.entries': '{count} entries',
'vault.hosts.header.live': '{count} live',
@@ -328,10 +337,26 @@ const en: Messages = {
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
'vault.hosts.multiSelect': 'Multi-select',
'vault.hosts.selected': '{count} selected',
'vault.hosts.selectAll': 'Select All',
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -348,6 +373,18 @@ const en: Messages = {
'vault.import.toast.summary':
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
'vault.import.toast.firstIssue': 'First issue: {issue}',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
'vault.import.sshConfig.managed': 'Managed Sync',
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
// Known Hosts
'knownHosts.search.placeholder': 'Search known hosts...',
@@ -440,8 +477,12 @@ const en: Messages = {
'sftp.columns.kind': 'Kind',
'sftp.columns.actions': 'Actions',
'sftp.emptyDirectory': 'Empty directory',
'sftp.nav.up': 'Go up',
'sftp.nav.home': 'Go to home',
'sftp.nav.refresh': 'Refresh',
'sftp.upload': 'Upload',
'sftp.uploadFiles': 'Upload files',
'sftp.uploadFolder': 'Upload folder',
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
@@ -527,6 +568,12 @@ const en: Messages = {
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Compressing',
'sftp.upload.phase.uploading': 'Uploading',
'sftp.upload.phase.extracting': 'Extracting',
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
@@ -591,10 +638,19 @@ const en: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.compressing': 'Compressing...',
'sftp.upload.extracting': 'Extracting...',
'sftp.upload.scanning': 'Scanning files...',
'sftp.upload.completed': 'Completed',
'sftp.upload.compressed': 'Compressed Transfer',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
// SFTP Download
'sftp.download.completed': 'Downloaded',
'sftp.download.cancelled': 'Download cancelled',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
@@ -610,6 +666,12 @@ const en: Messages = {
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
@@ -662,6 +724,8 @@ const en: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
'hostDetails.password.hide': 'Hide password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
@@ -810,6 +874,13 @@ const en: Messages = {
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
@@ -840,6 +911,11 @@ const en: Messages = {
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
@@ -1101,6 +1177,7 @@ const en: Messages = {
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
@@ -1152,6 +1229,14 @@ const en: Messages = {
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Rename Package',
'snippets.renameDialog.currentPath': 'Current path: {path}',
'snippets.renameDialog.placeholder': 'Enter new name',
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
@@ -1206,6 +1291,16 @@ const en: Messages = {
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
};
export default en;

View File

@@ -27,6 +27,7 @@ const zhCN: Messages = {
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
@@ -184,6 +185,11 @@ const zhCN: Messages = {
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
'vault.groups.renameDialogTitle': '重命名分组',
'vault.groups.renameDialog.desc': '重命名已有分组。',
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.ungrouped': '未分组',
'vault.groups.field.name': '分组名称',
'vault.groups.placeholder.example': '例如Production',
'vault.groups.parentLabel': '父级',
@@ -191,6 +197,9 @@ const zhCN: Messages = {
'vault.groups.errors.required': '分组名称不能为空。',
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
'vault.hosts.header.entries': '{count} 条',
'vault.hosts.header.live': '{count} 个在线',
@@ -199,10 +208,26 @@ const zhCN: Messages = {
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
'vault.hosts.multiSelect': '多选',
'vault.hosts.selected': '已选择 {count} 项',
'vault.hosts.selectAll': '全选',
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -217,6 +242,18 @@ const zhCN: Messages = {
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
'vault.import.toast.firstIssue': '首个问题:{issue}',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
'vault.import.sshConfig.managed': '托管同步',
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
// Known Hosts
'knownHosts.search.placeholder': '搜索已知主机...',
@@ -293,8 +330,12 @@ const zhCN: Messages = {
'sftp.columns.kind': '类型',
'sftp.columns.actions': '操作',
'sftp.emptyDirectory': '空目录',
'sftp.nav.up': '返回上层',
'sftp.nav.home': '返回主目录',
'sftp.nav.refresh': '刷新',
'sftp.upload': '上传',
'sftp.uploadFiles': '上传文件',
'sftp.uploadFolder': '上传文件夹',
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
@@ -409,6 +450,8 @@ const zhCN: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.show': '显示密码',
'hostDetails.password.hide': '隐藏密码',
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
@@ -528,6 +571,13 @@ const zhCN: Messages = {
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
@@ -559,6 +609,11 @@ const zhCN: Messages = {
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '串口',
'terminal.connection.protocol.local': '本地终端',
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
@@ -779,6 +834,12 @@ const zhCN: Messages = {
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
'sftp.upload.phase.compressing': '正在压缩',
'sftp.upload.phase.uploading': '正在上传',
'sftp.upload.phase.extracting': '正在解压',
'sftp.upload.phase.compressed': '压缩传输',
// SFTP File Opener
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
@@ -843,10 +904,19 @@ const zhCN: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.compressing': '正在压缩...',
'sftp.upload.extracting': '正在解压...',
'sftp.upload.scanning': '正在扫描文件...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '压缩传输',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
// SFTP Download
'sftp.download.completed': '已下载',
'sftp.download.cancelled': '下载已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
@@ -862,6 +932,12 @@ const zhCN: Messages = {
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
@@ -1090,6 +1166,7 @@ const zhCN: Messages = {
'tabs.closeLogViewAria': '关闭日志视图',
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
@@ -1141,6 +1218,14 @@ const zhCN: Messages = {
'snippets.packageDialog.placeholder': '例如ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重命名代码包',
'snippets.renameDialog.currentPath': '当前路径:{path}',
'snippets.renameDialog.placeholder': '输入新名称',
'snippets.renameDialog.error.empty': '代码包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',
@@ -1195,6 +1280,16 @@ const zhCN: Messages = {
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
'passphrase.desc': '请输入 {keyName} 的密码',
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
'passphrase.label': '密码',
'passphrase.keyPath': '密钥',
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
};
export default zhCN;

View File

@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize } from "./utils";
import { formatFileSize, formatDate } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
@@ -18,13 +18,14 @@ export const useSftpDirectoryListing = () => {
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
};
@@ -40,13 +41,14 @@ export const useSftpDirectoryListing = () => {
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});

View File

@@ -344,6 +344,25 @@ export const useSftpExternalOperations = (
}
: undefined,
cancelSftpUpload: bridge?.cancelSftpUpload,
// Stream transfer for large files (avoids loading into memory)
startStreamTransfer: bridge?.startStreamTransfer
? async (options, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.startStreamTransfer) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
try {
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
return result;
} catch (error) {
return {
transferId: options.transferId,
error: error instanceof Error ? error.message : String(error),
};
}
}
: undefined,
cancelTransfer: bridge?.cancelTransfer,
};
}, []);

View File

@@ -11,13 +11,9 @@ export const formatFileSize = (bytes: number): string => {
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
if (isNaN(date.getTime())) return "--";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
export const getFileExtension = (name: string): string => {

View File

@@ -0,0 +1,358 @@
import { useCallback, useEffect, useRef } from "react";
import { Host, ManagedSource } from "../../domain/models";
import {
serializeHostsToSshConfig,
mergeWithExistingSshConfig,
} from "../../domain/sshConfigSerializer";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
export interface UseManagedSourceSyncOptions {
hosts: Host[];
managedSources: ManagedSource[];
onUpdateManagedSources: (sources: ManagedSource[]) => void;
}
export const useManagedSourceSync = ({
hosts,
managedSources,
onUpdateManagedSources,
}: UseManagedSourceSyncOptions) => {
const previousHostsRef = useRef<Host[]>([]);
const syncInProgressRef = useRef(false);
// Keep a ref to the latest managedSources to avoid stale closure issues
const managedSourcesRef = useRef(managedSources);
managedSourcesRef.current = managedSources;
const getManagedHostsForSource = useCallback(
(sourceId: string) => {
return hosts.filter((h) => h.managedSourceId === sourceId);
},
[hosts],
);
const readExistingFileContent = useCallback(
async (filePath: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) {
return null;
}
try {
const buffer = await bridge.readLocalFile(filePath);
const decoder = new TextDecoder();
return decoder.decode(buffer);
} catch {
// File might not exist yet
return null;
}
},
[],
);
const mergeWithExistingContent = useCallback(
(
existingContent: string | null,
managedHosts: Host[],
allHosts: Host[],
): string => {
// Serialize the managed hosts
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
if (!existingContent) {
// No existing file, just wrap the managed content
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
}
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
// No existing managed block - need to remove duplicate Host entries
// Build a set of hostnames/aliases that will be managed
const managedHostnameSet = new Set<string>();
for (const host of managedHosts) {
if (!host.protocol || host.protocol === "ssh") {
// Add both hostname and sanitized label (alias) for matching
managedHostnameSet.add(host.hostname.toLowerCase());
if (host.label) {
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
}
}
}
// Use mergeWithExistingSshConfig to filter out existing Host blocks
// that match our managed hosts, keeping preserved content outside markers
const mergedContent = mergeWithExistingSshConfig(
existingContent,
managedHosts,
managedHostnameSet,
allHosts,
);
return mergedContent;
}
// Replace the existing managed block
const before = existingContent.substring(0, beginIndex);
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
},
[],
);
const writeSshConfigToFile = useCallback(
async (source: ManagedSource, managedHosts: Host[]) => {
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) {
console.warn("[ManagedSourceSync] writeLocalFile not available");
return false;
}
try {
// Read existing file content to preserve non-managed parts
const existingContent = await readExistingFileContent(source.filePath);
// Merge with existing content, preserving non-managed parts and removing duplicates
const finalContent = mergeWithExistingContent(
existingContent,
managedHosts,
hosts,
);
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
const encoder = new TextEncoder();
const buffer = encoder.encode(finalContent);
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
console.log(`[ManagedSourceSync] Write successful`);
return true;
} catch (err) {
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
return false;
}
},
[readExistingFileContent, mergeWithExistingContent, hosts],
);
const syncManagedSource = useCallback(
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
const managedHosts = getManagedHostsForSource(source.id);
const success = await writeSshConfigToFile(source, managedHosts);
return { sourceId: source.id, success };
},
[getManagedHostsForSource, writeSshConfigToFile],
);
const unmanageSource = useCallback(
(sourceId: string) => {
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources],
);
// Clear the managed block in the SSH config file and then remove the source
// This should be called before deleting a managed group to avoid stale entries
const clearAndRemoveSource = useCallback(
async (source: ManagedSource) => {
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
// Write empty hosts list to clear the managed block
const success = await writeSshConfigToFile(source, []);
if (success) {
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
}
// Remove the source regardless of write success
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
onUpdateManagedSources(updatedSources);
return success;
},
[onUpdateManagedSources, writeSshConfigToFile],
);
// Clear and remove multiple sources atomically to avoid race conditions
// when multiple sources are removed concurrently
const clearAndRemoveSources = useCallback(
async (sources: ManagedSource[]) => {
if (sources.length === 0) return;
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
// Clear all files in parallel
const results = await Promise.all(
sources.map(async (source) => {
const success = await writeSshConfigToFile(source, []);
return { sourceId: source.id, success };
})
);
const successCount = results.filter(r => r.success).length;
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
// Remove all sources atomically in a single update
const sourceIdsToRemove = new Set(sources.map(s => s.id));
const updatedSources = managedSourcesRef.current.filter(
(s) => !sourceIdsToRemove.has(s.id)
);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources, writeSshConfigToFile],
);
const pendingSyncRef = useRef(false);
const checkAndSyncRef = useRef<() => void>(() => {});
const checkAndSync = useCallback(() => {
if (managedSources.length === 0) {
// Still update previousHostsRef so we have a baseline when sources are added
previousHostsRef.current = hosts;
return;
}
const prevHosts = previousHostsRef.current;
previousHostsRef.current = hosts;
// On initial sync (prevHosts empty), sync all sources that have managed hosts
const isInitialSync = prevHosts.length === 0;
const changedSourceIds = new Set<string>();
if (isInitialSync) {
// Initial sync: sync all sources that have hosts
for (const source of managedSources) {
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
if (currManaged.length > 0) {
changedSourceIds.add(source.id);
}
}
} else {
// Build maps for all hosts (for jump host lookup)
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
// Helper to check if a host's SSH-relevant fields changed
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
if (!prevHost || !currHost) return prevHost !== currHost;
return (
prevHost.hostname !== currHost.hostname ||
prevHost.port !== currHost.port ||
prevHost.username !== currHost.username ||
prevHost.label !== currHost.label
);
};
for (const source of managedSources) {
const prevManaged = prevHosts.filter((h) => h.managedSourceId === source.id);
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
if (prevManaged.length !== currManaged.length) {
changedSourceIds.add(source.id);
continue;
}
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
let sourceChanged = false;
for (const curr of currManaged) {
const prev = prevManagedMap.get(curr.id);
if (!prev) {
sourceChanged = true;
break;
}
// Compare hostChain arrays for ProxyJump changes
const prevChain = prev.hostChain?.hostIds || [];
const currChain = curr.hostChain?.hostIds || [];
const chainChanged =
prevChain.length !== currChain.length ||
prevChain.some((id, i) => id !== currChain[i]);
const hasChanged =
prev.hostname !== curr.hostname ||
prev.port !== curr.port ||
prev.username !== curr.username ||
prev.label !== curr.label ||
prev.group !== curr.group ||
prev.protocol !== curr.protocol ||
chainChanged;
if (hasChanged) {
sourceChanged = true;
break;
}
// Check if any referenced jump hosts changed (even if outside this managed source)
for (const jumpHostId of currChain) {
const prevJumpHost = prevHostMap.get(jumpHostId);
const currJumpHost = currHostMap.get(jumpHostId);
if (hostChanged(prevJumpHost, currJumpHost)) {
sourceChanged = true;
break;
}
}
if (sourceChanged) break;
}
if (sourceChanged) {
changedSourceIds.add(source.id);
}
}
}
if (changedSourceIds.size > 0) {
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
syncInProgressRef.current = true;
Promise.all(
managedSources
.filter((s) => changedSourceIds.has(s.id))
.map(syncManagedSource),
).then((results) => {
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
const successfulSourceIds = new Set(
results.filter(r => r.success).map(r => r.sourceId)
);
if (successfulSourceIds.size > 0) {
const currentSources = managedSourcesRef.current;
const now = Date.now();
const updatedSources = currentSources.map((s) =>
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
);
onUpdateManagedSources(updatedSources);
}
}).finally(() => {
syncInProgressRef.current = false;
// Check if there were changes during sync that need to be processed
// Use ref to get the latest checkAndSync to avoid stale closure
if (pendingSyncRef.current) {
pendingSyncRef.current = false;
checkAndSyncRef.current();
}
});
}
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
// Keep ref updated with the latest checkAndSync
checkAndSyncRef.current = checkAndSync;
useEffect(() => {
if (syncInProgressRef.current) {
// Mark that we need to re-sync after current sync completes
pendingSyncRef.current = true;
return;
}
checkAndSync();
}, [hosts, managedSources, checkAndSync]);
return {
syncManagedSource,
unmanageSource,
clearAndRemoveSource,
clearAndRemoveSources,
getManagedHostsForSource,
};
};

View File

@@ -547,6 +547,31 @@ export const useSessionState = () => {
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
serialConfig: session.serialConfig,
};
setActiveTabId(newSession.id);
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -662,5 +687,7 @@ export const useSessionState = () => {
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
};
};

View File

@@ -20,6 +20,7 @@ STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
@@ -49,6 +50,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
@@ -196,6 +198,13 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
const [sftpUseCompressedUpload, setSftpUseCompressedUpload] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
// 兼容旧的设置值
if (stored === 'true' || stored === 'enabled' || stored === 'ask') return true;
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
@@ -467,11 +476,18 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
if (newValue !== sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -540,6 +556,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -670,6 +692,8 @@ export const useSettingsState = () => {
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
availableFonts,
// Session Logs
sessionLogsEnabled,

View File

@@ -188,6 +188,15 @@ export const useSftpBackend = () => {
return bridge.selectApplication();
}, []);
const showSaveDialog = useCallback(async (
defaultPath: string,
filters?: Array<{ name: string; extensions: string[] }>
) => {
const bridge = netcattyBridge.get();
if (!bridge?.showSaveDialog) return null;
return bridge.showSaveDialog(defaultPath, filters);
}, []);
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
@@ -268,6 +277,7 @@ export const useSftpBackend = () => {
cancelSftpUpload,
onTransferProgress,
selectApplication,
showSaveDialog,
downloadSftpToTempAndOpen,
};
};

View File

@@ -61,6 +61,11 @@ export const useSftpState = (
// SFTP session refs
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
// Getter for sftpId from connectionId (for stream transfers)
const getSftpIdForConnection = useCallback((connectionId: string) => {
return sftpSessionsRef.current.get(connectionId);
}, []);
// Directory listing cache (connectionId + path)
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
@@ -274,11 +279,14 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
});
methodsRef.current = {
getFilteredFiles,
@@ -315,11 +323,14 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
};
// Create stable method wrappers that call through methodsRef
@@ -360,11 +371,14 @@ export const useSftpState = (
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
}), []); // Empty deps - these wrappers never change
// Return object with stable method references but reactive state

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export type ViewMode = "grid" | "list";
export type ViewMode = "grid" | "list" | "tree";
const isViewMode = (value: string | null): value is ViewMode =>
value === "grid" || value === "list";
value === "grid" || value === "list" || value === "tree";
export const useStoredViewMode = (
storageKey: string,

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export const useTreeExpandedState = (storageKey: string) => {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored) {
try {
const paths = JSON.parse(stored) as string[];
return new Set(paths);
} catch {
return new Set();
}
}
return new Set();
});
useEffect(() => {
const pathsArray = Array.from(expandedPaths);
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
}, [storageKey, expandedPaths]);
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const expandAll = (allPaths: string[]) => {
setExpandedPaths(new Set(allPaths));
};
const collapseAll = () => {
setExpandedPaths(new Set());
};
return {
expandedPaths,
togglePath,
expandAll,
collapseAll,
};
};

View File

@@ -6,6 +6,7 @@ import {
Identity,
KeyCategory,
KnownHost,
ManagedSource,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -22,6 +23,7 @@ import {
STORAGE_KEY_KEYS,
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
@@ -95,6 +97,7 @@ export const useVaultState = () => {
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
@@ -132,6 +135,11 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
}, []);
const updateManagedSources = useCallback((data: ManagedSource[]) => {
setManagedSources(data);
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
@@ -140,6 +148,7 @@ export const useVaultState = () => {
updateSnippetPackages([]);
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
@@ -149,6 +158,7 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
]);
const addShellHistoryEntry = useCallback(
@@ -339,6 +349,12 @@ export const useVaultState = () => {
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -407,6 +423,12 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs(next);
return;
}
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
setManagedSources(next);
}
};
@@ -474,6 +496,7 @@ export const useVaultState = () => {
knownHosts,
shellHistory,
connectionLogs,
managedSources,
updateHosts,
updateKeys,
updateIdentities,
@@ -481,6 +504,7 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,

View File

@@ -16,6 +16,7 @@ interface GroupTreeItemProps {
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
isManagedGroup?: (path: string) => boolean;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({

View File

@@ -2,6 +2,8 @@ import {
AlertTriangle,
Check,
ChevronDown,
Eye,
EyeOff,
FolderLock,
FolderPlus,
Forward,
@@ -27,7 +29,7 @@ import { useApplicationBackend } from "../application/state/useApplicationBacken
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ProxyConfig, SSHKey } from "../types";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -41,6 +43,7 @@ import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -68,6 +71,7 @@ interface HostDetailsPanelProps {
availableKeys: SSHKey[];
identities: Identity[];
groups: string[];
managedSources?: ManagedSource[];
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)
@@ -82,6 +86,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
availableKeys,
identities,
groups,
managedSources = [],
allTags = [],
allHosts = [],
defaultGroup,
@@ -123,6 +128,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Identity suggestion dropdown state (popover anchored to username input)
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -164,6 +172,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
setForm(updatedData);
setGroupInputValue(initialData.group || "");
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
}, [initialData]);
@@ -244,14 +254,51 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const handleSubmit = () => {
if (!form.hostname || !form.label) return;
if (!form.hostname) return;
// If label is empty, use hostname as label
let finalLabel = form.label?.trim() || form.hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
// Find the most specific (deepest) managed source that matches the group path
// This handles nested managed groups correctly by preferring exact matches
// and longer paths over shorter prefix matches
const targetManagedSource = managedSources
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
// Only SSH hosts can be managed (SSH config only supports SSH protocol)
const canBeManaged = !form.protocol || form.protocol === "ssh";
// Strip spaces from label only if host can be managed and is in a managed group
// (SSH config requires no spaces in Host alias)
if (targetManagedSource && canBeManaged) {
finalLabel = finalLabel.replace(/\s/g, '');
}
// Determine managedSourceId:
// - Only SSH hosts can be managed (SSH config only supports SSH protocol)
// - If we found a matching managed source, use its id
// - If managedSources was not provided (empty array) and host already has managedSourceId, preserve it
// - Otherwise, clear it (host is not in a managed group)
let finalManagedSourceId: string | undefined;
if (targetManagedSource && canBeManaged) {
finalManagedSourceId = targetManagedSource.id;
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
// managedSources not provided, preserve existing value
finalManagedSourceId = form.managedSourceId;
} else {
finalManagedSourceId = undefined;
}
const cleaned: Host = {
...form,
group: groupInputValue.trim() || form.group,
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port || 22,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
onSave(cleaned);
};
@@ -501,7 +548,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
@@ -509,32 +556,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
>
<AsidePanelContent>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
@@ -545,7 +566,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Input
placeholder={t("hostDetails.label.placeholder")}
value={form.label}
onChange={(e) => update("label", e.target.value)}
onChange={(e) => {
let value = e.target.value;
// Only strip spaces if the TARGET group belongs to a managed source
// (don't use form.managedSourceId as it reflects old state before group change)
const targetGroup = groupInputValue.trim() || form.group || "";
const willBeManaged = managedSources.some(s =>
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
);
// Also check protocol - only SSH hosts can be managed
const canBeManaged = !form.protocol || form.protocol === "ssh";
if (willBeManaged && canBeManaged) {
value = value.replace(/\s/g, '');
}
update("label", value);
}}
className="h-10"
/>
@@ -591,6 +626,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
@@ -800,13 +861,23 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
{!selectedIdentity && !form.identityId && (
<Input
placeholder={t("hostDetails.password.placeholder")}
type="password"
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{/* Save Password toggle - shown when password is entered */}
@@ -1314,11 +1385,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
</div>
<Input
<Textarea
placeholder={t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="h-9"
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.startupCommand.help")}
@@ -1460,7 +1532,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Button
className="w-full h-10"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
>
{t("common.save")}
</Button>

501
components/HostTreeView.tsx Normal file
View File

@@ -0,0 +1,501 @@
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { sanitizeHost } from '../domain/host';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
interface HostTreeViewProps {
groupTree: GroupNode[];
hosts: Host[];
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths?: Set<string>;
onTogglePath?: (path: string) => void;
onExpandAll?: (paths: string[]) => void;
onCollapseAll?: () => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}
interface TreeNodeProps {
node: GroupNode;
depth: number;
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
sortMode,
expandedPaths,
onToggle,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const childNodes = useMemo(() => {
if (!node.children) return [];
const nodes = Object.values(node.children) as unknown as GroupNode[];
return nodes.sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [node.children, sortMode]);
const sortedHosts = useMemo(() => {
return [...node.hosts].sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [node.hosts, sortMode]);
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{(hasChildren || node.hosts.length > 0) && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={14} />
</div>
)}
</div>
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
</div>
<span className="truncate flex-1 font-semibold">{node.name}</span>
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
<FileSymlink size={10} />
Managed
</span>
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(node.path)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<CollapsibleContent>
{/* Child Groups */}
{childNodes.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={onToggle}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={depth + 1}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
/>
))}
</CollapsibleContent>
</Collapsible>
</div>
);
};
interface HostTreeItemProps {
host: Host;
depth: number;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
}
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const isTelnet = host.protocol === 'telnet';
const displayUsername = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim() || '')
: (host.username?.trim() || '');
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => onConnect(safeHost)}
>
<div className="mr-2 flex-shrink-0 w-4 h-4" />
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{host.protocol && host.protocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{host.protocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupTree,
hosts,
sortMode = 'az',
expandedPaths: externalExpandedPaths,
onTogglePath: externalOnTogglePath,
onExpandAll: externalOnExpandAll,
onCollapseAll: externalOnCollapseAll,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
const togglePath = externalOnTogglePath || localTreeState.togglePath;
const expandAll = externalOnExpandAll || localTreeState.expandAll;
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
// Get all possible group paths for expand/collapse all functionality
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: GroupNode[]) => {
nodeList.forEach(node => {
paths.push(node.path);
if (node.children) {
traverse(Object.values(node.children) as GroupNode[]);
}
});
};
traverse(nodes);
return paths;
};
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
const handleExpandAll = () => {
expandAll(allGroupPaths);
};
const handleCollapseAll = () => {
collapseAll();
};
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
const ungroupedHosts = useMemo(() => {
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
return hosts_without_group.sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [hosts, sortMode]);
// Sort group tree based on sort mode
const sortedGroupTree = useMemo(() => {
return [...groupTree].sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
<Button
variant="ghost"
size="sm"
onClick={handleExpandAll}
className="h-7 px-2 text-xs"
>
<Expand size={12} className="mr-1" />
{t("vault.tree.expandAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCollapseAll}
className="h-7 px-2 text-xs"
>
<Minimize2 size={12} className="mr-1" />
{t("vault.tree.collapseAll")}
</Button>
</div>
)}
{/* Group tree */}
{sortedGroupTree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={togglePath}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
/>
))}
{/* Ungrouped hosts at root level */}
{ungroupedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={0}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
/>
))}
{/* Empty state */}
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Server size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-sm">{t("vault.hosts.empty")}</p>
</div>
)}
</div>
);
};

View File

@@ -23,6 +23,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -68,6 +69,7 @@ interface KeychainManagerProps {
identities?: Identity[];
hosts?: Host[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
onUpdate: (key: SSHKey) => void;
onDelete: (id: string) => void;
@@ -83,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
identities = [],
hosts = [],
customGroups = [],
managedSources = [],
onSave,
onUpdate,
onDelete,
@@ -1282,6 +1285,7 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
/>

View File

@@ -0,0 +1,169 @@
/**
* Passphrase Modal
* Modal for requesting passphrase for encrypted SSH keys
*/
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 PassphraseRequest {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
request,
onSubmit,
onCancel,
onSkip,
}) => {
const { t } = useI18n();
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleSkip = useCallback(() => {
if (!request || !onSkip) return;
onSkip(request.requestId);
}, [request, onSkip]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting && passphrase) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting, passphrase]
);
if (!request) return null;
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
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>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="passphrase-input">
{t("passphrase.label")}
</Label>
<div className="relative">
<Input
id="passphrase-input"
type={showPassphrase ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className="pr-10"
autoFocus
disabled={isSubmitting}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => setShowPassphrase(!showPassphrase)}
disabled={isSubmitting}
>
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
{onSkip && (
<Button
variant="ghost"
onClick={handleSkip}
disabled={isSubmitting}
>
{t("passphrase.skip")}
</Button>
)}
</div>
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("passphrase.unlocking")}
</>
) : (
t("passphrase.unlock")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default PassphraseModal;

View File

@@ -15,6 +15,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
SSHKey,
@@ -64,6 +65,7 @@ interface PortForwardingProps {
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -74,6 +76,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
keys,
identities = [],
customGroups: _customGroups,
managedSources = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -844,6 +847,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
/>

View File

@@ -6,6 +6,7 @@ import { useSettingsState } from "../application/state/useSettingsState";
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DropEntry } from "../lib/sftpFileUtils";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
@@ -45,6 +46,8 @@ interface SFTPModalProps {
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
}
const SFTPModal: React.FC<SFTPModalProps> = ({
@@ -53,6 +56,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
open,
onClose,
initialPath,
initialEntriesToUpload,
}) => {
const {
openSftp,
@@ -76,15 +80,19 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const navigatingRef = useRef(false);
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
@@ -345,10 +353,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
uploadTasks,
dragActive,
handleDownload,
handleUploadEntries,
handleFileSelect,
handleFolderSelect,
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
} = useSftpModalTransfers({
currentPath,
@@ -365,8 +376,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
});
const handleClose = async () => {
@@ -374,6 +389,43 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onClose();
};
// Handle initial entries to upload (from drag-and-drop to terminal)
const initialUploadTriggeredRef = useRef(false);
const prevLoadingRef = useRef(loading);
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
useEffect(() => {
// Detect when loading transitions from true to false (initial load complete)
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = loading;
const justFinishedLoading = wasLoading && !loading;
// Reset the flag when initialEntriesToUpload is cleared
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = undefined;
return;
}
// Reset the flag when new entries arrive (different reference = new drop)
if (initialEntriesToUpload !== prevEntriesRef.current) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = initialEntriesToUpload;
}
// Prevent duplicate uploads
if (initialUploadTriggeredRef.current) return;
// Wait for SFTP connection to be established
// Trigger when: modal is open AND loading just finished (works for empty directories too)
if (!open || loading) return;
if (!justFinishedLoading) return;
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
handleUploadEntries(initialEntriesToUpload);
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
@@ -522,12 +574,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
onRootSelect={() => setCurrentPath(rootPath)}
inputRef={inputRef}
folderInputRef={folderInputRef}
pathInputRef={pathInputRef}
uploading={uploading}
onTriggerUpload={() => inputRef.current?.click()}
onTriggerFolderUpload={() => folderInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
/>
<SftpModalFileList
@@ -540,7 +595,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loading={loading}
loadingTextContent={loadingTextContent}
reconnecting={reconnecting}
resolvedLocale={resolvedLocale}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
@@ -549,6 +603,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
visibleRows={visibleRows}
fileListRef={fileListRef}
inputRef={inputRef}
folderInputRef={folderInputRef}
handleSort={handleSort}
handleResizeStart={handleResizeStart}
handleFileListScroll={handleFileListScroll}
@@ -573,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
formatDate={formatDate}
/>
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onDismiss={dismissTask} />
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
<SftpModalFooter
t={t}

View File

@@ -11,6 +11,7 @@ import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import { Button } from "./ui/button";
@@ -31,6 +32,7 @@ interface SelectHostPanelProps {
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
title?: string;
@@ -49,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onNewHost,
availableKeys = [],
identities = [],
managedSources = [],
onSaveHost,
onCreateGroup,
title,
@@ -407,6 +410,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
availableKeys={availableKeys}
identities={identities}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}
onSave={(host) => {
onSaveHost(host);

View File

@@ -18,6 +18,7 @@ import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
@@ -67,6 +68,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
// without needing to re-create when sftp changes
const sftpRef = useRef(sftp);
@@ -130,6 +134,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
const visibleTransfers = useMemo(

View File

@@ -5,6 +5,7 @@ import { useStoredViewMode } from '../application/state/useStoredViewMode';
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { ManagedSource } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
@@ -30,6 +31,7 @@ interface SnippetsManagerProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
// Props for inline host creation
availableKeys?: SSHKey[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
}
@@ -49,6 +51,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onPackagesChange,
onRunSnippet,
availableKeys = [],
managedSources = [],
onSaveHost,
onCreateGroup,
}) => {
@@ -67,6 +70,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const [newPackageName, setNewPackageName] = useState('');
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
// Rename package state
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
const [renamePackageName, setRenamePackageName] = useState('');
const [renameError, setRenameError] = useState('');
// Search, sort, and view mode state
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useStoredViewMode(
@@ -144,23 +153,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const roots = packages
// Separate absolute paths (starting with /) from relative paths
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
const results: { name: string; path: string; count: number }[] = [];
// Process relative paths (traditional behavior)
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter(Boolean);
return Array.from(new Set(roots)).map((name) => {
const path = name;
const count = snippets.filter((s) => (s.package || '') === path).length;
return { name, path, count };
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
// Process absolute paths - show them as separate roots with "/" prefix
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1); // Remove leading slash
const firstSegment = cleanPath.split('/')[0];
return firstSegment;
})
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`; // Show with leading slash to distinguish
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter(Boolean);
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => (s.package || '') === path).length;
// Count snippets in this package AND all nested packages
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
@@ -191,28 +237,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
const createPackage = () => {
const name = newPackageName.trim();
if (!name) return;
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
if (!packages.includes(full)) onPackagesChange([...packages, full]);
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
// Could add toast notification here for invalid characters
return;
}
// Normalize path construction to avoid double slashes
let full: string;
if (selectedPackage) {
// Strip leading slash from name when we're inside a package to avoid double slashes
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
full = `${selectedPackage}/${normalizedName}`;
} else {
// At root level, preserve the leading slash if user intended it
full = name;
}
// Strip trailing slash to ensure consistent path handling
if (full.endsWith('/')) {
full = full.slice(0, -1);
}
// Check for duplicate package names (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
if (existingPackage) {
// Could add toast notification here for duplicate package
return;
}
onPackagesChange([...packages, full]);
setNewPackageName('');
setIsPackageDialogOpen(false);
};
const deletePackage = (path: string) => {
// Remove the package and all its children
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
// Move all snippets from deleted packages to root
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
if (s.package === path || s.package.startsWith(path + '/')) {
return { ...s, package: '' };
}
return s;
});
// Update packages first, then save snippets
onPackagesChange(keep);
updatedSnippets.forEach(onSave);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
setSelectedPackage(null);
}
@@ -220,24 +314,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const movePackage = (source: string, target: string | null) => {
const name = source.split('/').pop() || '';
const newPath = target ? `${target}/${name}` : name;
const isAbsolute = source.startsWith('/');
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
if (newPath === source || newPath.startsWith(source + '/')) return;
// Check if target path already exists
if (packages.includes(newPath)) return;
const updatedPackages = packages.map((p) => {
if (p === source) return newPath;
if (p.startsWith(source + '/')) return p.replace(source, newPath);
// Use more precise replacement to avoid substring issues
if (p.startsWith(source + '/')) {
return newPath + p.substring(source.length);
}
return p;
});
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === source) return { ...s, package: newPath };
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
// Use more precise replacement to avoid substring issues
if (s.package.startsWith(source + '/')) {
return { ...s, package: newPath + s.package.substring(source.length) };
}
return s;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
if (selectedPackage === source) setSelectedPackage(newPath);
};
const openRenameDialog = (path: string) => {
const name = path.split('/').pop() || '';
setRenamingPackagePath(path);
setRenamePackageName(name);
setRenameError('');
setIsRenameDialogOpen(true);
};
const renamePackage = () => {
if (!renamingPackagePath) return;
const newName = renamePackageName.trim();
// Validate: empty name
if (!newName) {
setRenameError(t('snippets.renameDialog.error.empty'));
return;
}
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
if (!/^[\w-]+$/.test(newName)) {
setRenameError(t('snippets.renameDialog.error.invalidChars'));
return;
}
// Build new path
const parts = renamingPackagePath.split('/');
parts[parts.length - 1] = newName;
const newPath = parts.join('/');
// Validate: same name
if (newPath === renamingPackagePath) {
setIsRenameDialogOpen(false);
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
}
// Update all packages with this path or nested under it
const updatedPackages = packages.map((p) => {
if (p === renamingPackagePath) return newPath;
if (p.startsWith(renamingPackagePath + '/')) {
return newPath + p.substring(renamingPackagePath.length);
}
return p;
});
// Update all snippets with this package or nested under it
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === renamingPackagePath) return { ...s, package: newPath };
if (s.package.startsWith(renamingPackagePath + '/')) {
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
}
return s;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
setSelectedPackage(newPath);
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
}
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
if (editingSnippet.package) {
if (editingSnippet.package === renamingPackagePath) {
setEditingSnippet(prev => ({ ...prev, package: newPath }));
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
setEditingSnippet(prev => ({
...prev,
package: newPath + prev.package!.substring(renamingPackagePath.length)
}));
}
}
setIsRenameDialogOpen(false);
};
const moveSnippet = (id: string, pkg: string | null) => {
const sn = snippets.find((s) => s.id === id);
if (!sn) return;
@@ -246,11 +441,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Package options for Combobox
const packageOptions: ComboboxOption[] = useMemo(() => {
return packages.map(p => ({
value: p,
label: p.includes('/') ? p.split('/').pop()! : p,
sublabel: p.includes('/') ? p : undefined,
}));
// Generate all possible parent paths for each package
const allPaths = new Set<string>();
packages.forEach(pkg => {
// Add the full package path
allPaths.add(pkg);
// Add all parent paths
const parts = pkg.split('/').filter(Boolean);
const isAbsolute = pkg.startsWith('/');
for (let i = 1; i < parts.length; i++) {
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
allPaths.add(parentPath);
}
});
return Array.from(allPaths)
.sort((a, b) => {
// Sort by depth first (shorter paths first), then alphabetically
const depthA = (a.match(/\//g) || []).length;
const depthB = (b.match(/\//g) || []).length;
if (depthA !== depthB) return depthA - depthB;
return a.localeCompare(b);
})
.map(p => ({
value: p,
label: p.includes('/') ? p.split('/').pop()! : p,
sublabel: p.includes('/') ? p : undefined,
}));
}, [packages]);
// Shell history lazy loading
@@ -310,6 +530,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onBack={handleTargetPickerBack}
onContinue={handleTargetPickerBack}
availableKeys={availableKeys}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
@@ -354,7 +575,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Combobox
options={packageOptions}
value={editingSnippet.package || selectedPackage || ''}
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
onValueChange={(val) => {
setEditingSnippet({ ...editingSnippet, package: val });
// If selecting an implicit parent path, persist it to packages
if (val && !packages.includes(val)) {
onPackagesChange([...packages, val]);
}
}}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate={true}
onCreateNew={(val) => {
@@ -624,6 +851,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -729,6 +957,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
</div>
@@ -742,6 +972,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</div>
)}
{/* Rename Package Dialog */}
{isRenameDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-sm p-4 space-y-4">
<div>
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
</div>
<div className="space-y-2">
<Label>{t('field.name')}</Label>
<Input
autoFocus
placeholder={t('snippets.renameDialog.placeholder')}
value={renamePackageName}
onChange={(e) => {
setRenamePackageName(e.target.value);
setRenameError('');
}}
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
/>
{renameError && (
<p className="text-[11px] text-destructive">{renameError}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={renamePackage}>{t('common.rename')}</Button>
</div>
</Card>
</div>
)}
{/* Right Panel */}
{renderRightPanel()}
</div>

View File

@@ -42,6 +42,57 @@ import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
/**
* Extract unique root paths from drop entries for local terminal path insertion.
* For nested files, extracts the root folder path; for single files, uses the full path.
* Paths with spaces are quoted.
*/
function extractRootPathsFromDropEntries(dropEntries: DropEntry[]): string[] {
const paths: string[] = [];
const seenPaths = new Set<string>();
for (const entry of dropEntries) {
if (!entry.file) continue;
const fullPath = getPathForFile(entry.file);
if (!fullPath) continue;
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
// Nested file in a folder - extract the root folder path
const rootFolderName = pathParts[0];
const separator = fullPath.includes('\\') ? '\\' : '/';
// Find the position of the root folder name in the full path
const rootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName + separator);
const altRootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName);
const folderStartIndex = rootFolderIndex !== -1
? rootFolderIndex + 1
: (altRootFolderIndex !== -1 ? altRootFolderIndex + 1 : -1);
if (folderStartIndex !== -1) {
const folderEndIndex = folderStartIndex + rootFolderName.length;
const folderPath = fullPath.substring(0, folderEndIndex);
if (!seenPaths.has(folderPath)) {
paths.push(folderPath.includes(' ') ? `"${folderPath}"` : folderPath);
seenPaths.add(folderPath);
}
}
} else {
// Single file (not in a folder)
if (!seenPaths.has(fullPath)) {
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
seenPaths.add(fullPath);
}
}
}
return paths;
}
interface TerminalProps {
host: Host;
@@ -211,6 +262,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentHostLabel: string;
} | null>(null);
// Drag and drop state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
isSearchOpen,
@@ -223,6 +279,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) for Linux servers
const { stats: serverStats } = useServerStats({
sessionId,
@@ -450,8 +510,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (status !== "connecting" || auth.needsAuth) return;
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
@@ -883,6 +946,95 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
e.dataTransfer.dropEffect = 'copy';
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDraggingOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDraggingOver(false);
if (!e.dataTransfer.types.includes('Files')) {
return;
}
// Only handle drops on connected terminals
if (status !== 'connected') {
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
return;
}
try {
const dropEntries = await extractDropEntries(e.dataTransfer);
if (dropEntries.length === 0) {
return;
}
if (isLocalConnection) {
// Local terminal: Insert absolute paths
const paths = extractRootPathsFromDropEntries(dropEntries);
if (paths.length > 0 && termRef.current && sessionRef.current) {
const pathsText = paths.join(' ');
// Write the paths to the terminal
terminalBackend.writeToSession(sessionRef.current, pathsText);
termRef.current.focus();
}
} else {
// Remote terminal: Trigger SFTP upload
// Get current working directory for SFTP initial path
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
}
}
setPendingUploadEntries(dropEntries);
// Use flushSync to ensure sftpInitialPath is updated synchronously
// before setShowSFTP(true) triggers the modal open
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
}
} catch (error) {
logger.error("Failed to handle file drop", error);
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
}
};
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
@@ -919,6 +1071,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
@@ -929,7 +1082,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitVertical={onSplitVertical}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
<div
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag and drop overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 bg-blue-600/20 backdrop-blur-sm border-4 border-dashed border-blue-400 pointer-events-none flex items-center justify-center">
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
<div className="text-center">
<div className="text-lg font-semibold mb-2">
{isLocalConnection
? t("terminal.dragDrop.localTitle")
: t("terminal.dragDrop.remoteTitle")
}
</div>
<div className="text-sm text-muted-foreground">
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
}
</div>
</div>
</div>
</div>
)}
<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]"
@@ -1294,7 +1474,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{status !== "connected" && !needsHostKeyVerification && (
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
<TerminalConnectionDialog
host={host}
status={status}
@@ -1399,8 +1582,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
onClose={() => {
setShowSFTP(false);
setPendingUploadEntries([]);
}}
initialPath={sftpInitialPath}
initialEntriesToUpload={pendingUploadEntries}
/>
</div>
</TerminalContextMenu>

View File

@@ -25,6 +25,7 @@ interface TopTabsProps {
isMacClient: boolean;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isMacClient,
onCloseSession,
onRenameSession,
onCopySession,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -213,6 +213,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Compressed folder upload section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.desc')}
</p>
<button
onClick={() => setSftpUseCompressedUpload(!sftpUseCompressedUpload)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpUseCompressedUpload
? "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",
sftpUseCompressedUpload
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpUseCompressedUpload && (
<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.compressedUpload.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Download, Edit2, Folder, FolderOpen, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { RemoteFile } from "../../types";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
@@ -23,7 +23,6 @@ interface SftpModalFileListProps {
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
resolvedLocale: string | undefined;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
@@ -32,6 +31,7 @@ interface SftpModalFileListProps {
visibleRows: VisibleRow[];
fileListRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
handleSort: (field: "name" | "size" | "modified") => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
@@ -53,7 +53,7 @@ interface SftpModalFileListProps {
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined, locale?: string) => string;
formatDate: (dateStr: string | number | undefined) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
@@ -66,7 +66,6 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
loading,
loadingTextContent,
reconnecting,
resolvedLocale,
columnWidths,
sortField,
sortOrder,
@@ -75,6 +74,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
visibleRows,
fileListRef,
inputRef,
folderInputRef,
handleSort,
handleResizeStart,
handleFileListScroll,
@@ -279,7 +279,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified, resolvedLocale)}
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (
@@ -400,6 +400,9 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
</ContextMenuItem>

View File

@@ -1,12 +1,13 @@
import React from "react";
import { ArrowUp, ChevronRight, Home, MoreHorizontal, Plus, RefreshCw, Upload } from "lucide-react";
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
interface BreadcrumbPart {
part: string;
@@ -40,12 +41,15 @@ interface SftpModalHeaderProps {
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onTriggerFolderUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
@@ -75,12 +79,15 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onBreadcrumbSelect,
onRootSelect,
inputRef,
folderInputRef,
pathInputRef,
uploading,
onTriggerUpload,
onTriggerFolderUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
onFolderSelect,
}) => (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
@@ -102,49 +109,90 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
</div>
</DialogHeader>
<TooltipProvider delayDuration={300}>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{showEncoding && (
<Select
value={filenameEncoding}
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
>
<SelectTrigger className="h-7 w-[130px] text-xs" title={t("sftp.encoding.label")}>
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
@@ -214,32 +262,61 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFolder}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFile}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
</Button>
<div className="flex items-center gap-1 ml-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
@@ -247,7 +324,16 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onChange={onFileSelect}
multiple
/>
</div>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
</div>
</div>
</TooltipProvider>
</>
);

View File

@@ -1,29 +1,66 @@
import React from "react";
import { Loader2, Upload, X, XCircle } from "lucide-react";
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface UploadTask {
interface TransferTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
}
interface SftpModalUploadTasksProps {
tasks: UploadTask[];
tasks: TransferTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onCancelTask?: (taskId: string) => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
if (tasks.length === 0) return null;
// Helper function to get localized display name for compressed uploads
const getDisplayName = (task: TransferTask) => {
// Check for explicit phase marker format: "folderName|phase"
// This is the format sent by uploadService.ts for compressed uploads
if (task.fileName.includes('|')) {
const pipeIndex = task.fileName.lastIndexOf('|');
const baseName = task.fileName.substring(0, pipeIndex);
const phase = task.fileName.substring(pipeIndex + 1);
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
const phaseLabel = t(`sftp.upload.phase.${phase}`);
return `${baseName} (${phaseLabel})`;
}
}
// Check for exact matches of phase status strings (legacy support)
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
return t('sftp.upload.compressing');
}
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
return t('sftp.upload.extracting');
}
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
return t('sftp.upload.scanning');
}
// Check if this is a compressed upload task (legacy format)
if (task.fileName.includes('(compressed)')) {
const baseName = task.fileName.replace(' (compressed)', '');
return `${baseName} (${t('sftp.upload.compressed')})`;
}
return task.fileName;
};
return (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
@@ -61,14 +98,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{task.status === "uploading" && (
{(task.status === "uploading" || task.status === "downloading") && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
<Upload size={14} className="text-muted-foreground animate-pulse" />
task.direction === "download"
? <Download size={14} className="text-muted-foreground animate-pulse" />
: <Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
<Upload size={14} className="text-green-500" />
task.direction === "download"
? <Download size={14} className="text-green-500" />
: <Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
@@ -80,20 +121,20 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{task.fileName}
{getDisplayName(task)}
</span>
{task.status === "uploading" && task.speed > 0 && (
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(task.speed)}
</span>
)}
{task.status === "uploading" && remainingStr && (
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || task.status === "pending") && (
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
@@ -105,30 +146,30 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
)}
style={{
width:
task.status === "uploading"
task.status === "uploading" || task.status === "downloading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
</span>
</div>
)}
{task.status === "uploading" && task.totalBytes > 0 && (
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
Completed - {formatBytes(task.totalBytes)}
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
Cancelled
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
</div>
)}
{task.status === "failed" && task.error && (
@@ -143,12 +184,19 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "pending") && onCancel && (
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={onCancel}
onClick={() => {
// For download tasks or when onCancelTask is available, use task-specific cancel
if (onCancelTask) {
onCancelTask(task.id);
} else if (onCancel) {
onCancel();
}
}}
title={t("sftp.action.cancel")}
>
<X size={12} />

View File

@@ -5,16 +5,18 @@ import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadTaskInfo,
UploadProgress,
} from "../../../lib/uploadService";
import { DropEntry } from "../../../lib/sftpFileUtils";
interface UploadTask {
interface TransferTask {
id: string;
fileName: string;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
progress: number;
totalBytes: number;
transferredBytes: number;
@@ -24,8 +26,12 @@ interface UploadTask {
isDirectory?: boolean;
fileCount?: number;
completedCount?: number;
direction: "upload" | "download";
}
// Keep UploadTask as alias for backwards compatibility
type UploadTask = TransferTask;
interface UseSftpModalTransfersParams {
currentPath: string;
isLocalSession: boolean;
@@ -43,14 +49,32 @@ interface UseSftpModalTransfersParams {
onProgress: (transferred: number, total: number, speed: number) => void,
onComplete: () => void,
onError: (error: string) => void,
) => Promise<boolean>;
) => Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
cancelTransfer?: (transferId: string) => Promise<void>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
setLoading: (loading: boolean) => void;
t: (key: string, params?: Record<string, unknown>) => string;
useCompressedUpload?: boolean; // Enable compressed folder uploads
}
interface UseSftpModalTransfersResult {
@@ -60,10 +84,13 @@ interface UseSftpModalTransfersResult {
handleDownload: (file: RemoteFile) => Promise<void>;
handleUploadMultiple: (fileList: FileList) => Promise<void>;
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
handleUploadEntries: (entries: DropEntry[]) => Promise<void>;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
cancelUpload: () => Promise<void>;
cancelTask: (taskId: string) => Promise<void>;
dismissTask: (taskId: string) => void;
}
@@ -81,8 +108,12 @@ export const useSftpModalTransfers = ({
mkdirLocal,
mkdirSftp,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload = false,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
@@ -97,35 +128,6 @@ export const useSftpModalTransfers = ({
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
const handleDownload = useCallback(
async (file: RemoteFile) => {
try {
const fullPath = joinPath(currentPath, file.name);
setLoading(true);
const content = isLocalSession
? await readLocalFile(fullPath)
: await readSftp(await ensureSftp(), fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
);
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
@@ -143,8 +145,8 @@ export const useSftpModalTransfers = ({
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
_onComplete?: () => void,
_onError?: (error: string) => void
onComplete?: () => void,
onError?: (error: string) => void
) => {
try {
const result = await writeSftpBinaryWithProgress(
@@ -153,29 +155,57 @@ export const useSftpModalTransfers = ({
data,
taskId,
onProgress,
() => { },
() => { }
onComplete || (() => { }),
onError || (() => { })
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
}
return { success: result, cancelled: wasCancelled };
return { success: result.success, transferId: result.transferId, cancelled: wasCancelled || result.cancelled };
} catch (error) {
// Check if this was a user-initiated cancellation
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
return { success: false, cancelled: true };
return { success: false, transferId: taskId, cancelled: true };
}
// Real error - propagate it by re-throwing
throw error;
}
},
cancelSftpUpload,
startStreamTransfer: startStreamTransfer ? async (
options,
onProgress,
onComplete,
onError
) => {
try {
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
}
// Handle case where result might be undefined (bridge not available)
if (!result) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
return { ...result, cancelled: wasCancelled };
} catch (error) {
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
return { transferId: options.transferId, cancelled: true };
}
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
}
} : undefined,
cancelTransfer,
};
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload]);
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
@@ -183,7 +213,7 @@ export const useSftpModalTransfers = ({
onScanningStart: (taskId: string) => {
const scanningTask: UploadTask = {
id: taskId,
fileName: "Scanning files...",
fileName: t("sftp.upload.scanning"),
status: "pending",
progress: 0,
totalBytes: 0,
@@ -191,6 +221,7 @@ export const useSftpModalTransfers = ({
speed: 0,
startTime: Date.now(),
isDirectory: true,
direction: "upload",
};
setUploadTasks(prev => [...prev, scanningTask]);
},
@@ -201,36 +232,35 @@ export const useSftpModalTransfers = ({
const uploadTask: UploadTask = {
id: task.id,
fileName: task.displayName,
status: "uploading",
status: "pending",
progress: 0,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
fileCount: task.fileCount,
completedCount: 0,
direction: "upload",
};
// Filter out any pending scanning tasks before adding the real task.
// This ensures that even if onScanningEnd's state update hasn't been applied yet
// (due to React state batching), the scanning placeholder will still be removed.
setUploadTasks(prev => [
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
uploadTask
]);
setUploadTasks(prev => [...prev, uploadTask]);
},
onTaskProgress: (taskId: string, progress: UploadProgress) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId && task.status === "uploading"
? {
...task,
transferredBytes: progress.transferred,
progress: progress.percent,
speed: progress.speed,
}
: task
)
prev.map(task => {
if (task.id !== taskId) return task;
// Don't update progress if task is already completed, failed, or cancelled
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
return task;
}
return {
...task,
status: "uploading" as const,
progress: progress.percent,
transferredBytes: progress.transferred,
speed: progress.speed,
};
})
);
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
@@ -249,24 +279,18 @@ export const useSftpModalTransfers = ({
);
},
onTaskFailed: (taskId: string, error: string) => {
// Any error marks the task as failed
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "failed" as const,
error,
speed: 0,
error,
}
: task
)
);
// Auto-clear failed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 3000);
},
onTaskCancelled: (taskId: string) => {
setUploadTasks(prev =>
@@ -280,65 +304,262 @@ export const useSftpModalTransfers = ({
: task
)
);
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 2000);
},
onTaskNameUpdate: (taskId: string, newName: string) => {
// Parse the phase format: "folderName|phase"
let displayName = newName;
if (newName.includes('|')) {
const [folderName, phase] = newName.split('|');
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
displayName = `${folderName} (${phaseLabel})`;
}
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
fileName: displayName,
}
: task
)
);
},
};
}, []);
}, [t]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (
files: FileList | File[],
useCompressed: boolean
): Promise<void> => {
if (files.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
files,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload: useCompressed,
},
controller
);
await loadFiles(currentPath, { force: true });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
// Upload process is complete - clear uploading state and controller
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
const handleDownload = useCallback(
async (file: RemoteFile) => {
try {
const fullPath = joinPath(currentPath, file.name);
// For local files, use blob download (file is already on local filesystem)
if (isLocalSession) {
setLoading(true);
const content = await readLocalFile(fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return;
}
// For remote SFTP files, use streaming download with save dialog
if (!showSaveDialog || !startStreamTransfer) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
// User cancelled the save dialog
return;
}
const sftpId = await ensureSftp();
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const fileSize = typeof file.size === 'number' ? file.size : parseInt(file.size, 10) || 0;
// Create download task for progress display
const downloadTask: TransferTask = {
id: transferId,
fileName: file.name,
status: "downloading",
progress: 0,
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
direction: "download",
};
setUploadTasks(prev => [...prev, downloadTask]);
// Track if this download was cancelled or error was handled
let wasCancelled = false;
let errorHandled = false;
const result = await startStreamTransfer(
{
transferId,
sourcePath: fullPath,
targetPath,
sourceType: 'sftp',
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: fileSize,
},
// onProgress
(transferred, total, speed) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: transferred,
totalBytes: total,
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
speed,
}
: task
)
);
},
// onComplete
() => {
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "completed" as const, progress: 100 }
: task
)
);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
},
// onError
(error) => {
errorHandled = true;
// Check if this is a cancellation error
if (error.includes('cancelled') || error.includes('canceled')) {
wasCancelled = true;
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "cancelled" as const, speed: 0 }
: task
)
);
} else {
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "failed" as const, error }
: task
)
);
toast.error(error, "SFTP");
}
}
);
// Check if bridge doesn't support streaming (returns undefined)
if (result === undefined) {
// Remove the pending task and show error
setUploadTasks(prev => prev.filter(task => task.id !== transferId));
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Handle result - check for cancellation in result.error as well
// (backend may set error without calling onError callback)
if (result?.error) {
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
if (isCancelError) {
// Mark as cancelled if not already done by onError
if (!wasCancelled) {
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "cancelled" as const, speed: 0 }
: task
)
);
}
// Don't show error for cancellation
return;
}
// For non-cancel errors, only show toast if onError didn't already handle it
if (!errorHandled) {
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "failed" as const, error: result.error }
: task
)
);
toast.error(result.error, "SFTP");
}
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
);
const handleUploadMultiple = useCallback(
async (fileList: FileList) => {
if (fileList.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
fileList,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
await performUpload(fileList, useCompressedUpload);
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
[performUpload, useCompressedUpload],
);
const handleUploadFromDrop = useCallback(
@@ -368,38 +589,121 @@ export const useSftpModalTransfers = ({
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
// Upload process is complete - clear uploading state and controller
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
);
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
const handleUploadEntries = useCallback(
async (entries: DropEntry[]) => {
if (entries.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadEntriesDirect(
entries,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
await loadFiles(currentPath, { force: true });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
// Upload process is complete - clear uploading state and controller
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
);
// Handle upload from File array (used by file input after copying files)
const handleUploadFromFiles = useCallback(
async (files: File[]) => {
if (files.length === 0) return;
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
await performUpload(files, useCompressedUpload);
},
[performUpload, useCompressedUpload],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
void handleUploadMultiple(e.target.files);
// Copy the files before clearing the input, because clearing the input
// will also clear the FileList reference
const files = Array.from(e.target.files);
// Clear input first to allow selecting the same files again
e.target.value = "";
// Now start the upload with the copied files
void handleUploadFromFiles(files);
} else {
e.target.value = "";
}
e.target.value = "";
},
[handleUploadMultiple],
[handleUploadFromFiles],
);
const handleFolderSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
// Copy the files before clearing the input, because clearing the input
// will also clear the FileList reference
const files = Array.from(e.target.files);
// Clear input first to allow selecting the same folder again
e.target.value = "";
// Now start the upload with the copied files
void handleUploadFromFiles(files);
} else {
e.target.value = "";
}
},
[handleUploadFromFiles],
);
const handleDrag = useCallback((e: React.DragEvent) => {
@@ -438,7 +742,9 @@ export const useSftpModalTransfers = ({
// Always clear all uploading/pending tasks immediately, even without controller
setUploadTasks(prev => {
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
if (!hasActiveTasks) return prev;
if (!hasActiveTasks) {
return prev;
}
return prev.map(task =>
task.status === "uploading" || task.status === "pending"
@@ -447,15 +753,60 @@ export const useSftpModalTransfers = ({
);
});
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "cancelled"));
}, 2000);
// Also reset uploading state
setUploading(false);
}, []);
// Cancel a specific task (works for both uploads and downloads)
const cancelTask = useCallback(async (taskId: string) => {
// Find the task to determine its type
const task = uploadTasks.find(t => t.id === taskId);
if (!task) return;
if (task.direction === "download") {
// For download tasks, cancel only this specific transfer
if (cancelTransfer) {
try {
await cancelTransfer(taskId);
} catch (e) {
// Ignore cancellation errors
}
}
// Mark task as cancelled
setUploadTasks(prev =>
prev.map(t =>
t.id === taskId
? { ...t, status: "cancelled" as const, speed: 0 }
: t
)
);
} else {
// For upload tasks, cancel the entire upload batch
// because controller.cancel() cancels all active uploads
const controller = uploadControllerRef.current;
if (controller) {
// Mark all active transfer IDs as cancelled before calling cancel
const activeIds = controller.getActiveTransferIds();
for (const id of activeIds) {
cancelledTransferIdsRef.current.add(id);
}
await controller.cancel();
}
// Mark ALL uploading/pending tasks as cancelled (not just the clicked one)
setUploadTasks(prev =>
prev.map(t =>
t.status === "uploading" || t.status === "pending"
? { ...t, status: "cancelled" as const, speed: 0 }
: t
)
);
// Reset uploading state
setUploading(false);
}
}, [uploadTasks, cancelTransfer]);
const dismissTask = useCallback((taskId: string) => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, []);
@@ -467,10 +818,13 @@ export const useSftpModalTransfers = ({
handleDownload,
handleUploadMultiple,
handleUploadFromDrop,
handleUploadEntries,
handleFileSelect,
handleFolderSelect,
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
};
};

View File

@@ -7,9 +7,10 @@ export const formatBytes = (bytes: number | string): string => {
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined, locale?: string): string => {
export const formatDate = (dateStr: string | number | undefined): string => {
if (!dateStr) return "--";
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
return date.toLocaleString(locale || undefined);
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

View File

@@ -20,6 +20,23 @@ interface UseSftpViewFileOpsParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
}
interface UseSftpViewFileOpsResult {
@@ -88,6 +105,9 @@ export const useSftpViewFileOps = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
@@ -328,19 +348,130 @@ export const useSftpViewFileOps = ({
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
const content = await sftpRef.current.readBinaryFile(side, fullPath);
// For local files, use blob download
if (pane.connection.isLocal) {
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
return;
}
// For remote SFTP files, use streaming download with save dialog
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
// User cancelled
return;
}
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const fileSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
// Add download task to transfer queue for progress display
sftpRef.current.addExternalUpload({
id: transferId,
fileName: file.name,
sourcePath: fullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: 'local',
direction: 'download',
status: 'transferring',
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
});
// Track if error was already handled by callback
let errorHandled = false;
const result = await startStreamTransfer(
{
transferId,
sourcePath: fullPath,
targetPath,
sourceType: 'sftp',
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: fileSize,
},
(transferred, total, speed) => {
// Update transfer progress in the queue
sftpRef.current.updateExternalUpload(transferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
() => {
// Mark as completed
sftpRef.current.updateExternalUpload(transferId, {
status: 'completed',
transferredBytes: fileSize,
endTime: Date.now(),
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
},
(error) => {
errorHandled = true;
// Check if this is a cancellation - don't show error toast for cancellations
const isCancelError = error.includes('cancelled') || error.includes('canceled');
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelError ? 'cancelled' : 'failed',
error: isCancelError ? undefined : error,
endTime: Date.now(),
});
if (!isCancelError) {
toast.error(error, "SFTP");
}
}
);
// Check if bridge doesn't support streaming (returns undefined)
if (result === undefined) {
sftpRef.current.updateExternalUpload(transferId, {
status: 'failed',
error: t("sftp.error.downloadFailed"),
endTime: Date.now(),
});
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Handle error from result only if onError callback wasn't called
if (result?.error && !errorHandled) {
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelError ? 'cancelled' : 'failed',
error: isCancelError ? undefined : result.error,
endTime: Date.now(),
});
if (!isCancelError) {
toast.error(result.error, "SFTP");
}
}
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
@@ -349,7 +480,7 @@ export const useSftpViewFileOps = ({
);
}
},
[sftpRef, t],
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
);
const onDownloadFileLeft = useCallback(

View File

@@ -19,6 +19,23 @@ interface UseSftpViewPaneCallbacksParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
}
export const useSftpViewPaneCallbacks = ({
@@ -28,6 +45,9 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
@@ -37,6 +57,9 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection,
});
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */

View File

@@ -48,14 +48,14 @@ export const formatTransferBytes = (bytes: number): string => {
};
/**
* Format date as YYYY-MM-DD HH:mm:ss in local timezone
* Format date as YYYY-MM-DD hh:mm in local timezone
*/
export const formatDate = (timestamp: number | undefined): string => {
if (!timestamp) return '--';
const date = new Date(timestamp);
if (isNaN(date.getTime())) return '--';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
/**

View File

@@ -34,6 +34,29 @@ export interface TerminalConnectionDialogProps {
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | '_setShowLogs'>;
}
// Helper to get protocol display info
const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port: number } => {
// Check moshEnabled first since mosh uses protocol: "ssh" with moshEnabled: true
if (host.moshEnabled) {
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
}
const protocol = host.protocol || 'ssh';
switch (protocol) {
case 'local':
return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 };
case 'telnet':
// Telnet uses telnetPort, not port (which is SSH port)
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: host.telnetPort ?? host.port ?? 23 };
case 'mosh':
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
case 'serial':
return { i18nKey: 'terminal.connection.protocol.serial', showPort: false, port: 0 };
case 'ssh':
default:
return { i18nKey: 'terminal.connection.protocol.ssh', showPort: true, port: host.port || 22 };
}
};
export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> = ({
host,
status,
@@ -50,6 +73,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
const { t } = useI18n();
const hasError = Boolean(error);
const isConnecting = status === 'connecting';
const protocolInfo = getProtocolInfo(host);
return (
<div className={cn(
@@ -75,14 +99,14 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
</div>
</>
) : (
<>
<div className="text-sm font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
</div>
</>
)}

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { RightClickBehavior } from '../../domain/models';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
ContextMenu,
ContextMenuContent,
@@ -26,6 +26,7 @@ export interface TerminalContextMenuProps {
children: React.ReactNode;
hasSelection?: boolean;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
onCopy?: () => void;
onPaste?: () => void;
@@ -41,6 +42,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
onCopy,
onPaste,
@@ -54,12 +56,24 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
const copyShortcut = isMac ? '⌘C' : 'Ctrl+Shift+C';
const pasteShortcut = isMac ? '⌘V' : 'Ctrl+Shift+V';
const selectAllShortcut = isMac ? '⌘A' : 'Ctrl+Shift+A';
const splitHShortcut = isMac ? '⌘D' : 'Ctrl+Shift+D';
const splitVShortcut = isMac ? '⌘E' : 'Ctrl+Shift+E';
const clearShortcut = isMac ? '⌘K' : 'Ctrl+L';
// Helper to get shortcut from keyBindings and format for display
const getShortcut = (bindingId: string): string => {
const binding = keyBindings?.find(b => b.id === bindingId);
if (!binding) return '';
const key = isMac ? binding.mac : binding.pc;
if (!key || key === 'Disabled') return '';
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
return key.replace(/\s*\+\s*/g, ' ').trim();
};
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu';
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
@@ -76,71 +90,72 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
[rightClickBehavior, onPaste, onSelectWord],
);
if (rightClickBehavior !== 'context-menu') {
return (
<div onContextMenu={handleRightClick} className="contents">
{children}
</div>
);
}
// Always use ContextMenu wrapper to maintain consistent React tree structure
// This prevents terminal from unmounting when rightClickBehavior changes
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onPaste}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onPaste}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator />
<ContextMenuItem onClick={onSplitVertical}>
<SplitSquareHorizontal size={14} className="mr-2" />
{t('terminal.menu.splitHorizontal')}
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitHorizontal}>
<SplitSquareVertical size={14} className="mr-2" />
{t('terminal.menu.splitVertical')}
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitVertical}>
<SplitSquareHorizontal size={14} className="mr-2" />
{t('terminal.menu.splitHorizontal')}
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitHorizontal}>
<SplitSquareVertical size={14} className="mr-2" />
{t('terminal.menu.splitVertical')}
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator />
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onClose && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.closeTerminal')}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
{onClose && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.closeTerminal')}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
)}
</ContextMenu>
);
};
export default TerminalContextMenu;

View File

@@ -22,6 +22,7 @@ export class KeywordHighlighter implements IDisposable {
private debounceTimer: NodeJS.Timeout | null = null;
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
constructor(term: XTerm) {
this.term = term;
@@ -42,7 +43,16 @@ export class KeywordHighlighter implements IDisposable {
this.triggerRefresh();
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh())
this.term.onResize(() => this.triggerRefresh()),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
}
})
);
}

View File

@@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverClose = PopoverPrimitive.Close
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@@ -68,4 +70,4 @@ const PopoverContent = React.forwardRef<
})
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }

View File

@@ -1,16 +1,17 @@
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,SortAsc,SortDesc } from 'lucide-react';
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,SortAsc,SortDesc } from 'lucide-react';
import React from 'react';
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from './button';
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
export type SortMode = 'az' | 'za' | 'newest' | 'oldest';
export type SortMode = 'az' | 'za' | 'newest' | 'oldest' | 'group';
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
az: { labelKey: 'sort.az', icon: <SortAsc className="w-4 h-4 shrink-0" />, triggerIcon: <SortAsc className="w-4 h-4" /> },
za: { labelKey: 'sort.za', icon: <SortDesc className="w-4 h-4 shrink-0" />, triggerIcon: <SortDesc className="w-4 h-4" /> },
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
};
interface SortDropdownProps {

30
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,30 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "../../lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[999999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useRef } from "react";
import React, { useCallback, useRef, useState } from "react";
import { FileSymlink, Import } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { getVaultCsvTemplate } from "../../domain/vaultImport";
import type { VaultImportFormat } from "../../domain/vaultImport";
@@ -51,10 +52,15 @@ const OPTIONS: ImportOption[] = [
},
];
export type ImportOptions = {
managed?: boolean;
filePath?: string;
};
export type ImportVaultDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onFileSelected: (format: VaultImportFormat, file: File) => void;
onFileSelected: (format: VaultImportFormat, file: File, options?: ImportOptions) => void;
};
export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
@@ -65,6 +71,8 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const pendingFormatRef = useRef<VaultImportFormat | null>(null);
const pendingOptionsRef = useRef<ImportOptions | undefined>(undefined);
const [showManagedChoice, setShowManagedChoice] = useState(false);
const downloadCsvTemplate = useCallback(() => {
const csv = getVaultCsvTemplate();
@@ -78,10 +86,11 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
}, []);
const pickFile = useCallback(
(format: VaultImportFormat, accept: string) => {
(format: VaultImportFormat, accept: string, options?: ImportOptions) => {
const input = fileInputRef.current;
if (!input) return;
pendingFormatRef.current = format;
pendingOptionsRef.current = options;
input.accept = accept;
input.value = "";
input.click();
@@ -89,19 +98,50 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
[],
);
const handleFormatClick = useCallback(
(opt: ImportOption) => {
if (opt.format === "ssh_config") {
setShowManagedChoice(true);
} else {
pickFile(opt.format, opt.accept);
}
},
[pickFile],
);
const handleManagedChoice = useCallback(
(managed: boolean) => {
setShowManagedChoice(false);
pickFile("ssh_config", "*", { managed });
},
[pickFile],
);
const onChangeFile = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const format = pendingFormatRef.current;
const options = pendingOptionsRef.current;
if (!file || !format) return;
onFileSelected(format, file);
onFileSelected(format, file, options);
e.target.value = "";
pendingOptionsRef.current = undefined;
},
[onFileSelected],
);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setShowManagedChoice(false);
}
onOpenChange(newOpen);
},
[onOpenChange],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader className="text-center sm:text-center">
<div className="mx-auto h-14 w-14 rounded-2xl bg-muted/60 border border-border/60 flex items-center justify-center">
@@ -113,7 +153,9 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
</div>
<DialogTitle className="text-xl">{t("vault.import.title")}</DialogTitle>
<DialogDescription className="mx-auto max-w-xl">
{t("vault.import.desc")}
{showManagedChoice
? t("vault.import.sshConfig.chooseMode")
: t("vault.import.desc")}
</DialogDescription>
</DialogHeader>
@@ -125,51 +167,108 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
/>
<div className="flex flex-col gap-4">
<div className="text-sm font-medium text-center text-muted-foreground">
{t("vault.import.chooseFormat")}
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{OPTIONS.map((opt) => (
{showManagedChoice ? (
<>
<div className="text-sm font-medium text-center text-muted-foreground">
{t("vault.import.sshConfig.modeQuestion")}
</div>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
className={cn(
"group rounded-2xl border border-border/60 bg-background",
"px-4 py-6 hover:bg-muted/30 hover:border-border transition-colors",
"flex flex-col items-center gap-3",
)}
onClick={() => handleManagedChoice(false)}
>
<div className="h-12 w-12 rounded-xl bg-muted/60 flex items-center justify-center">
<Import className="h-6 w-6 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{t("vault.import.sshConfig.importOnly")}
</div>
<div className="text-xs text-muted-foreground text-center">
{t("vault.import.sshConfig.importOnlyDesc")}
</div>
</button>
<button
type="button"
className={cn(
"group rounded-2xl border border-primary/60 bg-primary/5",
"px-4 py-6 hover:bg-primary/10 hover:border-primary transition-colors",
"flex flex-col items-center gap-3",
)}
onClick={() => handleManagedChoice(true)}
>
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center">
<FileSymlink className="h-6 w-6 text-primary" />
</div>
<div className="text-sm font-medium text-foreground">
{t("vault.import.sshConfig.managed")}
</div>
<div className="text-xs text-muted-foreground text-center">
{t("vault.import.sshConfig.managedDesc")}
</div>
</button>
</div>
<button
key={opt.format}
type="button"
className={cn(
"group rounded-2xl border border-border/60 bg-background",
"px-3 py-4 hover:bg-muted/30 hover:border-border transition-colors",
"flex flex-col items-center gap-3",
)}
onClick={() => pickFile(opt.format, opt.accept)}
onClick={() => setShowManagedChoice(false)}
className="text-xs text-muted-foreground hover:text-foreground"
>
<div className="h-16 flex items-center justify-center">
<img
src={opt.iconSrc}
alt=""
className={cn(
"max-h-12 w-14 object-contain",
opt.format === "mobaxterm" && "w-16",
)}
/>
</div>
<div className="text-sm font-medium text-foreground">
{opt.label}
</div>
{t("common.back")}
</button>
))}
</div>
</>
) : (
<>
<div className="text-sm font-medium text-center text-muted-foreground">
{t("vault.import.chooseFormat")}
</div>
<div className="flex items-center justify-between gap-3 pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("vault.import.csv.tip")}
</div>
<button
type="button"
onClick={downloadCsvTemplate}
className="text-xs text-primary hover:underline"
>
{t("vault.import.csv.downloadTemplate")}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{OPTIONS.map((opt) => (
<button
key={opt.format}
type="button"
className={cn(
"group rounded-2xl border border-border/60 bg-background",
"px-3 py-4 hover:bg-muted/30 hover:border-border transition-colors",
"flex flex-col items-center gap-3",
)}
onClick={() => handleFormatClick(opt)}
>
<div className="h-16 flex items-center justify-center">
<img
src={opt.iconSrc}
alt=""
className={cn(
"max-h-12 w-14 object-contain",
opt.format === "mobaxterm" && "w-16",
)}
/>
</div>
<div className="text-sm font-medium text-foreground">
{opt.label}
</div>
</button>
))}
</div>
<div className="flex items-center justify-between gap-3 pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("vault.import.csv.tip")}
</div>
<button
type="button"
onClick={downloadCsvTemplate}
className="text-xs text-primary hover:underline"
>
{t("vault.import.csv.downloadTemplate")}
</button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -94,6 +94,8 @@ export interface Host {
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
managedSourceId?: string; // Reference to ManagedSource.id
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -309,7 +311,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + K', pc: 'Ctrl + L', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
// Navigation / Split View
@@ -320,11 +322,11 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
// App Features
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
{ id: 'open-local', action: 'openLocal', label: 'Open Local Terminal', mac: '⌘ + L', pc: 'Ctrl + L', category: 'app' },
{ id: 'open-sftp', action: 'openSftp', label: 'Open SFTP', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'open-sftp', action: 'openSftp', label: 'Open SFTP', mac: '⌘ + Shift + O', pc: 'Ctrl + Shift + O', category: 'app' },
{ id: 'port-forwarding', action: 'portForwarding', label: 'Open Port Forwarding', mac: '⌘ + P', pc: 'Ctrl + P', category: 'app' },
{ id: 'command-palette', action: 'commandPalette', label: 'Open Command Palette', mac: '⌘ + K', pc: 'Ctrl + K', category: 'app' },
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Alt + S', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
];
@@ -654,3 +656,15 @@ export interface SessionLogsSettings {
directory: string; // Base directory for logs
format: SessionLogFormat; // Log file format
}
// Managed Source - external file that manages a group of hosts (e.g., ~/.ssh/config)
export type ManagedSourceType = 'ssh_config';
export interface ManagedSource {
id: string;
type: ManagedSourceType;
filePath: string;
groupName: string;
lastSyncedAt: number;
lastFileHash?: string;
}

View File

@@ -0,0 +1,222 @@
import { Host } from "./models";
const DEFAULT_SSH_PORT = 22;
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
/**
* Check if a string is an IPv6 address
*/
const isIPv6 = (hostname: string): boolean => {
// IPv6 addresses contain colons and may be wrapped in brackets
return hostname.includes(':') && !hostname.startsWith('[');
};
/**
* Serialize a single jump host to ProxyJump format
* Format: [user@]host[:port]
* @param host - The jump host to serialize
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
*/
const serializeJumpHost = (host: Host, managedHostIds: Set<string>): string => {
let result = "";
if (host.username) {
result += `${host.username}@`;
}
// Only use label as alias if this jump host is in the managed hosts (has a Host block)
// and sanitize it by removing spaces. Otherwise use hostname directly.
let hostPart: string;
if (managedHostIds.has(host.id) && host.label) {
// Use sanitized label (same as the Host block alias)
hostPart = host.label.replace(/\s/g, '') || host.hostname;
} else {
// Jump host is outside managed config, use hostname directly
hostPart = host.hostname;
}
// For IPv6 addresses, always wrap in brackets to disambiguate colons
// OpenSSH requires brackets for IPv6 in ProxyJump regardless of port
if (isIPv6(hostPart)) {
result += `[${hostPart}]`;
if (host.port && host.port !== DEFAULT_SSH_PORT) {
result += `:${host.port}`;
}
} else {
result += hostPart;
if (host.port && host.port !== DEFAULT_SSH_PORT) {
result += `:${host.port}`;
}
}
return result;
};
/**
* Build ProxyJump directive from hostChain
* @param host - The host with hostChain
* @param allHosts - All hosts to look up jump host details
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
* @returns ProxyJump value string or null if chain is empty/invalid
*/
const buildProxyJumpValue = (
host: Host,
allHosts: Host[],
managedHostIds: Set<string>,
): string | null => {
if (!host.hostChain?.hostIds || host.hostChain.hostIds.length === 0) {
return null;
}
const hostMap = new Map(allHosts.map(h => [h.id, h]));
const jumpParts: string[] = [];
for (const jumpHostId of host.hostChain.hostIds) {
const jumpHost = hostMap.get(jumpHostId);
if (jumpHost) {
jumpParts.push(serializeJumpHost(jumpHost, managedHostIds));
}
}
return jumpParts.length > 0 ? jumpParts.join(",") : null;
};
export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): string => {
const blocks: string[] = [];
// Use provided allHosts for jump host lookup, or fall back to hosts array
const hostsForLookup = allHosts || hosts;
// Build set of managed host IDs (SSH hosts that will have Host blocks)
const managedHostIds = new Set(
hosts
.filter(h => !h.protocol || h.protocol === "ssh")
.map(h => h.id)
);
for (const host of hosts) {
if (host.protocol && host.protocol !== "ssh") continue;
const lines: string[] = [];
// Sanitize alias by removing spaces (SSH config doesn't allow spaces in Host patterns)
const alias = (host.label?.replace(/\s/g, '') || host.hostname);
lines.push(`Host ${alias}`);
if (host.hostname !== alias) {
lines.push(` HostName ${host.hostname}`);
}
if (host.username) {
lines.push(` User ${host.username}`);
}
if (host.port && host.port !== DEFAULT_SSH_PORT) {
lines.push(` Port ${host.port}`);
}
// Serialize ProxyJump if host has a chain
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
if (proxyJumpValue) {
lines.push(` ProxyJump ${proxyJumpValue}`);
}
blocks.push(lines.join("\n"));
}
return blocks.join("\n\n") + "\n";
};
export const mergeWithExistingSshConfig = (
existingContent: string,
managedHosts: Host[],
managedHostnameSet: Set<string>,
allHosts?: Host[],
): string => {
const lines = existingContent.split(/\r?\n/);
const preservedBlocks: string[] = [];
// Track preamble lines (comments/blank lines before first Host/Match block)
let preambleLines: string[] = [];
let seenFirstBlock = false;
let currentBlock: string[] = [];
let currentHostPatterns: string[] = [];
let isMatchBlock = false; // Track if current block is a Match block (always preserve)
const flush = () => {
if (currentBlock.length > 0) {
// Match blocks are always preserved (we don't manage them)
if (isMatchBlock) {
preservedBlocks.push(currentBlock.join("\n"));
} else {
// Filter out managed patterns from the Host line, keep non-managed ones
const nonManagedPatterns = currentHostPatterns.filter(
(p) => !managedHostnameSet.has(p.toLowerCase())
);
if (nonManagedPatterns.length === currentHostPatterns.length) {
// No managed patterns - preserve the entire block as-is
preservedBlocks.push(currentBlock.join("\n"));
} else if (nonManagedPatterns.length > 0) {
// Some patterns are managed, some are not - rewrite Host line with only non-managed patterns
const newHostLine = `Host ${nonManagedPatterns.join(" ")}`;
const restOfBlock = currentBlock.slice(1); // Everything after Host line
preservedBlocks.push([newHostLine, ...restOfBlock].join("\n"));
}
// If all patterns are managed (nonManagedPatterns.length === 0), drop the entire block
}
currentBlock = [];
currentHostPatterns = [];
isMatchBlock = false;
}
};
for (const line of lines) {
const trimmed = line.replace(/#.*/, "").trim();
const tokens = trimmed.split(/\s+/).filter(Boolean);
const keyword = tokens[0]?.toLowerCase();
if (keyword === "host") {
flush();
seenFirstBlock = true;
currentHostPatterns = tokens.slice(1);
currentBlock.push(line);
} else if (keyword === "match") {
flush();
seenFirstBlock = true;
isMatchBlock = true;
currentBlock.push(line);
} else if (!seenFirstBlock) {
// Preserve preamble lines (comments, blank lines before first block)
preambleLines.push(line);
} else if (currentBlock.length > 0) {
// Inside a block - add to current block
currentBlock.push(line);
} else {
// Between blocks (comments/blank lines after a block ended)
// These will be included with the next block or preserved separately
currentBlock.push(line);
}
}
flush();
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
const managedBlock = `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
const preserved = preservedBlocks.join("\n\n");
// Build final output: preamble + preserved blocks + managed block
const parts: string[] = [];
// Add preamble if it has content (trim trailing empty lines but keep structure)
const preamble = preambleLines.join("\n");
if (preamble.trim()) {
parts.push(preamble);
}
if (preserved.trim()) {
parts.push(preserved);
}
parts.push(managedBlock);
return parts.join("\n\n");
};

View File

@@ -998,3 +998,77 @@ export const getVaultCsvTemplate = (
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
if (/[",\r\n]/.test(value)) return `"${value}"`;
return value;
};
// Filter out serial hosts - CSV format doesn't support serial port configuration
// Note: mosh-enabled hosts are exported as SSH (losing mosh flag) rather than being skipped,
// since exporting partial data is better than losing the entire host entry
const isUnsupported = (h: Host) => h.protocol === "serial";
const exportableHosts = hosts.filter((h) => !isUnsupported(h));
// Helper to bracket IPv6 addresses for CSV export
// IPv6 addresses contain colons which would be misinterpreted as port separators on import
const formatHostname = (hostname: string): string => {
// Check if it looks like an IPv6 address (contains colons but not already bracketed)
if (hostname.includes(":") && !hostname.startsWith("[")) {
return `[${hostname}]`;
}
return hostname;
};
for (const host of exportableHosts) {
// For telnet hosts, use telnet-specific port and username
const isTelnet = host.protocol === "telnet";
const effectivePort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const effectiveUsername = isTelnet
? (host.telnetUsername ?? host.username ?? "")
: (host.username ?? "");
rows.push([
host.group ?? "",
host.label ?? "",
(host.tags ?? []).join(","),
formatHostname(host.hostname),
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export interface ExportHostsResult {
csv: string;
exportedCount: number;
skippedCount: number;
}
export const exportHostsToCsvWithStats = (hosts: Host[]): ExportHostsResult => {
// Only serial hosts are truly unsupported - mosh hosts are exported as SSH
const isUnsupported = (h: Host) => h.protocol === "serial";
const skippedHosts = hosts.filter((h) => isUnsupported(h));
const exportableHosts = hosts.filter((h) => !isUnsupported(h));
return {
csv: exportHostsToCsv(hosts),
exportedCount: exportableHosts.length,
skippedCount: skippedHosts.length,
};
};

View File

@@ -73,11 +73,7 @@ module.exports = {
target: [
{
target: 'nsis',
arch: ['x64']
},
{
target: 'dir',
arch: ['x64']
arch: ['x64', 'arm64']
}
]
},

View File

@@ -0,0 +1,556 @@
/**
* Compress Upload Bridge - Handles folder compression and upload
*
* Compresses folders locally using tar, uploads the archive, then extracts on remote server
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { getTempFilePath } = require("./tempDirBridge.cjs");
/**
* Escape shell arguments to prevent injection attacks
* Wraps arguments in single quotes and escapes any existing single quotes
*/
function escapeShellArg(arg) {
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
return "'" + arg.replace(/'/g, "'\\''") + "'";
}
// Shared references
let sftpClients = null;
let transferBridge = null;
// Active compress operations
const activeCompressions = new Map();
/**
* Initialize the compress upload bridge with dependencies
*/
function init(deps) {
sftpClients = deps.sftpClients;
transferBridge = deps.transferBridge;
}
/**
* Check if tar command is available on the system
*/
async function checkTarAvailable() {
return new Promise((resolve) => {
const tar = spawn('tar', ['--version'], { stdio: 'ignore' });
tar.on('close', (code) => {
resolve(code === 0);
});
tar.on('error', () => {
resolve(false);
});
});
}
/**
* Check if tar command is available on remote server
*/
async function checkRemoteTarAvailable(sftpId) {
try {
const client = sftpClients.get(sftpId);
if (!client) throw new Error("SFTP session not found");
// Try to execute tar --version via SSH
const sshClient = client.client; // Get underlying SSH2 client
if (!sshClient) throw new Error("SSH client not available");
return new Promise((resolve) => {
sshClient.exec('tar --version', (err, stream) => {
if (err) {
resolve(false);
return;
}
let hasOutput = false;
stream.on('data', () => {
hasOutput = true;
});
stream.on('close', (code) => {
resolve(code === 0 && hasOutput);
});
stream.on('error', () => {
resolve(false);
});
});
});
} catch {
return false;
}
}
/**
* Compress a folder using tar
*/
async function compressFolder(folderPath, outputPath, compressionId, sendProgress) {
return new Promise((resolve, reject) => {
const compression = activeCompressions.get(compressionId);
if (!compression) {
reject(new Error('Compression cancelled'));
return;
}
// Use tar with gzip compression, excluding macOS resource fork files
// -czf: create, gzip, file
// -C: change to directory (so we don't include the full path in archive)
// --exclude='._*': exclude macOS resource fork files
// --exclude='.DS_Store': exclude macOS folder metadata files
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
const tar = spawn('tar', [
'-czf', outputPath,
'-C', parentDir,
'--exclude=._*',
'--exclude=.DS_Store',
'--exclude=.Spotlight-V100',
'--exclude=.Trashes',
folderName
], {
stdio: ['ignore', 'pipe', 'pipe']
});
compression.process = tar;
let stderr = '';
// Monitor progress by checking output file size periodically
const progressInterval = setInterval(async () => {
if (compression.cancelled) {
clearInterval(progressInterval);
return;
}
try {
const stat = await fs.promises.stat(outputPath);
// We don't know the final size, so we'll show indeterminate progress
sendProgress(stat.size, 0); // 0 means indeterminate
} catch {
// File doesn't exist yet, ignore
}
}, 500);
tar.stderr.on('data', (data) => {
stderr += data.toString();
});
tar.on('close', (code) => {
clearInterval(progressInterval);
if (compression.cancelled) {
// Clean up output file if cancelled
fs.promises.unlink(outputPath).catch(() => {});
reject(new Error('Compression cancelled'));
return;
}
if (code === 0) {
resolve();
} else {
reject(new Error(`Tar compression failed: ${stderr}`));
}
});
tar.on('error', (err) => {
clearInterval(progressInterval);
reject(new Error(`Failed to start tar: ${err.message}`));
});
});
}
/**
* Extract archive on remote server
* @param {string} sftpId - SFTP session ID
* @param {string} archivePath - Path to the archive on remote server
* @param {string} targetDir - Target directory for extraction
* @param {number} [archiveSize] - Size of the archive in bytes (optional, for timeout calculation)
*/
async function extractRemoteArchive(sftpId, archivePath, targetDir, archiveSize) {
const client = sftpClients.get(sftpId);
if (!client) throw new Error("SFTP session not found");
const sshClient = client.client;
if (!sshClient) throw new Error("SSH client not available");
// Calculate timeout based on archive size
// Base: 60 seconds minimum
// Add 30 seconds per 10MB of archive size
// Maximum: 10 minutes to prevent excessively long waits
const baseTimeout = 60000; // 60 seconds minimum
const maxTimeout = 600000; // 10 minutes maximum
const sizeBasedTimeout = archiveSize ? Math.ceil(archiveSize / (10 * 1024 * 1024)) * 30000 : 0;
const extractionTimeout = Math.min(maxTimeout, Math.max(baseTimeout, baseTimeout + sizeBasedTimeout));
return new Promise((resolve, reject) => {
// Create target directory, extract, then always clean up the archive
// Use && for tar success, then always try cleanup regardless of tar result
// Also exclude any ._* files that might have been included despite our compression exclusions
// Properly escape shell arguments to prevent injection attacks
const escapedTargetDir = escapeShellArg(targetDir);
const escapedArchivePath = escapeShellArg(archivePath);
const command = `mkdir -p ${escapedTargetDir} && cd ${escapedTargetDir} && tar -xzf ${escapedArchivePath} --exclude='._*' --exclude='.DS_Store' && rm -f ${escapedArchivePath} || (rm -f ${escapedArchivePath}; exit 1)`;
sshClient.exec(command, (err, stream) => {
if (err) {
reject(new Error(`Failed to execute extraction command: ${err.message}`));
return;
}
let stderr = '';
let resolved = false;
stream.on('data', () => {
// stdout not needed, just consume the data
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
stream.on('close', (code) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
// The command uses `;` and `||` so cleanup should always run
// We only care about the tar extraction success (first part of command)
// The rm commands are just cleanup and their failure doesn't matter
// For most cases, code 0 means success
// If code is not 0, check if it's just cleanup failure
if (code === 0) {
resolve();
} else {
// Check if the error is from tar extraction or just cleanup
// If stderr contains tar errors, it's a real extraction failure
if (stderr.includes('tar:') || stderr.includes('gzip:') || stderr.includes('Cannot open:') || stderr.includes('not found in archive')) {
reject(new Error(`Remote extraction failed: ${stderr || 'Tar extraction error'}`));
} else {
// Likely just cleanup failure - consider it successful if no tar-specific errors
resolve();
}
}
});
stream.on('error', (err) => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
reject(new Error(`Stream error: ${err.message}`));
});
// Add timeout to prevent hanging (uses dynamic timeout based on archive size)
const timeout = setTimeout(() => {
if (resolved) return;
resolved = true;
reject(new Error(`Remote extraction timed out after ${extractionTimeout / 1000} seconds`));
}, extractionTimeout);
});
});
}
/**
* Start compressed folder upload
*/
async function startCompressedUpload(event, payload) {
const {
compressionId,
folderPath,
targetPath,
sftpId,
folderName
} = payload;
const sender = event.sender;
// Register compression for cancellation
const compression = { cancelled: false, process: null };
activeCompressions.set(compressionId, compression);
const sendProgress = (phase, transferred, total) => {
if (compression.cancelled) return;
sender.send("netcatty:compress:progress", {
compressionId,
phase,
transferred,
total
});
};
const sendComplete = () => {
// Send final 100% progress before completion
if (!compression.cancelled) {
sender.send("netcatty:compress:progress", {
compressionId,
phase: 'extracting',
transferred: 100,
total: 100
});
}
activeCompressions.delete(compressionId);
sender.send("netcatty:compress:complete", { compressionId });
};
const sendError = (error) => {
activeCompressions.delete(compressionId);
sender.send("netcatty:compress:error", {
compressionId,
error: error.message || String(error)
});
};
// Declare tempArchivePath in outer scope for cleanup access
let tempArchivePath = null;
try {
// Check if tar is available locally and remotely
const localTarAvailable = await checkTarAvailable();
if (!localTarAvailable) {
throw new Error("tar command not available on local system. Please install tar.");
}
const remoteTarAvailable = await checkRemoteTarAvailable(sftpId);
if (!remoteTarAvailable) {
throw new Error("tar command not available on remote server. Please install tar on the remote system.");
}
// Phase 1: Compression (0-30%)
sendProgress('compressing', 0, 100);
tempArchivePath = getTempFilePath(`${folderName}.tar.gz`);
await compressFolder(folderPath, tempArchivePath, compressionId, (transferred) => {
// Show compression progress (0-30%)
sendProgress('compressing', Math.min(30, transferred / 1024 / 1024), 100);
});
if (compression.cancelled) {
try {
await fs.promises.unlink(tempArchivePath);
} catch {
// Ignore cleanup errors
}
throw new Error('Upload cancelled');
}
// Get compressed file size
const stat = await fs.promises.stat(tempArchivePath);
const compressedSize = stat.size;
sendProgress('compressing', 30, 100);
// Phase 2: Upload (30-90%)
sendProgress('uploading', 30, 100);
const remoteArchivePath = `${targetPath}/${folderName}.tar.gz`;
// Use existing transfer bridge for upload with progress
const transferId = `compress-${compressionId}`;
// Progress callback to map upload progress to 30-90%
const onUploadProgress = (transferred, total, _speed) => {
if (compression.cancelled) return;
const uploadProgress = Math.min(60, (transferred / total) * 60);
sendProgress('uploading', 30 + uploadProgress, 100);
};
// Start the transfer with progress callback
await transferBridge.startTransfer(event, {
transferId,
sourcePath: tempArchivePath,
targetPath: remoteArchivePath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: compressedSize
}, onUploadProgress);
if (compression.cancelled) {
await fs.promises.unlink(tempArchivePath).catch(() => {});
throw new Error('Upload cancelled');
}
// Upload completed, update to 90%
sendProgress('uploading', 90, 100);
// Phase 3: Extraction (90-100%)
sendProgress('extracting', 90, 100);
await extractRemoteArchive(sftpId, remoteArchivePath, targetPath, compressedSize);
// Update progress to 95% after extraction
sendProgress('extracting', 95, 100);
// Perform cleanup operations asynchronously without blocking completion
// Note: These cleanup operations are best-effort; if the SFTP session closes before
// cleanup completes, errors will be silently ignored
setImmediate(async () => {
// Additional cleanup: remove any ._* files that might have been extracted
try {
const client = sftpClients.get(sftpId);
// Check both that client exists and connection is still open
if (client && client.client && client.client.writable !== false) {
const cleanupCommand = `find ${escapeShellArg(targetPath)} -name "._*" -type f -delete 2>/dev/null || true`;
client.client.exec(cleanupCommand, (err, stream) => {
if (err) {
// Silently ignore - session may have closed
return;
}
stream.on('close', () => {
// Cleanup completed
});
stream.on('error', () => {
// Silently ignore cleanup errors
});
});
}
} catch {
// Silently ignore cleanup errors
}
// Additional cleanup attempt - ensure remote archive is removed
try {
const client = sftpClients.get(sftpId);
if (client && client.client && client.client.writable !== false) {
client.client.exec(`rm -f ${escapeShellArg(remoteArchivePath)}`, (err, stream) => {
if (err) {
// Silently ignore - session may have closed
return;
}
stream.on('close', () => {
// Cleanup completed
});
stream.on('error', () => {
// Silently ignore cleanup errors
});
});
}
} catch {
// Silently ignore cleanup errors
}
});
// Clean up local temp file
try {
await fs.promises.unlink(tempArchivePath);
} catch {
// Ignore cleanup errors
}
// Check if cancelled during extraction before reporting completion
if (compression.cancelled) {
sender.send("netcatty:compress:cancelled", { compressionId });
return { compressionId, cancelled: true };
}
sendComplete();
return { compressionId, success: true };
} catch (err) {
// Clean up local temp file if it exists
if (tempArchivePath) {
try {
await fs.promises.unlink(tempArchivePath);
} catch {
// Ignore cleanup errors
}
}
if (err.message === 'Upload cancelled' || err.message === 'Compression cancelled' || err.message === 'Transfer cancelled') {
activeCompressions.delete(compressionId);
sender.send("netcatty:compress:cancelled", { compressionId });
} else {
sendError(err.message || 'Unknown error occurred');
}
return { compressionId, error: err.message };
} finally {
// Always clean up the active compression entry
activeCompressions.delete(compressionId);
}
}
/**
* Cancel a compression operation
*/
async function cancelCompression(event, payload) {
const { compressionId } = payload;
const compression = activeCompressions.get(compressionId);
if (compression) {
compression.cancelled = true;
// Kill the tar process if running
if (compression.process) {
try {
compression.process.kill('SIGTERM');
} catch {
// Ignore errors when killing process
}
}
// Cancel the associated transfer if it's running
const transferId = `compress-${compressionId}`;
if (transferBridge && transferBridge.cancelTransfer) {
try {
await transferBridge.cancelTransfer(event, { transferId });
} catch {
// Ignore errors when cancelling transfer
}
}
}
return { success: true };
}
/**
* Check if compressed upload is supported (tar available on both local and remote)
*/
async function checkCompressedUploadSupport(event, payload) {
const { sftpId } = payload;
try {
const localSupport = await checkTarAvailable();
const remoteSupport = await checkRemoteTarAvailable(sftpId);
return {
supported: localSupport && remoteSupport,
localTar: localSupport,
remoteTar: remoteSupport
};
} catch (err) {
return {
supported: false,
localTar: false,
remoteTar: false,
error: err.message
};
}
}
/**
* Register IPC handlers
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:compress:start", startCompressedUpload);
ipcMain.handle("netcatty:compress:cancel", cancelCompression);
ipcMain.handle("netcatty:compress:checkSupport", checkCompressedUploadSupport);
}
module.exports = {
init,
registerHandlers,
checkTarAvailable,
checkRemoteTarAvailable,
};

View File

@@ -6,19 +6,23 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
* Uses async exec to avoid blocking the main process
*/
function isWindowsHiddenFile(filePath) {
async function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
const { stdout } = await execAsync(`attrib "${filePath}"`);
// 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();
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",

View File

@@ -0,0 +1,141 @@
/**
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
* This module provides a mechanism to request passphrase input from the user
* when encountering encrypted default SSH keys in ~/.ssh
*/
// Passphrase request pending map
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
const passphraseRequests = new Map();
// TTL for abandoned requests (2 minutes)
const REQUEST_TTL_MS = 2 * 60 * 1000;
/**
* Generate a unique request ID for passphrase requests
*/
function generateRequestId(prefix = 'pp') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
/**
* Request passphrase from user via IPC
* @param {Object} sender - Electron webContents sender
* @param {string} keyPath - Path to the encrypted key
* @param {string} keyName - Name of the key (e.g., id_rsa)
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
*/
function requestPassphrase(sender, keyPath, keyName, hostname) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed()) {
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
resolve(null);
return;
}
const requestId = generateRequestId();
// Set up TTL timeout to clean up abandoned requests
const timeoutId = setTimeout(() => {
const pending = passphraseRequests.get(requestId);
if (pending) {
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
passphraseRequests.delete(requestId);
// Notify renderer to close the modal
try {
if (!sender.isDestroyed()) {
sender.send('netcatty:passphrase-timeout', { requestId });
}
} catch (err) {
console.warn('[Passphrase] Failed to send timeout notification:', err.message);
}
resolve(null);
}
}, REQUEST_TTL_MS);
passphraseRequests.set(requestId, {
resolveCallback: resolve,
webContentsId: sender.id,
keyPath,
keyName,
createdAt: Date.now(),
timeoutId,
});
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
try {
sender.send('netcatty:passphrase-request', {
requestId,
keyPath,
keyName,
hostname,
});
} catch (err) {
console.error('[Passphrase] Failed to send passphrase request:', err);
passphraseRequests.delete(requestId);
clearTimeout(timeoutId);
resolve(null);
}
});
}
/**
* Handle passphrase response from renderer
*/
function handleResponse(_event, payload) {
const { requestId, passphrase, cancelled, skipped } = payload;
const pending = passphraseRequests.get(requestId);
if (!pending) {
console.warn(`[Passphrase] No pending request for ${requestId}`);
return { success: false, error: 'Request not found' };
}
// Clear the TTL timeout
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
passphraseRequests.delete(requestId);
if (cancelled) {
// User clicked Cancel - stop the entire passphrase flow
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
pending.resolveCallback({ cancelled: true });
} else if (skipped) {
// User clicked Skip - skip this key but continue with others
console.log(`[Passphrase] Request ${requestId} skipped by user`);
pending.resolveCallback({ skipped: true });
} else {
console.log(`[Passphrase] Received passphrase for ${requestId}`);
pending.resolveCallback({ passphrase: passphrase || null });
}
return { success: true };
}
/**
* Register IPC handler for passphrase responses
*/
function registerHandler(ipcMain) {
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
}
/**
* Get pending requests (for debugging)
*/
function getRequests() {
return passphraseRequests;
}
module.exports = {
generateRequestId,
requestPassphrase,
handleResponse,
registerHandler,
getRequests,
};

View File

@@ -6,6 +6,11 @@
const net = require("node:net");
const { Client: SSHClient } = require("ssh2");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
} = require("./sshAuthHelper.cjs");
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
@@ -38,6 +43,7 @@ async function startPortForward(event, payload) {
username,
password,
privateKey,
passphrase,
} = payload;
return new Promise((resolve, reject) => {
@@ -63,59 +69,31 @@ async function startPortForward(event, payload) {
if (privateKey) {
connectOpts.privateKey = privateKey;
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
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;
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey,
password,
passphrase,
username: connectOpts.username,
logPrefix: "[PortForward]",
});
applyAuthToConnOpts(connectOpts, authConfig);
// 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;
}
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: tunnelId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId: tunnelId,
hostname,
password,
logPrefix: "[PortForward]",
}));
conn.on('ready', () => {

View File

@@ -23,6 +23,12 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
let sftpClients = null;
@@ -258,7 +264,8 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -282,7 +289,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000,
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
keepaliveInterval: 10000,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
@@ -318,11 +325,18 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
@@ -351,6 +365,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId: connId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
}));
conn.connect(connOpts);
});
@@ -648,7 +670,8 @@ async function openSftp(event, options) {
options,
jumpHosts,
options.hostname,
options.port || 22
options.port || 22,
connId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -700,78 +723,29 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
if (authAgent) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
} else if (options.privateKey && connectOpts.password) {
// Prefer key auth when both key and password are present (password still needed for sudo)
connectOpts.authHandler = ["publickey", "password"];
}
// 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;
}
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, event.sender.id, connId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
sessionId: connId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null,
});
};
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SFTP]",
});
applyAuthToConnOpts(connectOpts, authConfig);
// Create keyboard-interactive handler using shared helper
const kiHandler = createKeyboardInteractiveHandler({
sender: event.sender,
sessionId: connId,
hostname: options.hostname,
password: options.password,
logPrefix: "[SFTP]",
});
// 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
@@ -1038,6 +1012,11 @@ async function writeSftpBinaryWithProgress(event, payload) {
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(remotePath, encoding);
// Extract callback functions from payload
const onProgress = payload.onProgress;
const onComplete = payload.onComplete;
const onError = payload.onError;
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
@@ -1085,13 +1064,22 @@ async function writeSftpBinaryWithProgress(event, payload) {
const isComplete = transferredBytes >= totalBytes;
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
transferred: transferredBytes,
totalBytes,
speed,
});
// Call the progress callback if provided, otherwise send IPC event
if (typeof onProgress === 'function') {
try {
onProgress(transferredBytes, totalBytes, speed);
} catch (err) {
console.warn('[SFTP] Progress callback error:', err);
}
} else {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:progress", {
transferId,
transferred: transferredBytes,
totalBytes,
speed,
});
}
lastProgressSentTime = now;
lastProgressSentBytes = transferredBytes;
}
@@ -1109,22 +1097,40 @@ async function writeSftpBinaryWithProgress(event, payload) {
try {
await client.put(readableStream, encodedPath);
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:complete", { transferId });
// Call the complete callback if provided, otherwise send IPC event
if (typeof onComplete === 'function') {
try {
onComplete();
} catch (err) {
console.warn('[SFTP] Complete callback error:', err);
}
} else {
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);
// Check if this upload was cancelled - the error might not be exactly "Upload cancelled"
// when stream is destroyed, SFTP server may return different errors like "Write stream error"
const uploadState = activeSftpUploads.get(transferId);
if (uploadState?.cancelled || err.message === "Upload cancelled") {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:cancelled", { transferId });
return { success: false, transferId, cancelled: true };
}
contents?.send("netcatty:upload:error", { transferId, error: err.message });
// Call the error callback if provided, otherwise send IPC event
if (typeof onError === 'function') {
try {
onError(err.message);
} catch (callbackErr) {
console.warn('[SFTP] Error callback error:', callbackErr);
}
} else {
const contents = electronModule.webContents.fromId(event.sender.id);
contents?.send("netcatty:upload:error", { transferId, error: err.message });
}
throw err;
} finally {
// Cleanup

View File

@@ -0,0 +1,533 @@
/**
* SSH Authentication Helper - Shared authentication logic for SSH connections
* Used by sshBridge, sftpBridge, and portForwardingBridge
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for DEK-Info header (legacy PEM encryption indicator)
if (keyContent.includes("DEK-Info:")) return true;
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Extract the base64 content between the markers
const base64Match = keyContent.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
}
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* @param {Object} [options]
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>}
*/
function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
continue; // Skip encrypted keys when not including them
}
keys.push({
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
});
} catch {
continue;
}
}
}
return keys;
}
/**
* Get ssh-agent socket path based on platform
* @returns {string|null}
*/
function getSshAgentSocket() {
if (process.platform === "win32") {
return "\\\\.\\pipe\\openssh-ssh-agent";
}
return process.env.SSH_AUTH_SOCK || null;
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
* @param {string} [options.privateKey] - Explicitly configured private key
* @param {string} [options.password] - Password for authentication
* @param {string} [options.passphrase] - Passphrase for encrypted private key
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
* @param {string} options.username - SSH username
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
const hasExplicitPassword = !!password;
const hasExplicitAgent = !!agent;
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
// Determine if this is a password-only or key-only connection
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
const sshAgentSocket = getSshAgentSocket();
const defaultKeys = findAllDefaultPrivateKeys();
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
// - No explicit auth is configured (pure fallback mode)
// When user configured key/password, system agent should only be used AFTER as fallback
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
// Determine effective agent
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
// Determine effective privateKey (user-provided takes priority)
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
// Determine fallback keys (keys to try after user's primary auth fails)
// - If user provided a key: all default keys are fallbacks
// - If no explicit auth: first default key is primary, rest are fallbacks
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
const fallbackKeys = hasExplicitKey
? defaultKeys
: !hasExplicitAuth
? defaultKeys.slice(1)
: defaultKeys;
// Check if we need dynamic handler (have fallback options)
const hasFallbackOptions = fallbackKeys.length > 0 ||
(!hasExplicitAgent && sshAgentSocket) ||
(isPasswordOnly && defaultKeys.length > 0);
// If only simple auth methods and no fallback keys needed, use array-based handler
if (hasExplicitAuth && !hasFallbackOptions) {
const authMethods = [];
if (effectiveAgent) authMethods.push("agent");
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
return {
authHandler: authMethods,
privateKey: effectivePrivateKey,
agent: effectiveAgent,
usedDefaultKeys: false,
};
}
// Build comprehensive authMethods array with all auth options
// Order depends on what user explicitly configured:
// - Password-only: password -> agent -> default keys -> keyboard-interactive
// - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
// - No explicit auth: agent -> default keys -> keyboard-interactive
const authMethods = [];
if (isPasswordOnly) {
// Password-only: password first, then fallbacks
authMethods.push({ type: "password", id: "password" });
// Add agent and default keys AFTER password as fallback
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
for (const keyInfo of defaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (isKeyOnly) {
// Key-only: user key first, then password (if any), then agent/default keys as fallback
// 1. User-provided key first
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
// 2. Password (if configured alongside key)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 3. System agent as fallback (AFTER user's key)
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else {
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
// 1. Agent (user-provided or system)
if (effectiveAgent) {
authMethods.push({ type: "agent", id: "agent" });
}
// 2. User-provided key
if (privateKey) {
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
}
// 3. Password (if configured)
if (password) {
authMethods.push({ type: "password", id: "password" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
// 5. If no user key provided, add first default key at the beginning (after agent)
if (!privateKey && defaultKeys.length > 0) {
const insertIndex = effectiveAgent ? 1 : 0;
authMethods.splice(insertIndex, 0, {
type: "publickey",
key: defaultKeys[0].privateKey,
id: `publickey-default-${defaultKeys[0].keyName}`
});
}
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Keyboard-interactive as last resort
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
console.log(`${logPrefix} Auth methods configured`, {
isPasswordOnly,
hasUserKey: !!privateKey,
hasPassword: !!password,
hasAgent: !!effectiveAgent,
methodCount: authMethods.length,
methods: authMethods.map(m => m.id),
});
// Use dynamic authHandler to try all keys
let authIndex = 0;
const attemptedMethodIds = new Set();
const authHandler = (methodsLeft, partialSuccess, callback) => {
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
if (attemptedMethodIds.has(method.id)) continue;
attemptedMethodIds.add(method.id);
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
console.log(`${logPrefix} Trying agent auth`);
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
const pubkeyAuth = {
type: "publickey",
username,
key: method.key,
};
if (method.passphrase) {
pubkeyAuth.passphrase = method.passphrase;
}
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
console.log(`${logPrefix} Trying password auth`);
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
return callback("keyboard-interactive");
}
}
return callback(false);
};
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
// even if effectiveAgent is null (for fallback scenarios)
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
return {
authHandler,
privateKey: effectivePrivateKey,
agent: returnAgent,
usedDefaultKeys: true,
};
}
/**
* Create a keyboard-interactive event handler
* @param {Object} options
* @param {Object} options.sender - Electron webContents sender
* @param {string} options.sessionId - Session/connection ID
* @param {string} options.hostname - Host being connected to
* @param {string} [options.password] - Saved password for fill button
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
});
// 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;
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || hostname,
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
};
}
/**
* 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.
}
}
/**
* Apply auth configuration to connection options
* Convenience function that combines buildAuthHandler results with connOpts
* @param {Object} connOpts - SSH connection options to modify
* @param {Object} authConfig - Auth configuration from buildAuthHandler
*/
function applyAuthToConnOpts(connOpts, authConfig) {
connOpts.authHandler = authConfig.authHandler;
if (authConfig.privateKey) {
connOpts.privateKey = authConfig.privateKey;
}
if (authConfig.agent) {
connOpts.agent = authConfig.agent;
}
}
/**
* Request passphrases for encrypted default keys
* Shows a modal for each encrypted key and collects passphrases
* @param {Object} sender - Electron webContents sender
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
*/
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
const allKeys = findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
if (encryptedKeys.length === 0) {
return { keys: [], cancelled: false };
}
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
const unlockedKeys = [];
let wasCancelled = false;
for (const keyInfo of encryptedKeys) {
const result = await passphraseHandler.requestPassphrase(
sender,
keyInfo.keyPath,
keyInfo.keyName,
hostname
);
// Handle different response types
if (!result) {
// Timeout or error - continue with next key
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
continue;
}
if (result.cancelled) {
// User clicked Cancel - stop the entire flow
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
wasCancelled = true;
break;
}
if (result.skipped) {
// User clicked Skip - continue with next key
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
continue;
}
if (result.passphrase) {
// User provided passphrase
unlockedKeys.push({
privateKey: keyInfo.privateKey,
keyPath: keyInfo.keyPath,
keyName: keyInfo.keyName,
passphrase: result.passphrase,
});
}
}
return { keys: unlockedKeys, cancelled: wasCancelled };
}
module.exports = {
DEFAULT_KEY_NAMES,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,
};

View File

@@ -11,7 +11,16 @@ const { exec } = require("node:child_process");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
requestPassphrasesForEncryptedKeys,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
} = require("./sshAuthHelper.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -71,14 +80,18 @@ function isKeyEncrypted(keyContent) {
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
log("Checking key file", { keyPath, exists: fs.existsSync(keyPath) });
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
if (isKeyEncrypted(privateKey)) {
const encrypted = isKeyEncrypted(privateKey);
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
if (encrypted) {
log("Skipping encrypted default key", { keyPath, keyName: name });
continue;
}
@@ -90,9 +103,40 @@ function findDefaultPrivateKey() {
}
}
}
log("No suitable default SSH key found");
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* Returns all non-encrypted keys for fallback authentication
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
*/
function findAllDefaultPrivateKeys() {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
keys.push({ privateKey, keyPath, keyName: name });
log("Found default key for fallback", { keyPath, keyName: name });
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
}
}
}
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
@@ -124,13 +168,45 @@ 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 { }
console.log("[SSH]", msg, data || "");
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
// Authentication method cache - remembers successful auth methods per host
// Key format: "username@hostname:port"
// Value: { method: "password" | "publickey" | "publickey-default" }
// Cache persists until auth failure, then cleared to retry all methods
const authMethodCache = new Map();
function getAuthCacheKey(username, hostname, port) {
return `${username}@${hostname}:${port || 22}`;
}
function getCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
const cached = authMethodCache.get(key);
if (cached) {
log("Using cached auth method", { key, method: cached.method });
return cached.method;
}
return null;
}
function setCachedAuthMethod(username, hostname, port, method) {
const key = getAuthCacheKey(username, hostname, port);
log("Caching successful auth method", { key, method });
authMethodCache.set(key, { method });
}
function clearCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
log("Clearing cached auth method", { key });
authMethodCache.delete(key);
}
// Normalize charset inputs (often provided as bare encodings like "UTF-8")
// into a usable LANG locale for remote shells.
function resolveLangFromCharset(charset) {
@@ -162,7 +238,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts
*/
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort) {
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -192,7 +268,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
host: jump.hostname,
port: jump.port || 22,
username: jump.username || 'root',
readyTimeout: 20000, // Reduced from 60s for faster failure detection
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
// 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,
@@ -208,7 +284,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
},
};
// Auth - support agent (certificate), key, and password fallback
// Auth - support agent (certificate), key, password, and default key fallback
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
@@ -232,11 +308,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
if (jump.password) connOpts.password = jump.password;
if (authAgent) {
const order = ["agent"];
if (connOpts.password) order.push("password");
connOpts.authHandler = order;
}
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
privateKey: connOpts.privateKey,
password: connOpts.password,
passphrase: connOpts.passphrase,
agent: connOpts.agent,
username: connOpts.username,
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
});
applyAuthToConnOpts(connOpts, authConfig);
// If first hop and proxy is configured, connect through proxy
if (isFirst && options.proxy) {
@@ -267,6 +350,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
}));
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
@@ -408,21 +499,63 @@ async function startSSHSession(event, options) {
}
}
if (options.password) {
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
connectOpts.password = options.password;
}
// Fallback to default SSH key if no authentication method is configured
let usedDefaultKey = null;
// Always try to find default SSH keys for fallback authentication
// This allows fallback even when password auth fails
let defaultKeyInfo = null;
let allDefaultKeys = [];
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
defaultKeyInfo = defaultKey;
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
}
// Also find ALL default keys for comprehensive fallback
allDefaultKeys = findAllDefaultPrivateKeys();
// Use unlocked encrypted keys if provided (from retry after auth failure)
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
if (unlockedEncryptedKeys.length > 0) {
log("Using unlocked encrypted keys from retry", {
count: unlockedEncryptedKeys.length,
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
});
}
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
connectOpts.privateKey = defaultKey.privateKey;
usedDefaultKey = defaultKey;
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
connectOpts.privateKey = allDefaultKeys[0].privateKey;
usedDefaultKeyAsPrimary = true;
} else {
log("No default SSH key found in ~/.ssh directory");
}
}
log("Final auth configuration", {
hasPrivateKey: !!connectOpts.privateKey,
hasPassword: !!connectOpts.password,
hasAgent: !!connectOpts.agent,
hasDefaultKeyFallback: !!defaultKeyInfo,
});
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
@@ -435,12 +568,248 @@ async function startSSHSession(event, options) {
}
}
// Prefer agent-based auth when we created an in-process agent (cert)
// Build authentication handler with fallback support
// ssh2 authHandler can be a function that returns the next auth method to try
// Check if we have a cached successful auth method for this host
const cachedMethod = getCachedAuthMethod(connectOpts.username, options.hostname, options.port);
// Track which method succeeded for caching
let lastTriedMethod = null;
if (authAgent) {
const order = ["agent"];
// Allow password fallback if provided
if (connectOpts.password) order.push("password");
// Add default key fallback if available and no user key configured
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
if (defaultKeyInfo && !options.privateKey) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
order.push("publickey");
}
order.push("keyboard-interactive");
connectOpts.authHandler = order;
log("Auth order (agent mode)", { order });
} else {
// Build dynamic auth handler for fallback support
const authMethods = [];
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try agent if configured (try agent before password since it's usually faster)
if (connectOpts.agent) {
authMethods.push({ type: "agent", id: "agent" });
}
// Then try password if available (explicit user choice)
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try ALL default SSH keys as fallback (not just the first one!)
// This is critical because different servers may have different keys in authorized_keys
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
// Single default key fallback (when user has configured other auth methods)
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
isDefault: true,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Finally try keyboard-interactive
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod,
usedDefaultKeyAsPrimary
});
// Reorder methods based on cached successful method
if (cachedMethod) {
const cachedIndex = authMethods.findIndex(m => m.id === cachedMethod);
if (cachedIndex > 0) {
const [cachedAuthMethod] = authMethods.splice(cachedIndex, 1);
authMethods.unshift(cachedAuthMethod);
log("Reordered auth methods based on cache", {
methods: authMethods.map(m => m.id)
});
}
}
// Use dynamic authHandler if we have multiple auth options
if (authMethods.length > 1) {
let authIndex = 0;
// Track methods that have been attempted (to avoid re-trying on failure)
// This prevents reusing the same key when server requires multiple publickey auth steps
// and also prevents re-attempting failed methods
const attemptedMethodIds = new Set();
// Track the first successful method for caching (not the last one in multi-step flows)
let firstSuccessfulMethod = null;
// Track if we've gone through a partialSuccess flow (multi-step auth)
let hadPartialSuccess = false;
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
// methodsLeft can be null on first call (before server responds with available methods)
// Include "agent" for SSH agent-based auth (used with agentForwarding)
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Handle partialSuccess case (e.g., password succeeded but server requires additional auth like MFA)
// When partialSuccess is true, we should try the remaining methods the server is asking for
if (partialSuccess && methodsLeft && methodsLeft.length > 0) {
hadPartialSuccess = true;
// Record the first successful method (the one that triggered partialSuccess)
if (lastTriedMethod && !firstSuccessfulMethod) {
firstSuccessfulMethod = lastTriedMethod;
log("Recorded first successful method for caching", { method: firstSuccessfulMethod });
}
// Mark the last tried method as attempted (it succeeded, so we shouldn't retry it)
if (lastTriedMethod) {
attemptedMethodIds.add(lastTriedMethod);
log("Marked method as attempted (partial success)", { method: lastTriedMethod });
}
log("Partial success - server requires additional auth", { methodsLeft, attemptedMethodIds: Array.from(attemptedMethodIds) });
// Find a method from our list that matches what the server wants
// Skip methods that have already been attempted
for (const serverMethod of methodsLeft) {
// Map server method names to our method types
const matchingMethod = authMethods.find(m => {
// Skip already attempted methods
if (attemptedMethodIds.has(m.id)) return false;
if (serverMethod === "keyboard-interactive" && m.type === "keyboard-interactive") return true;
if (serverMethod === "password" && m.type === "password") return true;
if (serverMethod === "publickey" && (m.type === "publickey" || m.type === "agent")) return true;
return false;
});
if (matchingMethod) {
log("Found matching method for partial success", { serverMethod, matchingMethod: matchingMethod.id });
// Mark as attempted BEFORE returning to prevent re-use on failure
attemptedMethodIds.add(matchingMethod.id);
lastTriedMethod = matchingMethod.id;
if (matchingMethod.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth (partial success)", { id: matchingMethod.id });
return callback("keyboard-interactive");
} else if (matchingMethod.type === "password") {
log("Trying password auth (partial success)", { id: matchingMethod.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (matchingMethod.type === "agent") {
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth (partial success)", { id: matchingMethod.id, agentType });
return callback("agent");
} else if (matchingMethod.type === "publickey") {
log("Trying publickey auth (partial success)", { id: matchingMethod.id });
return callback({
type: "publickey",
username: connectOpts.username,
key: matchingMethod.key,
passphrase: matchingMethod.passphrase,
});
}
}
}
// No matching method found for partial success
log("No matching method found for partial success requirements", { methodsLeft });
return callback(false);
}
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
// Skip methods that have already been attempted (e.g., during partial success handling)
if (attemptedMethodIds.has(method.id)) {
log("Skipping already attempted method", { method: method.id });
continue;
}
// Check if this method is still available on server
// Note: "agent" uses "publickey" as the underlying method type
const methodName = method.type === "password" ? "password" :
method.type === "publickey" ? "publickey" :
method.type === "agent" ? "publickey" : "keyboard-interactive";
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
log("Auth method not available on server, skipping", { method: method.id });
continue;
}
// Mark as attempted BEFORE returning
attemptedMethodIds.add(method.id);
lastTriedMethod = method.id;
if (method.type === "agent") {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
return callback({
type: "publickey",
username: connectOpts.username,
key: method.key,
passphrase: method.passphrase,
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
return callback("keyboard-interactive");
}
}
log("All auth methods exhausted");
return callback(false);
};
// Store method reference for success callback
// For multi-step auth (partialSuccess), cache the first successful method, not the last
// This ensures next connection starts with the correct first factor
connectOpts._lastTriedMethodRef = () => {
if (hadPartialSuccess && firstSuccessfulMethod) {
log("Using first successful method for cache (multi-step auth)", { firstSuccessfulMethod });
return firstSuccessfulMethod;
}
return lastTriedMethod;
};
}
}
// Handle chain/proxy connections
@@ -450,7 +819,8 @@ async function startSSHSession(event, options) {
options,
jumpHosts,
options.hostname,
options.port || 22
options.port || 22,
sessionId
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -476,6 +846,15 @@ async function startSSHSession(event, options) {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
// Cache the successful auth method
if (connectOpts._lastTriedMethodRef) {
const successMethod = connectOpts._lastTriedMethodRef();
if (successMethod) {
setCachedAuthMethod(connectOpts.username, options.hostname, options.port, successMethod);
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
@@ -584,8 +963,9 @@ async function startSSHSession(event, options) {
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
// Clear cached auth method on auth failure so next attempt tries all methods
if (isAuthError) {
clearCachedAuthMethod(connectOpts.username, options.hostname, options.port);
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
@@ -670,23 +1050,39 @@ async function startSSHSession(event, options) {
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Note: If authHandler is a function (for fallback support), keyboard-interactive
// is already included in the auth methods list
if (Array.isArray(connectOpts.authHandler)) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
const authMethods = [];
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
if (connectOpts.agent) authMethods.push("agent");
if (connectOpts.privateKey) authMethods.push("publickey");
if (connectOpts.password) authMethods.push("password");
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
}
// If authHandler is a function, it already handles keyboard-interactive
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
// Enable debug logging for ssh2 to diagnose auth issues
connectOpts.debug = (msg) => {
// Only log auth-related messages to avoid noise
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
log("ssh2 debug", { msg });
}
};
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});
@@ -858,6 +1254,57 @@ async function startSSHSessionWrapper(event, options) {
err.level === 'client-authentication';
if (isAuthError) {
// Check if there are encrypted default keys we haven't tried yet
// Only offer retry if no unlocked keys were provided in this attempt
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
const allKeysWithEncrypted = findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
if (encryptedKeys.length > 0) {
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname
);
// If user cancelled, don't retry even if some keys were unlocked
if (passphraseResult.cancelled) {
console.log('[SSH] User cancelled passphrase flow, not retrying');
} else if (passphraseResult.keys.length > 0) {
console.log('[SSH] User unlocked keys, retrying connection...', {
count: passphraseResult.keys.length,
keyNames: passphraseResult.keys.map(k => k.keyName)
});
// Retry connection with unlocked keys
// Wrap in try-catch to ensure consistent error handling for retry failures
try {
return await startSSHSession(event, {
...options,
_unlockedEncryptedKeys: passphraseResult.keys,
});
} catch (retryErr) {
// Re-wrap retry errors the same way as initial errors
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
retryErr.message?.toLowerCase().includes('auth') ||
retryErr.level === 'client-authentication';
if (isRetryAuthError) {
const authError = new Error(retryErr.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
throw authError;
}
throw retryErr;
}
} else {
console.log('[SSH] User did not unlock any keys, not retrying');
}
}
}
// 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);
@@ -1281,6 +1728,8 @@ function registerHandlers(ipcMain) {
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
// Register the passphrase response handler
passphraseHandler.registerHandler(ipcMain);
}
module.exports = {
@@ -1294,4 +1743,8 @@ module.exports = {
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
isKeyEncrypted,
findAllDefaultPrivateKeys,
isKeyEncrypted,
};

View File

@@ -24,9 +24,144 @@ function init(deps) {
}
/**
* Start a file transfer
* Upload a local file to SFTP using streams (supports cancellation)
*/
async function startTransfer(event, payload) {
async function uploadWithStreams(localPath, remotePath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath);
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const writeStream = sftp.createWriteStream(remotePath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
readStream.pipe(writeStream);
});
}
/**
* Download from SFTP to local file using streams (supports cancellation)
*/
async function downloadWithStreams(remotePath, localPath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
}
/**
* Start a file transfer
* @param {object} event - IPC event
* @param {object} payload - Transfer configuration
* @param {function} [onProgress] - Optional progress callback (transferred, total, speed)
*/
async function startTransfer(event, payload, onProgress) {
const {
transferId,
sourcePath,
@@ -40,17 +175,18 @@ async function startTransfer(event, payload) {
targetEncoding,
} = payload;
const sender = event.sender;
// Register transfer for cancellation
activeTransfers.set(transferId, { cancelled: false });
const transfer = { cancelled: false, readStream: null, writeStream: null };
activeTransfers.set(transferId, transfer);
let lastTime = Date.now();
let lastTransferred = 0;
let speed = 0;
const sendProgress = (transferred, total) => {
if (activeTransfers.get(transferId)?.cancelled) return;
if (transfer.cancelled) return;
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed >= 100) {
@@ -58,25 +194,28 @@ async function startTransfer(event, payload) {
lastTime = now;
lastTransferred = transferred;
}
// Call optional progress callback if provided
if (onProgress) {
onProgress(transferred, total, speed);
}
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
};
const sendComplete = () => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:complete", { transferId });
};
const sendError = (error) => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:error", { transferId, error: error.message || String(error) });
};
const isCancelled = () => activeTransfers.get(transferId)?.cancelled;
try {
let fileSize = totalBytes || 0;
// Get file size if not provided
if (!fileSize) {
if (sourceType === 'local') {
@@ -90,123 +229,124 @@ async function startTransfer(event, payload) {
fileSize = stat.size;
}
}
// Send initial progress
sendProgress(0, fileSize);
// Handle different transfer scenarios
if (sourceType === 'local' && targetType === 'sftp') {
// Upload: Local -> SFTP
// Upload: Local -> SFTP using streams (supports cancellation)
const client = sftpClients.get(targetSftpId);
if (!client) throw new Error("Target SFTP session not found");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await client.fastPut(sourcePath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await uploadWithStreams(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'sftp' && targetType === 'local') {
// Download: SFTP -> Local
// Download: SFTP -> Local using streams (supports cancellation)
const client = sftpClients.get(sourceSftpId);
if (!client) throw new Error("Source SFTP session not found");
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await client.fastGet(encodedSourcePath, targetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await downloadWithStreams(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
// Local copy: use streams
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(targetPath);
let transferred = 0;
const transfer = activeTransfers.get(transferId);
if (transfer) {
transfer.readStream = readStream;
transfer.writeStream = writeStream;
}
let finished = false;
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (isCancelled()) {
readStream.destroy();
writeStream.destroy();
reject(new Error('Transfer cancelled'));
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
// SFTP to SFTP: download to temp then upload
// SFTP to SFTP: download to temp then upload using streams
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
// Download phase (0-50%)
// Download phase (0-50%) - wrap progress to show 0-50%
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await sourceClient.fastGet(encodedSourcePath, tempPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(totalTransferred / 2), fileSize);
}
});
if (isCancelled()) {
const downloadProgress = (transferred, total) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadWithStreams(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch {}
throw new Error('Transfer cancelled');
}
// Upload phase (50-100%)
// Upload phase (50-100%) - wrap progress to show 50-100%
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await targetClient.fastPut(tempPath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(fileSize / 2) + Math.floor(totalTransferred / 2), fileSize);
}
});
const uploadProgress = (transferred, total) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadWithStreams(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
// Cleanup temp file
try { await fs.promises.unlink(tempPath); } catch {}
} else {
throw new Error("Invalid transfer configuration");
}
@@ -232,16 +372,24 @@ async function startTransfer(event, payload) {
*/
async function cancelTransfer(event, payload) {
const { transferId } = payload;
console.log('[transferBridge] cancelTransfer called for:', transferId);
const transfer = activeTransfers.get(transferId);
console.log('[transferBridge] Found transfer:', !!transfer, 'activeTransfers keys:', Array.from(activeTransfers.keys()));
if (transfer) {
transfer.cancelled = true;
console.log('[transferBridge] Set cancelled=true for transfer:', transferId);
// Destroy streams to immediately stop the transfer
if (transfer.readStream) {
try { transfer.readStream.destroy(); } catch {}
console.log('[transferBridge] Destroying read stream');
try { transfer.readStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying readStream:', e); }
}
if (transfer.writeStream) {
try { transfer.writeStream.destroy(); } catch {}
console.log('[transferBridge] Destroying write stream');
try { transfer.writeStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying writeStream:', e); }
}
activeTransfers.delete(transferId);
console.log('[transferBridge] Transfer marked for cancellation');
}
return { success: true };
}

View File

@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -363,6 +364,12 @@ const registerBridges = (win) => {
transferBridge.init(deps);
terminalBridge.init(deps);
fileWatcherBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
...deps,
transferBridge,
});
// Initialize temp directory (synchronously)
tempDirBridge.ensureTempDir();
@@ -382,6 +389,7 @@ const registerBridges = (win) => {
fileWatcherBridge.registerHandlers(ipcMain);
tempDirBridge.registerHandlers(ipcMain, shell);
sessionLogsBridge.registerHandlers(ipcMain);
compressUploadBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {
@@ -551,6 +559,22 @@ const registerBridges = (win) => {
}
});
// Show save file dialog and return selected path
ipcMain.handle("netcatty:showSaveDialog", async (_event, { defaultPath, filters }) => {
const { dialog } = electronModule;
const result = await dialog.showSaveDialog({
defaultPath,
filters: filters || [{ name: "All Files", extensions: ["*"] }],
});
if (result.canceled || !result.filePath) {
return null;
}
return result.filePath;
});
// Download SFTP file to temp and return local path
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
console.log(`[Main] Downloading SFTP file to temp:`);

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge } = require("electron");
const { ipcRenderer, contextBridge, webUtils } = require("electron");
const dataListeners = new Map();
const exitListeners = new Map();
@@ -10,6 +10,8 @@ const authFailedListeners = new Map();
const languageChangeListeners = new Set();
const fullscreenChangeListeners = new Set();
const keyboardInteractiveListeners = new Set();
const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
@@ -98,6 +100,28 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
});
});
// Passphrase request events for encrypted SSH keys
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
passphraseListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Passphrase request callback failed", err);
}
});
});
// Passphrase timeout events (request expired)
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
passphraseTimeoutListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Passphrase timeout callback failed", err);
}
});
});
// Transfer progress events
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
const cb = transferProgressListeners.get(payload.transferId);
@@ -152,6 +176,11 @@ const uploadProgressListeners = new Map();
const uploadCompleteListeners = new Map();
const uploadErrorListeners = new Map();
// Compress upload listeners
const compressProgressListeners = new Map();
const compressCompleteListeners = new Map();
const compressErrorListeners = new Map();
ipcRenderer.on("netcatty:upload:progress", (_event, payload) => {
const cb = uploadProgressListeners.get(payload.transferId);
if (cb) {
@@ -193,6 +222,55 @@ ipcRenderer.on("netcatty:upload:error", (_event, payload) => {
uploadErrorListeners.delete(payload.transferId);
});
// Compress upload events
ipcRenderer.on("netcatty:compress:progress", (_event, payload) => {
const cb = compressProgressListeners.get(payload.compressionId);
if (cb) {
try {
cb(payload.phase, payload.transferred, payload.total);
} catch (err) {
console.error("Compress progress callback failed", err);
}
}
});
ipcRenderer.on("netcatty:compress:complete", (_event, payload) => {
const cb = compressCompleteListeners.get(payload.compressionId);
if (cb) {
try {
cb();
} catch (err) {
console.error("Compress complete callback failed", err);
}
}
// Cleanup listeners
compressProgressListeners.delete(payload.compressionId);
compressCompleteListeners.delete(payload.compressionId);
compressErrorListeners.delete(payload.compressionId);
});
ipcRenderer.on("netcatty:compress:error", (_event, payload) => {
const cb = compressErrorListeners.get(payload.compressionId);
if (cb) {
try {
cb(payload.error);
} catch (err) {
console.error("Compress error callback failed", err);
}
}
// Cleanup listeners
compressProgressListeners.delete(payload.compressionId);
compressCompleteListeners.delete(payload.compressionId);
compressErrorListeners.delete(payload.compressionId);
});
ipcRenderer.on("netcatty:compress:cancelled", (_event, payload) => {
// Just cleanup listeners, the UI already knows it's cancelled
compressProgressListeners.delete(payload.compressionId);
compressCompleteListeners.delete(payload.compressionId);
compressErrorListeners.delete(payload.compressionId);
});
// Port forwarding status listeners
const portForwardStatusListeners = new Map();
@@ -318,6 +396,29 @@ const api = {
cancelled,
});
},
// Passphrase request for encrypted SSH keys
onPassphraseRequest: (cb) => {
passphraseListeners.add(cb);
return () => passphraseListeners.delete(cb);
},
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
return ipcRenderer.invoke("netcatty:passphrase:respond", {
requestId,
passphrase,
cancelled,
});
},
respondPassphraseSkip: async (requestId) => {
return ipcRenderer.invoke("netcatty:passphrase:respond", {
requestId,
passphrase: '',
skipped: true,
});
},
onPassphraseTimeout: (cb) => {
passphraseTimeoutListeners.add(cb);
return () => passphraseTimeoutListeners.delete(cb);
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;
@@ -440,6 +541,26 @@ const api = {
transferErrorListeners.delete(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
// Compressed folder upload
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
const { compressionId } = options;
// Register callbacks
if (onProgress) compressProgressListeners.set(compressionId, onProgress);
if (onComplete) compressCompleteListeners.set(compressionId, onComplete);
if (onError) compressErrorListeners.set(compressionId, onError);
return ipcRenderer.invoke("netcatty:compress:start", options);
},
cancelCompressedUpload: async (compressionId) => {
// Cleanup listeners
compressProgressListeners.delete(compressionId);
compressCompleteListeners.delete(compressionId);
compressErrorListeners.delete(compressionId);
return ipcRenderer.invoke("netcatty:compress:cancel", { compressionId });
},
checkCompressedUploadSupport: async (sftpId) => {
return ipcRenderer.invoke("netcatty:compress:checkSupport", { sftpId });
},
// Window controls for custom title bar
windowMinimize: () => ipcRenderer.invoke("netcatty:window:minimize"),
windowMaximize: () => ipcRenderer.invoke("netcatty:window:maximize"),
@@ -584,7 +705,11 @@ const api = {
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
// Save dialog for file downloads
showSaveDialog: (defaultPath, filters) =>
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
// File watcher for auto-sync feature
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId, encoding }),
@@ -626,6 +751,15 @@ const api = {
ipcRenderer.invoke("netcatty:sessionLogs:autoSave", payload),
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Get file path from File object (for drag-and-drop)
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file);
} catch {
return undefined;
}
},
};
// 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/**", "public/monaco/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
},
{
files: ["**/*.{ts,tsx}"],

58
global.d.ts vendored
View File

@@ -2,6 +2,15 @@ import type { RemoteFile, SftpFilenameEncoding } from "./types";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
declare global {
// Extend HTMLInputElement to support webkitdirectory attribute
namespace JSX {
interface IntrinsicElements {
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
webkitdirectory?: string;
}, HTMLInputElement>;
}
}
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
@@ -245,6 +254,27 @@ declare global {
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// Passphrase request for encrypted SSH keys
onPassphraseRequest?(
cb: (request: {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}) => void
): () => void;
respondPassphrase?(
requestId: string,
passphrase: string,
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
respondPassphraseSkip?(
requestId: string
): Promise<{ success: boolean; error?: string }>;
onPassphraseTimeout?(
cb: (event: { requestId: string }) => void
): () => void;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;
@@ -278,6 +308,28 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
// Compressed folder upload
startCompressedUpload?(
options: {
compressionId: string;
folderPath: string;
targetPath: string;
sftpId: string;
folderName: string;
},
onProgress?: (phase: string, transferred: number, total: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ compressionId: string; success?: boolean; error?: string }>;
cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean }>;
checkCompressedUploadSupport?(sftpId: string): Promise<{
supported: boolean;
localTar: boolean;
remoteTar: boolean;
error?: string;
}>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
@@ -484,6 +536,9 @@ declare global {
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
// Save dialog for file downloads
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
@@ -520,6 +575,9 @@ declare global {
directory: string;
}): Promise<{ success: boolean; error?: string; filePath?: string }>;
openSessionLogsDir?(directory: string): Promise<{ success: boolean; error?: string }>;
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
}
interface Window {

View File

@@ -28,6 +28,7 @@ export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
@@ -43,6 +44,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
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';
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
// Session Logs Settings
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
@@ -51,3 +53,6 @@ export const STORAGE_KEY_SESSION_LOGS_FORMAT = 'netcatty_session_logs_format_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';
// Managed Sources - external files that manage groups of hosts (e.g., ~/.ssh/config)
export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';

View File

@@ -0,0 +1,87 @@
/**
* Compressed Upload Service
*
* Provides compressed folder upload functionality using tar compression
*/
import { netcattyBridge } from "./netcattyBridge";
export interface CompressUploadOptions {
compressionId: string;
folderPath: string;
targetPath: string;
sftpId: string;
folderName: string;
}
export interface CompressUploadProgress {
phase: 'compressing' | 'uploading' | 'extracting';
transferred: number;
total: number;
}
export interface CompressUploadSupport {
supported: boolean;
localTar: boolean;
remoteTar: boolean;
error?: string;
}
export type CompressUploadProgressCallback = (phase: string, transferred: number, total: number) => void;
export type CompressUploadCompleteCallback = () => void;
export type CompressUploadErrorCallback = (error: string) => void;
/**
* Start a compressed folder upload
*/
export async function startCompressedUpload(
options: CompressUploadOptions,
onProgress?: CompressUploadProgressCallback,
onComplete?: CompressUploadCompleteCallback,
onError?: CompressUploadErrorCallback
): Promise<{ compressionId: string; success?: boolean; error?: string }> {
const bridge = netcattyBridge.get();
if (!bridge?.startCompressedUpload) {
throw new Error("Compressed upload not available");
}
try {
return await bridge.startCompressedUpload(options, onProgress, onComplete, onError);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
compressionId: options.compressionId,
success: false,
error: errorMessage
};
}
}
/**
* Cancel a compressed upload
*/
export async function cancelCompressedUpload(compressionId: string): Promise<{ success: boolean }> {
const bridge = netcattyBridge.get();
if (!bridge?.cancelCompressedUpload) {
throw new Error("Compressed upload not available");
}
return bridge.cancelCompressedUpload(compressionId);
}
/**
* Check if compressed upload is supported for a given SFTP session
*/
export async function checkCompressedUploadSupport(sftpId: string): Promise<CompressUploadSupport> {
const bridge = netcattyBridge.get();
if (!bridge?.checkCompressedUploadSupport) {
return {
supported: false,
localTar: false,
remoteTar: false,
error: "Compressed upload not available"
};
}
return bridge.checkCompressedUploadSupport(sftpId);
}

View File

@@ -3,6 +3,8 @@
* Helper functions for file type detection and extension handling
*/
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
// Common text file extensions
const TEXT_EXTENSIONS = new Set([
// Code/Scripts
@@ -538,6 +540,22 @@ async function processEntriesIteratively(
return results;
}
/**
* Get the local file path for a File object using Electron's webUtils API
* Falls back to the legacy file.path property if webUtils is not available
*/
export function getPathForFile(file: File): string | undefined {
try {
// Try Electron's webUtils API (exposed via preload)
const path = netcattyBridge.get()?.getPathForFile?.(file);
if (path) return path;
// Fallback: try legacy file.path property
return (file as File & { path?: string }).path;
} catch {
return undefined;
}
}
/**
* Extract all files and directories from a DataTransfer object
* Supports both regular files and folders dropped from the OS
@@ -553,6 +571,20 @@ export async function extractDropEntries(
): Promise<DropEntry[]> {
const items = dataTransfer.items;
// Build a map of file/folder name to path from the original files in DataTransfer.files
const filePathMap = new Map<string, string>();
const filesWithPath = dataTransfer.files;
console.log('[extractDropEntries] DataTransfer.files count:', filesWithPath.length);
for (let i = 0; i < filesWithPath.length; i++) {
const f = filesWithPath[i];
const path = getPathForFile(f);
console.log('[extractDropEntries] File:', { name: f.name, path, size: f.size });
if (path) {
filePathMap.set(f.name, path);
}
}
console.log('[extractDropEntries] filePathMap:', Object.fromEntries(filePathMap));
// 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)
@@ -568,9 +600,46 @@ export async function extractDropEntries(
}
// Process entries iteratively (non-recursive) to avoid stack overflow
return await processEntriesIteratively(entries);
const results = await processEntriesIteratively(entries);
// Restore the 'path' property for all files
// Try to get the path directly from webUtils.getPathForFile for each file
// This is more reliable than trying to reconstruct from folder paths
for (const result of results) {
if (result.file) {
// First try to get path directly from the file
const directPath = getPathForFile(result.file);
if (directPath) {
(result.file as File & { path?: string }).path = directPath;
console.log('[extractDropEntries] Direct path for:', { relativePath: result.relativePath, path: directPath });
} else {
// Fallback: try to reconstruct from root folder path
const pathParts = result.relativePath.split('/');
const rootName = pathParts[0];
const rootPath = filePathMap.get(rootName);
console.log('[extractDropEntries] Fallback matching:', { relativePath: result.relativePath, rootName, rootPath });
if (rootPath) {
if (pathParts.length === 1) {
// Root-level file: use the path directly
(result.file as File & { path?: string }).path = rootPath;
} else {
// Nested file in a folder: construct full path
// rootPath is the path to the root folder, we need to append the rest
const restOfPath = pathParts.slice(1).join('/');
const separator = rootPath.includes('\\') ? '\\' : '/';
const fullPath = rootPath + separator + restOfPath.replace(/\//g, separator);
(result.file as File & { path?: string }).path = fullPath;
}
}
}
}
}
return results;
} else {
// Fallback: use regular FileList (no folder support)
// Files from FileList in Electron already have the 'path' property
const results: DropEntry[] = [];
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {

View File

@@ -6,7 +6,7 @@
* cancellation support, and works for both local and remote (SFTP) uploads.
*/
import { extractDropEntries, DropEntry } from "./sftpFileUtils";
import { extractDropEntries, DropEntry, getPathForFile } from "./sftpFileUtils";
// ============================================================================
// Types
@@ -55,6 +55,8 @@ export interface UploadCallbacks {
onScanningStart?: (taskId: string) => void;
/** Called when scanning ends */
onScanningEnd?: (taskId: string) => void;
/** Called when task name needs to be updated (for phase changes) */
onTaskNameUpdate?: (taskId: string, newName: string) => void;
}
export interface UploadBridge {
@@ -72,6 +74,23 @@ export interface UploadBridge {
onError?: (error: string) => void
) => Promise<{ success: boolean; cancelled?: boolean } | undefined>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
/** Stream transfer using local file path (avoids loading file into memory) */
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
cancelTransfer?: (transferId: string) => Promise<void>;
}
export interface UploadConfig {
@@ -87,6 +106,8 @@ export interface UploadConfig {
joinPath: (base: string, name: string) => string;
/** Callbacks for progress updates */
callbacks?: UploadCallbacks;
/** Use compressed upload for folders (requires tar on both local and remote) */
useCompressedUpload?: boolean;
}
// ============================================================================
@@ -142,6 +163,7 @@ export function sortEntries(entries: DropEntry[]): DropEntry[] {
export class UploadController {
private cancelled = false;
private activeFileTransferIds = new Set<string>();
private activeCompressionIds = new Set<string>();
private currentTransferId = "";
private bridge: UploadBridge | null = null;
@@ -151,15 +173,30 @@ export class UploadController {
async cancel(): Promise<void> {
this.cancelled = true;
if (!this.bridge?.cancelSftpUpload) {
return;
// Cancel all active compressed uploads
const activeCompressionIds = Array.from(this.activeCompressionIds);
for (const compressionId of activeCompressionIds) {
try {
// Import and call cancelCompressedUpload
const { cancelCompressedUpload } = await import('../infrastructure/services/compressUploadService');
await cancelCompressedUpload(compressionId);
} catch {
// Ignore cancel errors
}
}
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
await this.bridge.cancelSftpUpload(transferId);
// Try cancelTransfer first (for stream transfers)
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
// Also try cancelSftpUpload (for legacy uploads)
if (this.bridge?.cancelSftpUpload) {
await this.bridge.cancelSftpUpload(transferId);
}
} catch {
// Ignore cancel errors
}
@@ -168,7 +205,12 @@ export class UploadController {
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
await this.bridge.cancelSftpUpload(this.currentTransferId);
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(this.currentTransferId);
}
if (this.bridge?.cancelSftpUpload) {
await this.bridge.cancelSftpUpload(this.currentTransferId);
}
} catch {
// Ignore cancel errors
}
@@ -190,7 +232,9 @@ export class UploadController {
if (this.currentTransferId && !ids.includes(this.currentTransferId)) {
ids.push(this.currentTransferId);
}
return ids;
// Also include compression IDs
const compressionIds = Array.from(this.activeCompressionIds);
return [...ids, ...compressionIds];
}
/**
@@ -199,6 +243,7 @@ export class UploadController {
reset(): void {
this.cancelled = false;
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
this.currentTransferId = "";
}
@@ -233,6 +278,20 @@ export class UploadController {
clearCurrentTransfer(): void {
this.currentTransferId = "";
}
/**
* Track a compression ID
*/
addActiveCompression(id: string): void {
this.activeCompressionIds.add(id);
}
/**
* Remove a tracked compression ID
*/
removeActiveCompression(id: string): void {
this.activeCompressionIds.delete(id);
}
}
// ============================================================================
@@ -252,7 +311,7 @@ export async function uploadFromDataTransfer(
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
// Reset controller if provided
if (controller) {
@@ -275,35 +334,141 @@ export async function uploadFromDataTransfer(
return [];
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
// Check if any folders failed due to lack of compression support
const failedFolders = compressedResults.filter(result =>
!result.success && result.error === "Compressed upload not supported - fallback needed"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
// Get entries only for failed folders, not already successful ones
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
// Upload standalone files using regular upload if any exist
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Upload a FileList with bundled folder support
* Upload a FileList or File array with bundled folder support
*/
export async function uploadFromFileList(
fileList: FileList,
fileList: FileList | File[],
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
// Convert FileList to DropEntry array (simple files, no folders)
const entries: DropEntry[] = Array.from(fileList).map(file => ({
file,
relativePath: file.name,
isDirectory: false,
}));
// Convert FileList to DropEntry array
// Use webkitRelativePath for folder uploads, fallback to file.name for regular file uploads
const entries: DropEntry[] = Array.from(fileList).map(file => {
const localPath = getPathForFile(file);
// Use webkitRelativePath if available (folder upload), otherwise use file.name (regular file upload)
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
if (localPath) {
// Set the path property on the file for stream transfer
(file as File & { path?: string }).path = localPath;
}
return {
file,
relativePath,
isDirectory: false,
};
});
if (entries.length === 0) {
return [];
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
// Check if any folders failed due to lack of compression support
const failedFolders = compressedResults.filter(result =>
!result.success && result.error === "Compressed upload not supported - fallback needed"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
// Get entries only for failed folders, not already successful ones
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
// Upload standalone files using regular upload if any exist
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
// Combine results: successful compressed + fallback results + standalone files
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
// Fall back to regular upload
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
@@ -470,99 +635,198 @@ async function uploadEntries(
}
}
const arrayBuffer = await entry.file.arrayBuffer();
// Check if file has a local path (Electron provides file.path for dropped files)
const localFilePath = (entry.file as File & { path?: string }).path;
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
// For bundled tasks, only update the current file's progress
// Don't add to completedFilesBytes until the file is fully completed
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
});
}
};
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
const arrayBuffer = await entry.file.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't show 100% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
() => {
// File upload completed successfully
},
(error) => {
// File upload failed - error is handled by the caller
void error;
}
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}
// File processing completed (both stream transfer and fallback paths)
controller?.clearCurrentTransfer();
results.push({ fileName: entry.relativePath, success: true });
@@ -572,16 +836,28 @@ async function uploadEntries(
if (progress) {
progress.completedCount++;
progress.completedFilesBytes += fileTotalBytes;
// Set transferredBytes to completedFilesBytes to avoid double counting
progress.transferredBytes = progress.completedFilesBytes;
if (progress.completedCount >= progress.fileCount) {
// All files completed - set final progress to 100% and mark as completed
callbacks?.onTaskProgress?.(bundleTaskId, {
transferred: progress.totalBytes,
total: progress.totalBytes,
speed: 0,
percent: 100,
});
// Call completion callback synchronously
callbacks?.onTaskCompleted?.(bundleTaskId, progress.totalBytes);
} else if (callbacks?.onTaskProgress) {
const percent = progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: progress.completedFilesBytes,
total: progress.totalBytes,
speed: 0,
percent: progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0,
percent: displayPercent,
});
}
}
@@ -650,3 +926,226 @@ export async function uploadEntriesDirect(
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**
* Upload folders using compression
*/
async function uploadFoldersCompressed(
folderEntries: Array<[string, DropEntry[]]>,
allEntries: DropEntry[],
targetPath: string,
sftpId: string,
callbacks?: UploadCallbacks,
controller?: UploadController
): Promise<UploadResult[]> {
const results: UploadResult[] = [];
// Import the compressed upload service
const { startCompressedUpload, checkCompressedUploadSupport } = await import('../infrastructure/services/compressUploadService');
for (const [folderName, entries] of folderEntries) {
if (controller?.isCancelled()) {
break;
}
// Get the local folder path from the first file in the folder
const firstFile = entries.find(e => e.file);
if (!firstFile?.file) {
// Empty folder - mark for fallback to regular upload which will create the directory
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
continue;
}
const localFilePath = getPathForFile(firstFile.file);
if (!localFilePath) {
results.push({ fileName: folderName, success: false, error: "Could not get local file path" });
continue;
}
// Extract folder path from the first file path
// Use DropEntry.relativePath which works for both file input and drag-drop scenarios
// For file input: webkitRelativePath is set (e.g., "folder/subdir/file.txt")
// For drag-drop: DropEntry.relativePath contains the correct path from extractDropEntries
const relativePath = firstFile.relativePath || (firstFile.file as File & { webkitRelativePath?: string }).webkitRelativePath || firstFile.file.name;
// Normalize path separators for cross-platform compatibility
const normalizePathSeparators = (path: string) => path.replace(/\\/g, '/');
const normalizedLocalPath = normalizePathSeparators(localFilePath);
const normalizedRelativePath = normalizePathSeparators(relativePath);
// Calculate the root folder path by removing the full relativePath from localFilePath
// For example: if localFilePath is "/Users/rice/Downloads/110-temp/insideServer/subdir/file.txt"
// and relativePath is "insideServer/subdir/file.txt", we want "/Users/rice/Downloads/110-temp/insideServer"
let folderPath = localFilePath;
if (normalizedRelativePath && normalizedLocalPath.endsWith(normalizedRelativePath)) {
// Remove the relativePath from the end to get the base directory
const basePath = localFilePath.substring(0, localFilePath.length - relativePath.length);
// Remove trailing slash/backslash if present
const cleanBasePath = basePath.replace(/[/\\]$/, '');
// Add the folder name to get the actual folder path
folderPath = cleanBasePath + (cleanBasePath ? (localFilePath.includes('\\') ? '\\' : '/') : '') + folderName;
} else {
// Fallback: try to extract based on folder name with normalized separators
const normalizedFolderPattern1 = '/' + folderName + '/';
const normalizedFolderPattern2 = '\\' + folderName + '\\';
const folderIndex1 = normalizedLocalPath.lastIndexOf(normalizedFolderPattern1);
const folderIndex2 = localFilePath.lastIndexOf(normalizedFolderPattern2);
const folderIndex = Math.max(folderIndex1, folderIndex2);
if (folderIndex >= 0) {
folderPath = localFilePath.substring(0, folderIndex + folderName.length + 1);
} else {
// Last resort: remove just the filename (original logic)
const pathParts = normalizedRelativePath.split('/');
if (pathParts.length > 1) {
const fileName = pathParts[pathParts.length - 1];
if (normalizedLocalPath.endsWith(fileName)) {
folderPath = localFilePath.substring(0, localFilePath.length - fileName.length - 1);
}
} else {
// Single file, get its parent directory
const lastSlash = Math.max(localFilePath.lastIndexOf('/'), localFilePath.lastIndexOf('\\'));
if (lastSlash > 0) {
folderPath = localFilePath.substring(0, lastSlash);
}
}
}
}
let taskId: string | null = null; // Declare taskId outside try block for error handling
try {
// Check if compressed upload is supported
const support = await checkCompressedUploadSupport(sftpId);
if (!support.supported) {
// Fall back to regular upload for this folder
results.push({
fileName: folderName,
success: false,
error: "Compressed upload not supported - fallback needed"
});
continue;
}
const compressionId = crypto.randomUUID();
// Check for cancellation before starting
if (controller?.isCancelled()) {
results.push({ fileName: folderName, success: false, cancelled: true });
break;
}
// Register compression ID with controller for cancellation support
controller?.addActiveCompression(compressionId);
// Create a task for this folder compression
const totalBytes = entries.reduce((sum, entry) => sum + (entry.file?.size || 0), 0);
taskId = compressionId;
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: taskId,
fileName: folderName,
displayName: `${folderName} (compressed)`,
isDirectory: true,
totalBytes,
transferredBytes: 0,
speed: 0,
fileCount: entries.length,
completedCount: 0,
});
}
// Start compressed upload
const result = await startCompressedUpload(
{
compressionId,
folderPath,
targetPath,
sftpId,
folderName,
},
(phase, transferred, total) => {
// Check for cancellation during progress updates
if (controller?.isCancelled()) {
return;
}
if (callbacks?.onTaskProgress) {
// Map compression progress to actual file bytes
const progressPercent = total > 0 ? (transferred / total) * 100 : 0;
const mappedTransferred = Math.floor((progressPercent / 100) * totalBytes);
callbacks.onTaskProgress(taskId, {
transferred: mappedTransferred,
total: totalBytes,
speed: 0, // Speed is handled by the compression service
percent: progressPercent,
});
}
// Update task name based on phase
if (callbacks?.onTaskNameUpdate) {
// Pass phase identifier for UI layer to handle i18n
// Format: "folderName|phase" where phase is: compressing, extracting, uploading, or compressed
const phaseKey = phase === 'compressing' ? 'compressing'
: phase === 'extracting' ? 'extracting'
: phase === 'uploading' ? 'uploading'
: 'compressed';
callbacks.onTaskNameUpdate(taskId, `${folderName}|${phaseKey}`);
}
},
() => {
// Remove compression ID from controller
controller?.removeActiveCompression(compressionId);
// Mark task as completed immediately
if (callbacks?.onTaskCompleted) {
callbacks.onTaskCompleted(taskId, totalBytes);
}
},
(error) => {
// Remove compression ID from controller on error
controller?.removeActiveCompression(compressionId);
if (callbacks?.onTaskFailed) {
callbacks.onTaskFailed(taskId, error);
}
}
);
if (result.success) {
results.push({ fileName: folderName, success: true });
} else if (result.error?.includes('cancelled') || controller?.isCancelled()) {
// Handle cancellation
results.push({ fileName: folderName, success: false, cancelled: true });
if (callbacks?.onTaskCancelled) {
callbacks.onTaskCancelled(taskId);
}
} else {
results.push({ fileName: folderName, success: false, error: result.error });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Remove compression ID from controller on error
if (taskId) {
controller?.removeActiveCompression(taskId);
}
// Check if this was a cancellation
if (controller?.isCancelled() || errorMessage.includes('cancelled')) {
results.push({ fileName: folderName, success: false, cancelled: true });
if (callbacks?.onTaskCancelled && taskId) {
callbacks.onTaskCancelled(taskId);
}
} else {
results.push({ fileName: folderName, success: false, error: errorMessage });
// Only call onTaskFailed if we have a valid taskId (task was created) and it's not a cancellation
if (callbacks?.onTaskFailed && taskId) {
callbacks.onTaskFailed(taskId, errorMessage);
}
}
}
}
return results;
}

85
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
@@ -1007,6 +1008,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1653,7 +1655,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1675,7 +1676,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1692,7 +1692,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1707,7 +1706,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3620,6 +3618,58 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -5621,6 +5671,7 @@
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.53.0",
@@ -5650,6 +5701,7 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -5928,7 +5980,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
@@ -5960,6 +6013,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5992,6 +6046,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6399,6 +6454,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7058,8 +7114,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -7299,6 +7354,7 @@
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.4.0",
"builder-util": "26.3.4",
@@ -7624,7 +7680,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7645,7 +7700,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -7870,6 +7924,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10152,6 +10207,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -10777,6 +10833,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10835,7 +10892,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10853,7 +10909,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10954,6 +11009,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10963,6 +11019,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11891,7 +11948,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -11955,7 +12011,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -11970,7 +12025,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -12132,6 +12186,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12334,6 +12389,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12688,6 +12744,7 @@
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -40,6 +40,7 @@
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",