Compare commits

...

24 Commits

Author SHA1 Message Date
bincxz
0c4900c73d fix: exclude cpu-features to fix native module build failure
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
The cpu-features package fails to compile with newer Node.js/Electron
due to deprecated V8 APIs. Since it's an optional dependency of ssh2,
replace it with an empty package via npm overrides.
2026-02-03 02:35:41 +08:00
陈大猫
3174e9ad27 feat(sftp): add visual focus indicator for pane selection (#181)
- Add inset ring border to focused SFTP pane for clear visual distinction
- Fix useSftpKeyboardShortcuts context error by passing showHiddenFiles as parameter
- Use sftpFocusStore to track which pane is currently focused
2026-02-03 02:17:11 +08:00
Copilot
f517c85d07 feat: Add SFTP keyboard shortcuts for copy, paste, cut, select all, rename, delete (#180)
* Initial plan

* feat: Add SFTP keyboard shortcuts support for copy, paste, cut, select all, rename, delete, refresh, and new folder operations

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

* feat: Add keyboard shortcuts support to SFTPModal for select all, rename, delete, refresh, and new folder

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

* fix: Address code review feedback - optimize useMemo deps and add same-pane paste notification

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

* Fix SFTP paste source path

* fix: delete sources after SFTP cut paste

* Fix SFTP delete key matching

* Fix cut delete after successful SFTP transfers

* fix: finalize cut-paste deletes after conflicts

* fix: track original names for cut transfers

* Fix delete key matching for shortcuts

* fix: respect visible files for sftp select all

* Fix modal selection and cut cleanup

* Throw on missing SFTP delete prerequisites

* Fix dialog action handler memo deps

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 01:45:53 +08:00
陈大猫
0b9e3c430d fix: remove chacha20-poly1305 cipher and upgrade Electron to 40.1.0 (#179)
- Remove chacha20-poly1305@openssh.com from SSH cipher list as Electron's
  BoringSSL (from Chromium) does not support standalone chacha20 cipher
- Upgrade Electron from 39.2.6 to 40.1.0 (Node.js 24.11.1)
- Keep AES-GCM and AES-CTR ciphers which are fully supported

The chacha20-poly1305 algorithm requires OpenSSL's chacha20 cipher which
is not available in Electron's bundled BoringSSL. This caused connection
failures with 'Unsupported algorithm' error when connecting to SSH servers.
2026-02-02 21:43:24 +08:00
Copilot
1c526e6965 Add keyboard shortcuts for snippets (#174)
* Initial plan

* Add snippet shortkey feature for sending commands via keyboard shortcuts

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

* Address code review feedback: extract isMacPlatform utility and improve comments

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

* Fix snippet shortcut validation

* Improve snippet shortcut conflict checks

* Append newline for snippet shortcuts

* Allow snippet shortcuts to fall through when disconnected

* Broadcast snippet shortcuts

* Fix snippet shortcut validation when hotkeys disabled

* Prevent cross-platform snippet shortcut matches

* Record snippet shortcuts in command history

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 21:12:00 +08:00
Copilot
70ff5299b6 Expand SSH algorithm support for modern servers (#178)
* Initial plan

* feat: expand SSH algorithm support for modern servers

Add additional cipher and key exchange algorithms to improve
compatibility with modern SSH servers:

Ciphers:
- chacha20-poly1305@openssh.com
- aes192-ctr

Key Exchange:
- ecdh-sha2-nistp521
- diffie-hellman-group16-sha512
- diffie-hellman-group18-sha512
- diffie-hellman-group-exchange-sha256

Fixes issue with "no matching key exchange algorithm" error.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:24:34 +08:00
Copilot
3ef53faef5 Add tooltip to port forwarding rules showing relay host details (#175)
* Initial plan

* Add tooltip to port forwarding rule card showing relay host info

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:10:04 +08:00
陈大猫
554bc3d2ab Show connection details in host selector (#173)
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
* Show user@host:port in host selector

Replace the host selector subtitle with username, hostname, and port to
surface the actual connection target details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Filter serial hosts from selector

Exclude serial protocol entries from SelectHostPanel results and counts to
avoid offering non-SSH targets in selection flows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:56:07 +08:00
陈大猫
951a89e91e Enable opt-in MFA for SSH exec export (#172)
Add execCommand options to opt into keyboard-interactive auth and wire MFA only
for export-key flows, preserving non-interactive exec usage elsewhere.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:32:41 +08:00
bincxz
339e34e722 Refactor port forwarding initialization and remove unused state.
This simplifies async auth prep before opening the SSH connection and cleans up unused variables in UI and SFTP hooks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:05:59 +08:00
bincxz
fe1a5ca0e5 Ignore local Claude settings to avoid committing machine-specific state.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:58:44 +08:00
陈大猫
3e89a65b39 Optimize Cloud Sync Performance (#159)
* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Normalize conflict check errors in sync (#164)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Return errors when sync is attempted while locked (#165)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Set lastError when parallel uploads all fail (#167)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Block uploads on conflict check errors (#168)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Fix lastError on upload failures (#170)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

* Normalize conflict check errors in parallel sync (#171)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

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

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 23:06:01 +08:00
Copilot
090aae1833 Add passphrase input support to SSH key import panel (#169)
* Initial plan

* Add passphrase input field and save checkbox to SSH key import panel

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-01 23:04:08 +08:00
陈大猫
8810b3cf0f Sync port forwarding rules (#161)
* feat: Sync port forwarding rules

- Refactor `usePortForwardingState` to use a global store pattern, ensuring state consistency across the application.
- Integrate `usePortForwardingState` into `App.tsx` to retrieve and update port forwarding rules.
- Update `useAutoSync` in `App.tsx` to include `portForwardingRules` in the sync payload and handle incoming updates via `importRules`.

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

* Normalize imported port forwarding statuses

* fix: correct indentation in usePortForwardingState.ts

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

* Normalize imported port forwarding rule status

* Stabilize port forwarding rules for auto-sync

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:55:45 +08:00
陈大猫
087ce0f3b1 feat: implement workspace creation from Quick Switcher (#162)
- Added `CreateWorkspaceDialog` component for creating named workspaces with multiple hosts.
- Implemented `createWorkspaceWithHosts` in `useSessionState`.
- Integrated dialog into `App.tsx` and triggered from Quick Switcher.
- Updated `QuickSwitcher` logic to improve visibility of recent connections.
- Added i18n keys for the dialog.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:31:12 +08:00
陈大猫
14e07741ae Implement setDeviceName in CloudSyncManager and useCloudSync (#160)
- Added `setDeviceName` method to `CloudSyncManager` to update state, persist to local storage, and notify listeners.
- Updated `useCloudSync` hook to expose the `setDeviceName` function from the manager.
- Ensures device name updates are correctly handled and persisted across sessions.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:28:29 +08:00
陈大猫
fe9b1b1011 perf: Optimize managed source host filtering to O(N) (#158)
Refactored the host filtering logic in `useManagedSourceSync` to index hosts by `managedSourceId` before iterating through sources. This reduces the complexity from O(N*M) to O(N+M), where N is the number of sources and M is the number of hosts.

Benchmarks showed a ~74x speedup (from ~600ms to ~8ms) with 500 sources and 25,000 hosts.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:22:30 +08:00
陈大猫
7941aa6d08 perf: make window state saving async to avoid blocking main thread (#157)
* perf: make window state saving async to avoid blocking main thread

- Convert `saveWindowState` to use `fs.promises.writeFile`
- Keep `saveWindowStateSync` for use in `close` handler
- Update `scheduleSaveState` to use async version
- Reduces blocking time from ~0.38ms to ~0.10ms per write

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

* Serialize async window state saves

* fix: avoid async window state overwrite on close

* fix: guard queued window state saves on close

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:13:24 +08:00
陈大猫
b3d9908814 perf: make key persistence asynchronous in main process (#154)
- Refactor `writeKeyToDisk` and `ensureKeyDir` in `electron/main.cjs` to use `fs.promises` instead of synchronous `fs` methods.
- This prevents blocking the main thread during file I/O operations, improving application responsiveness.
- Added error handling with try/catch blocks to ensure safety.
- Verified performance improvement with a benchmark script (deleted before commit).
- Verified code quality with `npm run lint`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:44:21 +08:00
陈大猫
1006fa1da0 perf: optimize SFTP directory existence check (#155)
Reduces the complexity of `ensureRemoteDirInternal` from O(N) to O(1) for the common case where the directory already exists.

- Adds a check for the full path at the beginning of the function.
- If the directory exists, it returns immediately.
- If not, it falls back to the existing recursive check/creation logic.

Benchmarks showed a reduction from ~8 calls to 1 call for a deep existing directory structure.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:43:53 +08:00
陈大猫
721b9596f5 Optimize SSH key discovery to use async I/O (#156)
Refactored synchronous file operations in SSH key discovery to use `fs.promises` and `Promise.all`, preventing main thread blocking during connection initialization. Updated all bridge modules to handle asynchronous key retrieval.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:43:35 +08:00
陈大猫
b3fbc0972d feat: use dynamic package version in CloudSyncManager (#153)
Replaced the hardcoded '1.0.0' version string in CloudSyncManager.ts with the version from package.json.
Enabled resolveJsonModule in tsconfig.json to support JSON imports.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:42:49 +08:00
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
53 changed files with 4137 additions and 1607 deletions

View File

@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)",
"Bash(npm run build:*)",
"Bash(gh pr view:*)",
"Bash(gh pr list:*)",
"Bash(gh api:*)",
"Bash(ls:*)",
"Bash(gh issue view:*)",
"Bash(npm run dev:*)",
"Bash(git checkout:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git push:*)",
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
]
}
}

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ coverage
*.njsproj
*.sln
*.sw?
# Claude Code local settings
/.claude/settings.local.json

41
App.tsx
View File

@@ -3,6 +3,7 @@ import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisi
import { useAutoSync } from './application/state/useAutoSync';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { usePortForwardingState } from './application/state/usePortForwardingState';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
@@ -86,6 +87,9 @@ const LazyProtocolSelectDialog = lazy(() => import('./components/ProtocolSelectD
const LazyQuickSwitcher = lazy(() =>
import('./components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('./components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
@@ -150,6 +154,7 @@ function App({ settings }: { settings: SettingsState }) {
const { t } = useI18n();
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
@@ -232,6 +237,7 @@ function App({ settings }: { settings: SettingsState }) {
closeSession,
closeWorkspace,
updateSessionStatus,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
updateSplitSizes,
@@ -254,6 +260,20 @@ function App({ settings }: { settings: SettingsState }) {
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive",
error: undefined,
lastUsedAt: undefined,
})),
[portForwardingRules],
);
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow } = useAutoSync({
hosts,
@@ -261,7 +281,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
snippets,
customGroups,
portForwardingRules: undefined, // TODO: Add port forwarding rules from usePortForwardingState
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {
importDataFromString(JSON.stringify({
@@ -271,6 +291,10 @@ function App({ settings }: { settings: SettingsState }) {
snippets: payload.snippets,
customGroups: payload.customGroups,
}));
if (payload.portForwardingRules) {
importPortForwardingRules(payload.portForwardingRules);
}
},
});
@@ -974,6 +998,8 @@ function App({ settings }: { settings: SettingsState }) {
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -1081,8 +1107,8 @@ function App({ settings }: { settings: SettingsState }) {
setQuickSearch('');
}}
onCreateWorkspace={() => {
// TODO: Implement workspace creation
setIsQuickSwitcherOpen(false);
setIsCreateWorkspaceOpen(true);
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
@@ -1147,6 +1173,17 @@ function App({ settings }: { settings: SettingsState }) {
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
)}
{/* Protocol Select Dialog for QuickSwitcher */}
{protocolSelectHost && (
<Suspense fallback={null}>

View File

@@ -48,11 +48,14 @@ const en: Messages = {
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
'field.name': 'Name',
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
// Settings shell
@@ -250,6 +253,7 @@ const en: Messages = {
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
@@ -460,6 +464,13 @@ const en: Messages = {
'pf.view.list': 'List',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Relay Host',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Address',
'pf.tooltip.noHost': 'No relay host configured',
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
'pf.deleteActive.confirm': 'Stop and Delete',
@@ -647,6 +658,10 @@ const en: Messages = {
'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',
@@ -1233,6 +1248,15 @@ const en: Messages = {
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',

View File

@@ -807,6 +807,13 @@ const zhCN: Messages = {
'pf.view.list': '列表',
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': '中转主机',
'pf.tooltip.hostLabel': '主机',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.noHost': '未配置中转主机',
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
'pf.deleteActive.title': '删除正在运行的端口转发?',
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
'pf.deleteActive.confirm': '关闭并删除',
@@ -913,6 +920,10 @@ const zhCN: Messages = {
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
// SFTP Download
'sftp.download.completed': '已下载',
'sftp.download.cancelled': '下载已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
@@ -1037,6 +1048,7 @@ const zhCN: Messages = {
'settings.shortcuts.category.terminal': '终端',
'settings.shortcuts.category.navigation': '导航',
'settings.shortcuts.category.app': '应用',
'settings.shortcuts.category.sftp': 'SFTP',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': 'Proxy',
@@ -1222,6 +1234,15 @@ const zhCN: Messages = {
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
'snippets.shortkey.recording': '请按下快捷键组合...',
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
'snippets.shortkey.clear': '清除快捷键',
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',

View File

@@ -39,6 +39,12 @@ interface UseSftpPaneActionsResult {
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
@@ -452,6 +458,88 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const deleteFilesAtPath = useCallback(
async (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
if (!pane?.connection) {
throw new Error("Source pane is no longer available");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Netcatty bridge not available");
}
try {
for (const name of fileNames) {
const fullPath = joinPath(path, name);
if (pane.connection.isLocal) {
if (!bridge.deleteLocalFile) {
throw new Error("Local delete unavailable");
}
await bridge.deleteLocalFile(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
handleSessionError(side, error);
throw error;
}
if (!bridge.deleteSftp) {
throw new Error("SFTP delete unavailable");
}
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
await refresh(side);
} else {
updateTab(side, pane.id, (prev) => {
if (!prev.connection || prev.connection.id !== connectionId) return prev;
if (prev.connection.currentPath !== path) return prev;
const removeSet = new Set(fileNames);
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
const nextSelection = new Set(prev.selectedFiles);
for (const name of fileNames) {
nextSelection.delete(name);
}
return {
...prev,
files: filteredFiles,
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
throw err;
}
throw err;
}
},
[
clearCacheForConnection,
handleSessionError,
isSessionError,
leftTabsRef,
refresh,
rightTabsRef,
sftpSessionsRef,
updateTab,
],
);
const renameFile = useCallback(
async (side: "left" | "right", oldName: string, newName: string) => {
const pane = getActivePane(side);
@@ -529,6 +617,7 @@ export const useSftpPaneActions = ({
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
};

View File

@@ -29,7 +29,13 @@ interface UseSftpTransfersResult {
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
) => Promise<void>;
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
@@ -39,6 +45,13 @@ interface UseSftpTransfersResult {
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
}
interface TransferResult {
id: string;
fileName: string;
originalFileName?: string;
status: TransferStatus;
}
export const useSftpTransfers = ({
getActivePane,
refresh,
@@ -53,6 +66,7 @@ export const useSftpTransfers = ({
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Track cancelled task IDs for checking during async operations
const cancelledTasksRef = useRef<Set<string>>(new Set());
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
useEffect(() => {
const intervalsRef = progressIntervalsRef.current;
@@ -268,6 +282,7 @@ export const useSftpTransfers = ({
...task,
id: crypto.randomUUID(),
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: file.type === "directory",
@@ -305,7 +320,7 @@ export const useSftpTransfers = ({
sourcePane: SftpPane,
targetPane: SftpPane,
targetSide: "left" | "right",
) => {
): Promise<TransferStatus> => {
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
@@ -461,7 +476,7 @@ export const useSftpTransfers = ({
status: "pending",
totalBytes: sourceStat?.size || estimatedSize,
});
return;
return "pending";
}
}
@@ -507,6 +522,20 @@ export const useSftpTransfers = ({
);
await refresh(targetSide);
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "completed",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "completed";
} catch (err) {
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
@@ -518,7 +547,20 @@ export const useSftpTransfers = ({
if (isCancelled) {
// Don't update status - cancelTransfer already set it to cancelled
return;
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "cancelled";
}
updateTask({
@@ -527,6 +569,20 @@ export const useSftpTransfers = ({
endTime: Date.now(),
speed: 0,
});
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "failed",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "failed";
}
};
@@ -534,23 +590,30 @@ export const useSftpTransfers = ({
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
) => {
const sourcePane = getActivePane(sourceSide);
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
if (!sourcePane?.connection || !targetPane?.connection) return;
if (!sourcePane?.connection || !targetPane?.connection) return [];
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const sourcePath = sourcePane.connection.currentPath;
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection.id);
: sftpSessionsRef.current.get(sourceConnectionId);
const newTasks: TransferTask[] = [];
@@ -585,9 +648,10 @@ export const useSftpTransfers = ({
newTasks.push({
id: crypto.randomUUID(),
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
targetPath: joinPath(targetPath, file.name),
sourceConnectionId: sourcePane.connection!.id,
sourceConnectionId,
targetConnectionId: targetPane.connection!.id,
direction,
status: "pending" as TransferStatus,
@@ -601,9 +665,25 @@ export const useSftpTransfers = ({
setTransfers((prev) => [...prev, ...newTasks]);
for (const task of newTasks) {
await processTransfer(task, sourcePane, targetPane, targetSide);
if (options?.onTransferComplete) {
for (const task of newTasks) {
completionHandlersRef.current.set(task.id, options.onTransferComplete);
}
}
const results: TransferResult[] = [];
for (const task of newTasks) {
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
results.push({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status,
});
}
return results;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, sftpSessionsRef],
@@ -715,6 +795,19 @@ export const useSftpTransfers = ({
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
}
return;
}

View File

@@ -358,8 +358,8 @@ export const useCloudSync = (): CloudSyncHook => {
manager.setAutoSync(enabled, intervalMinutes);
}, []);
const setDeviceName = useCallback((_name: string) => {
// TODO: Add setDeviceName to CloudSyncManager if needed
const setDeviceName = useCallback((name: string) => {
manager.setDeviceName(name);
}, []);
// ========== Utilities ==========

View File

@@ -15,6 +15,8 @@ export const useKeychainBackend = () => {
privateKey?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");

View File

@@ -233,6 +233,31 @@ export const useManagedSourceSync = ({
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
// Index hosts by managedSourceId to avoid O(N*M) lookups
const prevHostsBySource = new Map<string, Host[]>();
for (const h of prevHosts) {
if (h.managedSourceId) {
let list = prevHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
prevHostsBySource.set(h.managedSourceId, list);
}
list.push(h);
}
}
const currHostsBySource = new Map<string, Host[]>();
for (const h of hosts) {
if (h.managedSourceId) {
let list = currHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
currHostsBySource.set(h.managedSourceId, list);
}
list.push(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;
@@ -245,8 +270,8 @@ export const useManagedSourceSync = ({
};
for (const source of managedSources) {
const prevManaged = prevHosts.filter((h) => h.managedSourceId === source.id);
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
const prevManaged = prevHostsBySource.get(source.id) || [];
const currManaged = currHostsBySource.get(source.id) || [];
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
@@ -9,7 +9,6 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
getActiveRuleIds,
startPortForward,
stopPortForward,
syncWithBackend,
@@ -40,6 +39,7 @@ export interface UsePortForwardingStateResult {
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
deleteRule: (id: string) => void;
duplicateRule: (id: string) => void;
importRules: (rules: PortForwardingRule[]) => void;
setRuleStatus: (
id: string,
@@ -63,8 +63,58 @@ export interface UsePortForwardingStateResult {
selectedRule: PortForwardingRule | undefined;
}
// Global Store State
let globalRules: PortForwardingRule[] = [];
let isInitialized = false;
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
// Store Actions
const notifyListeners = () => {
listeners.forEach((listener) => listener(globalRules));
};
const setGlobalRules = (newRules: PortForwardingRule[]) => {
globalRules = newRules;
notifyListeners();
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
return rules.map((rule) => {
const connection = getActiveConnection(rule.id);
if (connection) {
return {
...rule,
status: connection.status,
error: connection.error,
};
}
return {
...rule,
status: "inactive",
error: undefined,
};
});
};
// Initialization Logic
const initializeStore = async () => {
if (isInitialized) return;
isInitialized = true;
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
setGlobalRules(normalizeRulesWithConnections(saved));
}
};
export const usePortForwardingState = (): UsePortForwardingStateResult => {
const [rules, setRules] = useState<PortForwardingRule[]>([]);
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_PF_VIEW_MODE,
@@ -76,49 +126,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
});
// Track if sync has been executed for this component instance
const syncExecutedRef = useRef(false);
const setPreferFormMode = useCallback((prefer: boolean) => {
setPreferFormModeState(prefer);
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
}, []);
// Load rules from storage on mount and sync with backend
// Initialize store on mount (only once globally)
useEffect(() => {
const loadAndSync = async () => {
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
if (!syncExecutedRef.current) {
syncExecutedRef.current = true;
await syncWithBackend();
}
void initializeStore();
}, []);
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
// Sync status with active connections in the service layer
const _activeRuleIds = getActiveRuleIds();
const withSyncedStatus = saved.map((r) => {
const conn = getActiveConnection(r.id);
if (conn) {
// This rule has an active connection, preserve its status
return { ...r, status: conn.status, error: conn.error };
}
// No active connection, reset to inactive
return { ...r, status: "inactive" as const, error: undefined };
});
setRules(withSyncedStatus);
}
// Subscribe to global store
useEffect(() => {
// If global state was updated before we subscribed (e.g. init finished), update local state
if (rules !== globalRules) {
setRules(globalRules);
}
const listener = (newRules: PortForwardingRule[]) => {
setRules(newRules);
};
void loadAndSync();
}, []);
// Persist rules to storage whenever they change
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
}, []);
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, [rules]);
const addRule = useCallback(
(
@@ -130,47 +162,38 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
createdAt: Date.now(),
status: "inactive",
};
setRules((prev) => {
const updated = [...prev, newRule];
persistRules(updated);
return updated;
});
const updated = [...globalRules, newRule];
setGlobalRules(updated);
setSelectedRuleId(newRule.id);
return newRule;
},
[persistRules],
[],
);
const updateRule = useCallback(
(id: string, updates: Partial<PortForwardingRule>) => {
setRules((prev) => {
const updated = prev.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
persistRules(updated);
return updated;
});
const updated = globalRules.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
setGlobalRules(updated);
},
[persistRules],
[],
);
const deleteRule = useCallback(
(id: string) => {
setRules((prev) => {
const updated = prev.filter((r) => r.id !== id);
persistRules(updated);
return updated;
});
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
if (selectedRuleId === id) {
setSelectedRuleId(null);
}
},
[selectedRuleId, persistRules],
[selectedRuleId],
);
const duplicateRule = useCallback(
(id: string) => {
const original = rules.find((r) => r.id === id);
const original = globalRules.find((r) => r.id === id);
if (!original) return;
const copy: PortForwardingRule = {
@@ -182,33 +205,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error: undefined,
lastUsedAt: undefined,
};
setRules((prev) => {
const updated = [...prev, copy];
persistRules(updated);
return updated;
});
const updated = [...globalRules, copy];
setGlobalRules(updated);
setSelectedRuleId(copy.id);
},
[rules, persistRules],
[],
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);
const setRuleStatus = useCallback(
(id: string, status: PortForwardingRule["status"], error?: string) => {
setRules((prev) => {
const updated = prev.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
});
persistRules(updated);
return updated;
const updated = globalRules.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
});
setGlobalRules(updated);
},
[persistRules],
[],
);
const startTunnel = useCallback(
@@ -301,6 +322,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
updateRule,
deleteRule,
duplicateRule,
importRules,
setRuleStatus,
startTunnel,

View File

@@ -286,6 +286,69 @@ export const useSessionState = () => {
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
// Create sessions for each host
const newSessions: TerminalSession[] = hosts.map(host => {
// Handle serial hosts specially
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
};
});
const sessionIds = newSessions.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
@@ -669,6 +732,7 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
updateSplitSizes,

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<
@@ -155,6 +160,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
} = useSftpPaneActions({
@@ -264,6 +270,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
readTextFile,
@@ -274,11 +281,14 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
});
methodsRef.current = {
getFilteredFiles,
@@ -305,6 +315,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
readTextFile,
@@ -315,11 +326,14 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
};
// Create stable method wrappers that call through methodsRef
@@ -350,6 +364,8 @@ export const useSftpState = (
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
@@ -360,11 +376,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

@@ -0,0 +1,143 @@
import { Search } from 'lucide-react';
import React, { useMemo, useState, useEffect } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
interface CreateWorkspaceDialogProps {
isOpen: boolean;
onClose: () => void;
hosts: Host[];
onCreate: (name: string, selectedHosts: Host[]) => void;
}
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
isOpen,
onClose,
hosts,
onCreate,
}) => {
const { t } = useI18n();
const [name, setName] = useState('');
const [search, setSearch] = useState('');
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const filteredHosts = useMemo(() => {
if (!search.trim()) return hosts;
const term = search.toLowerCase();
return hosts.filter(h =>
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term) ||
(h.group || '').toLowerCase().includes(term)
);
}, [hosts, search]);
const toggleHost = (hostId: string) => {
setSelectedHostIds(prev => {
const next = new Set(prev);
if (next.has(hostId)) {
next.delete(hostId);
} else {
next.add(hostId);
}
return next;
});
};
const handleCreate = () => {
const selected = hosts.filter(h => selectedHostIds.has(h.id));
onCreate(name, selected);
onClose();
};
useEffect(() => {
if (isOpen) {
setName('');
setSearch('');
setSelectedHostIds(new Set());
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="border rounded-md flex-1 min-h-[200px]">
<ScrollArea className="h-full max-h-[300px]">
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
</div>
) : (
filteredHosts.map(host => {
const isSelected = selectedHostIds.has(host.id);
return (
<div
key={host.id}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
onClick={() => toggleHost(host.id)}
>
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
</div>
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -916,6 +916,8 @@ echo $3 >> "$FILE"`);
<ImportKeyPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
onImport={handleImport}
/>
)}
@@ -1114,6 +1116,8 @@ echo $3 >> "$FILE"`);
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
});
// Check result - code 0, null, or undefined with no stderr is success

View File

@@ -701,6 +701,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<RuleCard
key={rule.id}
rule={rule}
host={hosts.find((h) => h.id === rule.hostId)}
viewMode={viewMode}
isSelected={selectedRuleId === rule.id}
isPending={pendingOperations.has(rule.id)}

View File

@@ -94,7 +94,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
return IS_MAC ? binding.mac : binding.pc;
}, [keyBindings]);
const quickSwitchKey = getHotkeyLabel('quick-switch');
const [isFocused, setIsFocused] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -102,7 +101,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Reset state when opening
useEffect(() => {
if (isOpen) {
setIsFocused(false);
setSelectedIndex(0);
// Auto focus the input after a short delay
setTimeout(() => {
@@ -134,7 +132,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
[sessions]
);
const showCategorized = isFocused || query.trim().length > 0;
const showCategorized = query.trim().length > 0;
// Memoize flat items list and index map
const { flatItems, itemIndexMap } = useMemo(() => {
@@ -232,7 +230,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onQueryChange(e.target.value);
setSelectedIndex(0);
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
placeholder={t("qs.search.placeholder")}
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"

View File

@@ -21,6 +21,7 @@ import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
import { Dialog, DialogContent } from "./ui/dialog";
@@ -82,9 +83,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
@@ -358,6 +360,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
} = useSftpModalTransfers({
currentPath,
@@ -376,6 +379,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
@@ -505,6 +509,56 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onNavigateUp: handleUp,
});
// Keyboard shortcuts for modal
const handleKeyboardRename = useCallback((file: RemoteFile) => {
openRenameDialog(file);
}, [openRenameDialog]);
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
})();
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
const handleKeyboardNewFolder = useCallback(() => {
handleCreateFolder();
}, [handleCreateFolder]);
useSftpModalKeyboardShortcuts({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles: displayFiles,
selectedFiles,
setSelectedFiles,
onRefresh: () => loadFiles(currentPath, { force: true }),
onRename: handleKeyboardRename,
onDelete: handleKeyboardDelete,
onNewFolder: handleKeyboardNewFolder,
});
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
const fileNames = Array.from(selectedFiles);
@@ -625,7 +679,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

@@ -66,21 +66,26 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showNewHostPanel, setShowNewHostPanel] = useState(false);
const selectableHosts = useMemo(
() => hosts.filter((host) => host.protocol !== "serial"),
[hosts]
);
// Get all unique tags from hosts
const allTags = useMemo(() => {
const tagSet = new Set<string>();
hosts.forEach((h) => {
selectableHosts.forEach((h) => {
if (h.tags) {
h.tags.forEach((tag) => tagSet.add(tag));
}
});
return Array.from(tagSet).sort();
}, [hosts]);
}, [selectableHosts]);
// Get unique group paths from both hosts and customGroups
const allGroupPaths = useMemo(() => {
const pathSet = new Set<string>();
hosts.forEach((h) => {
selectableHosts.forEach((h) => {
if (h.group) {
// Add all parent paths as well
const parts = h.group.split("/");
@@ -91,7 +96,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
customGroups.forEach((g) => pathSet.add(g));
return Array.from(pathSet).sort();
}, [hosts, customGroups]);
}, [selectableHosts, customGroups]);
// Get groups at current level
const groupsWithCounts = useMemo(() => {
@@ -105,7 +110,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const topLevel = path.split("/")[0];
if (!seen.has(topLevel)) {
seen.add(topLevel);
const count = hosts.filter(
const count = selectableHosts.filter(
(h) =>
h.group &&
(h.group === topLevel || h.group.startsWith(`${topLevel}/`)),
@@ -119,7 +124,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const fullPath = `${prefix}${nextLevel}`;
if (!seen.has(fullPath)) {
seen.add(fullPath);
const count = hosts.filter(
const count = selectableHosts.filter(
(h) =>
h.group &&
(h.group === fullPath || h.group.startsWith(`${fullPath}/`)),
@@ -130,11 +135,11 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
return groups;
}, [allGroupPaths, currentPath, hosts]);
}, [allGroupPaths, currentPath, selectableHosts]);
// Get hosts at current level with filtering and sorting
const filteredHosts = useMemo(() => {
let result = hosts;
let result = selectableHosts;
// Filter by current path
if (currentPath) {
@@ -180,7 +185,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
return result;
}, [hosts, currentPath, searchQuery, selectedTags, sortMode]);
}, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]);
// Build breadcrumb from current path
const breadcrumbs = useMemo(() => {
@@ -359,7 +364,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.protocol || "ssh"}, {host.username}
{host.username}@{host.hostname}:{host.port || 22}
</div>
</div>
{isSelected && (
@@ -390,7 +395,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
if (onContinue) {
onContinue();
} else {
const host = hosts.find((h) => selectedHostIds.includes(h.id));
const host = selectableHosts.find((h) => selectedHostIds.includes(h.id));
if (host) {
onSelect(host);
}

View File

@@ -14,10 +14,11 @@
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
*/
import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } 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";
@@ -36,6 +37,8 @@ import { Loader2 } from "lucide-react";
import { SftpContextProvider, activeTabStore } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
// Wrapper component that subscribes to activeTabId for CSS visibility
// This isolates the activeTabId subscription - only this component re-renders on tab switch
@@ -50,7 +53,7 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -67,6 +70,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);
@@ -80,6 +86,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
// SFTP keyboard shortcuts handler
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
isActive,
showHiddenFiles: sftpShowHiddenFiles,
});
// Subscribe to focused side for visual indicator
const focusedSide = useSftpFocusedSide();
// Handle pane focus when clicking on a pane container
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
sftpFocusStore.setFocusedSide(side);
}, []);
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
// Using useLayoutEffect to sync before paint
useLayoutEffect(() => {
@@ -130,6 +153,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
const visibleTransfers = useMemo(
@@ -193,7 +219,13 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
style={containerStyle}
>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
<div className="relative border-r border-border/70 flex flex-col">
<div
className={cn(
"relative border-r border-border/70 flex flex-col",
focusedSide === "left" && "ring-1 ring-inset ring-primary/70"
)}
onClick={() => handlePaneFocus("left")}
>
{/* Left side tab bar - only show when there are tabs */}
{leftTabsInfo.length > 0 && (
<SftpTabBar
@@ -233,7 +265,13 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
)}
</div>
</div>
<div className="relative flex flex-col">
<div
className={cn(
"relative flex flex-col",
focusedSide === "right" && "ring-1 ring-inset ring-primary/70"
)}
onClick={() => handlePaneFocus("right")}
>
{/* Right side tab bar - only show when there are tabs */}
{rightTabsInfo.length > 0 && (
<SftpTabBar

View File

@@ -1,11 +1,11 @@
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useStoredViewMode } from '../application/state/useStoredViewMode';
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { cn, isMacPlatform } from '../lib/utils';
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { ManagedSource } from '../domain/models';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
@@ -25,6 +25,8 @@ interface SnippetsManagerProps {
hosts: Host[];
customGroups?: string[];
shellHistory: ShellHistoryEntry[];
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
@@ -46,6 +48,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hosts,
customGroups = [],
shellHistory,
hotkeyScheme,
keyBindings,
onSave,
onDelete,
onPackagesChange,
@@ -89,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const historyScrollRef = useRef<HTMLDivElement>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Shortkey recording state
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
const existingShortkeys = useMemo(() => (
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
), [snippets, editingSnippet.id]);
const isMac = useMemo(() => (
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
), [hotkeyScheme]);
const activeSystemBindings = useMemo(() => {
return keyBindings.flatMap((binding) => {
const entries: { binding: string; isMac: boolean }[] = [];
const macBinding = binding.mac;
const pcBinding = binding.pc;
if (hotkeyScheme === 'mac') {
if (macBinding && macBinding !== 'Disabled') {
entries.push({ binding: macBinding, isMac: true });
}
return entries;
}
if (hotkeyScheme === 'pc') {
if (pcBinding && pcBinding !== 'Disabled') {
entries.push({ binding: pcBinding, isMac: false });
}
return entries;
}
if (macBinding && macBinding !== 'Disabled') {
entries.push({ binding: macBinding, isMac: true });
}
if (pcBinding && pcBinding !== 'Disabled') {
entries.push({ binding: pcBinding, isMac: false });
}
return entries;
});
}, [hotkeyScheme, keyBindings]);
const buildKeyEventFromString = useCallback((keyString: string) => {
const parsed = parseKeyCombo(keyString);
if (!parsed) return null;
const modifiers = new Set(parsed.modifiers);
const key = parsed.key;
const normalizedKey = (() => {
switch (key) {
case 'Space':
return ' ';
case '↑':
return 'ArrowUp';
case '↓':
return 'ArrowDown';
case '←':
return 'ArrowLeft';
case '→':
return 'ArrowRight';
case 'Esc':
return 'Escape';
case '⌫':
return 'Backspace';
case 'Del':
return 'Delete';
case '↵':
return 'Enter';
case '⇥':
return 'Tab';
default:
return key.length === 1 ? key.toLowerCase() : key;
}
})();
return new KeyboardEvent('keydown', {
key: normalizedKey,
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
shiftKey: modifiers.has('Shift'),
});
}, []);
// Validate shortkey for conflicts (case-insensitive comparison)
const normalizeKeyString = useCallback((value: string) => (
value.toLowerCase().replace(/\s+/g, '')
), []);
const validateShortkey = useCallback((key: string): string | null => {
if (!key) return null;
const syntheticEvent = buildKeyEventFromString(key);
if (syntheticEvent) {
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
));
if (conflictsSystem) {
return t('snippets.shortkey.error.systemConflict');
}
}
// Check other snippet shortcuts
if (syntheticEvent) {
for (const snippet of existingShortkeys) {
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
}
}
} else {
const normalizedKey = normalizeKeyString(key);
const conflictingSnippet = existingShortkeys.find(snippet => (
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
));
if (conflictingSnippet) {
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
}
}
return null;
}, [
activeSystemBindings,
buildKeyEventFromString,
existingShortkeys,
isMac,
normalizeKeyString,
t,
]);
// Handle shortkey recording
useEffect(() => {
if (!isRecordingShortkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
// Escape cancels recording
if (e.key === 'Escape') {
setIsRecordingShortkey(false);
setShortkeyError(null);
return;
}
// Skip pure modifier keys
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
const keyString = keyEventToString(e, isMac);
// Validate the new shortkey
const error = validateShortkey(keyString);
if (error) {
setShortkeyError(error);
// Don't stop recording, let user try again
return;
}
setShortkeyError(null);
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
setIsRecordingShortkey(false);
};
const handleClick = () => {
setIsRecordingShortkey(false);
setShortkeyError(null);
};
// Delay adding click handler by 100ms to prevent the button click that
// initiated recording from immediately triggering the click handler
const timer = setTimeout(() => {
window.addEventListener('click', handleClick, true);
}, 100);
window.addEventListener('keydown', handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('click', handleClick, true);
};
}, [isRecordingShortkey, isMac, validateShortkey]);
const handleEdit = (snippet?: Snippet) => {
if (snippet) {
setEditingSnippet(snippet);
@@ -114,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
tags: editingSnippet.tags || [],
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
});
setRightPanelMode('none');
}
@@ -606,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
{editingSnippet.shortkey && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
title={t('snippets.shortkey.clear')}
>
<RotateCcw size={12} />
</Button>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsRecordingShortkey(true);
setShortkeyError(null);
}}
className={cn(
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
isRecordingShortkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50 bg-background"
)}
>
<Keyboard size={14} className="text-muted-foreground" />
{isRecordingShortkey
? t('snippets.shortkey.recording')
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
</button>
{shortkeyError && (
<p className="text-xs text-destructive">{shortkeyError}</p>
)}
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
</Card>
{/* Targets */}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
@@ -895,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
{snippet.shortkey}
</div>
)}
{viewMode === 'list' && (
<Button
variant="ghost"

View File

@@ -235,6 +235,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBroadcastEnabledRef.current = isBroadcastEnabled;
onBroadcastInputRef.current = onBroadcastInput;
// Snippets ref for shortkey support in terminal
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
@@ -425,6 +429,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onHotkeyActionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
snippetsRef,
sessionId,
statusRef,
onCommandExecuted,

View File

@@ -85,6 +85,7 @@ import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import { toast } from "./ui/toast";
import { Badge } from "./ui/badge";
import { HotkeyScheme, KeyBinding } from "../domain/models";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
@@ -104,6 +105,8 @@ interface VaultViewProps {
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessions: TerminalSession[];
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onCreateLocalTerminal: () => void;
@@ -144,6 +147,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
connectionLogs,
managedSources,
sessions,
hotkeyScheme,
keyBindings,
onOpenSettings,
onOpenQuickSwitcher,
onCreateLocalTerminal,
@@ -2075,6 +2080,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts={hosts}
customGroups={customGroups}
shellHistory={shellHistory}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onPackagesChange={onUpdateSnippetPackages}
onSave={(s) =>
onUpdateSnippets(

View File

@@ -97,6 +97,8 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
});
// Check result

View File

@@ -2,7 +2,7 @@
* Import Key Panel - Import existing SSH key
*/
import { Upload } from 'lucide-react';
import { Eye, EyeOff, Upload } from 'lucide-react';
import React,{ useCallback,useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
@@ -15,12 +15,16 @@ import { detectKeyType } from './utils';
interface ImportKeyPanelProps {
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
onImport: () => void;
}
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
onImport,
}) => {
const { t } = useI18n();
@@ -132,6 +136,41 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
/>
</div>
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphraseImport"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<div
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
onDrop={handleDrop}

View File

@@ -5,16 +5,18 @@
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { PortForwardingRule } from '../../domain/models';
import { Host, PortForwardingRule } from '../../domain/models';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { getStatusColor,getTypeColor } from './utils';
export type ViewMode = 'grid' | 'list';
export interface RuleCardProps {
rule: PortForwardingRule;
host?: Host; // The relay host for this rule (for tooltip display)
viewMode: ViewMode;
isSelected: boolean;
isPending: boolean;
@@ -28,6 +30,7 @@ export interface RuleCardProps {
export const RuleCard: React.FC<RuleCardProps> = ({
rule,
host,
viewMode,
isSelected,
isPending,
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
/>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">
{rule.type === 'dynamic'
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate cursor-default">
{rule.type === 'dynamic'
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs">
<div className="space-y-1 text-xs">
{host ? (
<>
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
</>
) : (
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
)}
<div className="border-t border-border/40 pt-1 mt-1">
{rule.type === 'dynamic'
? t('pf.tooltip.dynamicDesc')
: rule.type === 'local'
? t('pf.tooltip.localDesc')
: t('pf.tooltip.remoteDesc')
}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -115,7 +115,7 @@ export default function SettingsShortcutsTab(props: {
};
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app"] as const, []);
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
return (
<SettingsTabContent value="shortcuts">

View File

@@ -1,31 +1,33 @@
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: UploadTask) => {
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('|')) {
@@ -96,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" />
@@ -117,18 +123,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
<span className="text-xs font-medium truncate">
{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
@@ -140,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">
{t("sftp.upload.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">
{t("sftp.upload.cancelled")}
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
</div>
)}
{task.status === "failed" && task.error && (
@@ -178,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

@@ -0,0 +1,155 @@
/**
* useSftpModalKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTPModal operations.
* Supports select all, rename, delete, refresh, and new folder.
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
*/
import { useCallback, useEffect } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import type { RemoteFile } from "../../../types";
// SFTP Modal action names that we handle (subset of main SFTP actions)
const SFTP_MODAL_ACTIONS = new Set([
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
]);
interface UseSftpModalKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
open: boolean;
files: RemoteFile[];
visibleFiles: RemoteFile[];
selectedFiles: Set<string>;
setSelectedFiles: (files: Set<string>) => void;
onRefresh: () => void;
onRename?: (file: RemoteFile) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpModalKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
}: UseSftpModalKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Skip if shortcuts are disabled or modal is not open
if (hotkeyScheme === "disabled" || !open) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
const { action } = matched;
if (!SFTP_MODAL_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
switch (action) {
case "sftpSelectAll": {
// Select all files
const allFileNames = new Set(
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
);
setSelectedFiles(allFileNames);
break;
}
case "sftpRename": {
// Trigger rename for the first selected file
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length !== 1) return;
const file = files.find((f) => f.name === selectedArray[0]);
if (file && onRename) {
onRename(file);
}
break;
}
case "sftpDelete": {
// Delete selected files
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length === 0) return;
onDelete?.(selectedArray);
break;
}
case "sftpRefresh": {
// Refresh file list
onRefresh();
break;
}
case "sftpNewFolder": {
// Create new folder
onNewFolder?.();
break;
}
}
},
[
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
keyBindings,
]
);
useEffect(() => {
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
};

View File

@@ -13,10 +13,10 @@ import {
} 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;
@@ -26,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;
@@ -67,6 +71,7 @@ interface UseSftpModalTransfersParams {
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
@@ -85,6 +90,7 @@ interface UseSftpModalTransfersResult {
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
cancelUpload: () => Promise<void>;
cancelTask: (taskId: string) => Promise<void>;
dismissTask: (taskId: string) => void;
}
@@ -95,7 +101,6 @@ export const useSftpModalTransfers = ({
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftpBinaryWithProgress,
writeSftpBinary,
@@ -104,6 +109,7 @@ export const useSftpModalTransfers = ({
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload = false,
@@ -214,6 +220,7 @@ export const useSftpModalTransfers = ({
speed: 0,
startTime: Date.now(),
isDirectory: true,
direction: "upload",
};
setUploadTasks(prev => [...prev, scanningTask]);
},
@@ -231,6 +238,7 @@ export const useSftpModalTransfers = ({
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -376,19 +384,159 @@ export const useSftpModalTransfers = ({
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);
// 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"),
@@ -398,7 +546,7 @@ export const useSftpModalTransfers = ({
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
);
@@ -608,6 +756,56 @@ export const useSftpModalTransfers = ({
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 {
// 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));
}, []);
@@ -625,6 +823,7 @@ export const useSftpModalTransfers = ({
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
};
};

View File

@@ -1,4 +1,4 @@
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
@@ -21,6 +21,7 @@ import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -195,6 +196,33 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
sortedDisplayFiles,
});
// Handle keyboard shortcut dialog actions
const dialogActionHandlers = useMemo(
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onNewFolder: () => setShowNewFolderDialog(true),
onNewFile: () => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
},
}),
[
getNextUntitledName,
openDeleteConfirm,
openRenameDialog,
pane.files,
setFileNameError,
setNewFileName,
setShowNewFileDialog,
setShowNewFolderDialog,
],
);
useSftpDialogActionHandler(side, dialogActionHandlers);
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
};

View File

@@ -0,0 +1,124 @@
/**
* SFTP Clipboard Store
*
* Manages clipboard state for SFTP file operations (copy/cut/paste)
* This is a simple store that holds the clipboard state and operation type.
*/
import { useSyncExternalStore } from "react";
export type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
export interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;
sourceSide: "left" | "right";
operation: SftpClipboardOperation;
}
type ClipboardListener = () => void;
let clipboardState: SftpClipboardState | null = null;
const clipboardListeners = new Set<ClipboardListener>();
const notifyListeners = () => {
clipboardListeners.forEach((listener) => listener());
};
export const sftpClipboardStore = {
getSnapshot: (): SftpClipboardState | null => clipboardState,
subscribe: (listener: ClipboardListener) => {
clipboardListeners.add(listener);
return () => clipboardListeners.delete(listener);
},
/**
* Copy files to clipboard
*/
copy: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "copy",
};
notifyListeners();
},
/**
* Cut files to clipboard
*/
cut: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "cut",
};
notifyListeners();
},
/**
* Clear clipboard (called after paste for cut operation)
*/
clear: () => {
clipboardState = null;
notifyListeners();
},
/**
* Update clipboard file list (used for partial cut transfers)
*/
updateFiles: (files: SftpClipboardFile[]) => {
if (!clipboardState) return;
if (files.length === 0) {
clipboardState = null;
} else {
clipboardState = {
...clipboardState,
files,
};
}
notifyListeners();
},
/**
* Check if there are files in the clipboard
*/
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
/**
* Get the clipboard state
*/
get: (): SftpClipboardState | null => clipboardState,
};
/**
* React hook to subscribe to clipboard state changes
*/
export const useSftpClipboard = (): SftpClipboardState | null => {
return useSyncExternalStore(
sftpClipboardStore.subscribe,
sftpClipboardStore.getSnapshot,
sftpClipboardStore.getSnapshot
);
};

View File

@@ -0,0 +1,120 @@
/**
* SFTP Dialog Action Store
*
* Manages dialog action triggers for SFTP operations.
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useCallback, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
export interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
type ActionListener = () => void;
let dialogAction: SftpDialogAction | null = null;
const actionListeners = new Set<ActionListener>();
const notifyListeners = () => {
actionListeners.forEach((listener) => listener());
};
export const sftpDialogActionStore = {
getSnapshot: (): SftpDialogAction | null => dialogAction,
subscribe: (listener: ActionListener) => {
actionListeners.add(listener);
return () => actionListeners.delete(listener);
},
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetFiles,
timestamp: Date.now(),
};
}
notifyListeners();
},
/**
* Clear the current action (called after a pane handles it)
*/
clear: () => {
dialogAction = null;
notifyListeners();
},
/**
* Get the current action
*/
get: (): SftpDialogAction | null => dialogAction,
};
/**
* React hook to subscribe to dialog action changes
*/
export const useSftpDialogAction = (): SftpDialogAction | null => {
return useSyncExternalStore(
sftpDialogActionStore.subscribe,
sftpDialogActionStore.getSnapshot,
sftpDialogActionStore.getSnapshot
);
};
/**
* React hook for a pane to respond to dialog actions
* Only the pane matching the targetSide will execute the callback
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
}
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side) return;
// Handle the action and clear it
switch (action.type) {
case "rename":
if (handlers.onRename && action.targetFiles?.[0]) {
handlers.onRename(action.targetFiles[0]);
}
break;
case "delete":
if (handlers.onDelete && action.targetFiles) {
handlers.onDelete(action.targetFiles);
}
break;
case "newFolder":
handlers.onNewFolder?.();
break;
case "newFile":
handlers.onNewFile?.();
break;
}
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, handlers]);
};

View File

@@ -0,0 +1,54 @@
/**
* SFTP Focused Pane Store
*
* Tracks which SFTP pane (left or right) is currently focused.
* This is used to determine which pane should receive keyboard shortcut actions.
*/
import { useSyncExternalStore } from "react";
export type SftpFocusedSide = "left" | "right";
type FocusListener = () => void;
let focusedSide: SftpFocusedSide = "left";
const focusListeners = new Set<FocusListener>();
const notifyListeners = () => {
focusListeners.forEach((listener) => listener());
};
export const sftpFocusStore = {
getSnapshot: (): SftpFocusedSide => focusedSide,
subscribe: (listener: FocusListener) => {
focusListeners.add(listener);
return () => focusListeners.delete(listener);
},
/**
* Set the focused side
*/
setFocusedSide: (side: SftpFocusedSide) => {
if (focusedSide !== side) {
focusedSide = side;
notifyListeners();
}
},
/**
* Get the current focused side
*/
getFocusedSide: (): SftpFocusedSide => focusedSide,
};
/**
* React hook to subscribe to focused side changes
*/
export const useSftpFocusedSide = (): SftpFocusedSide => {
return useSyncExternalStore(
sftpFocusStore.subscribe,
sftpFocusStore.getSnapshot,
sftpFocusStore.getSnapshot
);
};

View File

@@ -0,0 +1,290 @@
/**
* useSftpKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTP operations.
* Supports copy, cut, paste, select all, rename, delete, refresh, and new folder.
*/
import { useCallback, useEffect } from "react";
import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import { toast } from "../../ui/toast";
// SFTP action names that we handle
const SFTP_ACTIONS = new Set([
"sftpCopy",
"sftpCut",
"sftpPaste",
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
]);
interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
isActive: boolean;
showHiddenFiles: boolean;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
isActive,
showHiddenFiles,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
async (e: KeyboardEvent) => {
// Skip if shortcuts are disabled or SFTP is not active
if (hotkeyScheme === "disabled" || !isActive) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
const { action } = matched;
if (!SFTP_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
// Get the active pane for the focused side
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
switch (action) {
case "sftpCopy": {
// Copy selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
break;
}
case "sftpCut": {
// Cut selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
break;
}
case "sftpPaste": {
// Paste files from clipboard
const clipboard = sftpClipboardStore.get();
if (!clipboard || clipboard.files.length === 0) return;
// Use startTransfer to paste files from source to current pane
// The transfer direction is determined by clipboard sourceSide and current focusedSide
if (clipboard.sourceSide !== focusedSide) {
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
if (!sourcePane?.connection) {
toast.info("Paste source is no longer available.", "SFTP");
return;
}
// Cross-pane paste - use startTransfer
try {
const isCut = clipboard.operation === "cut";
const pendingNames = new Set(clipboard.files.map((file) => file.name));
const completedNames = new Set<string>();
const failedNames = new Set<string>();
const updateClipboardAfterCompletion = (showToast: boolean) => {
if (!isCut) return;
const current = sftpClipboardStore.get();
if (
!current ||
current.operation !== "cut" ||
current.sourceConnectionId !== clipboard.sourceConnectionId ||
current.sourcePath !== clipboard.sourcePath ||
current.sourceSide !== clipboard.sourceSide
) {
return;
}
const remainingFiles = current.files.filter((file) => !completedNames.has(file.name));
if (remainingFiles.length === 0) {
sftpClipboardStore.clear();
} else {
sftpClipboardStore.updateFiles(remainingFiles);
}
if (showToast && failedNames.size > 0) {
toast.info("Some items could not be transferred and were kept in the clipboard.", "SFTP");
}
};
const handleTransferComplete = async (result: {
fileName: string;
originalFileName?: string;
status: string;
}) => {
if (!isCut) return;
const sourceFileName = result.originalFileName ?? result.fileName;
if (!pendingNames.has(sourceFileName)) return;
pendingNames.delete(sourceFileName);
if (result.status === "completed") {
try {
await sftp.deleteFilesAtPath(
clipboard.sourceSide,
clipboard.sourceConnectionId,
clipboard.sourcePath,
[sourceFileName],
);
completedNames.add(sourceFileName);
} catch {
failedNames.add(sourceFileName);
}
} else {
failedNames.add(sourceFileName);
}
updateClipboardAfterCompletion(pendingNames.size === 0);
};
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
sourcePane,
sourcePath: clipboard.sourcePath,
sourceConnectionId: clipboard.sourceConnectionId,
onTransferComplete: handleTransferComplete,
});
} catch (error) {
toast.error("Paste failed. Please try again.", "SFTP");
}
} else {
// Same-pane paste is not supported - show info toast
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
}
break;
}
case "sftpSelectAll": {
// Select all files in the current pane
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
if (term) {
visibleFiles = visibleFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}
const allFileNames = visibleFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
sftp.rangeSelect(focusedSide, allFileNames);
break;
}
case "sftpRename": {
// Trigger rename for the first selected file
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length !== 1) return;
sftpDialogActionStore.trigger("rename", selectedFiles);
break;
}
case "sftpDelete": {
// Delete selected files
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
sftpDialogActionStore.trigger("delete", selectedFiles);
break;
}
case "sftpRefresh": {
// Refresh the current pane
sftp.refresh(focusedSide);
break;
}
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder");
break;
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
);
useEffect(() => {
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
};

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

@@ -18,7 +18,7 @@ import {
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings } from "../../../lib/utils";
import { isMacPlatform, normalizeLineEndings } from "../../../lib/utils";
import type {
Host,
KeyBinding,
@@ -26,6 +26,7 @@ import type {
TerminalSettings,
TerminalTheme,
} from "../../../types";
import { matchesKeyBinding } from "../../../domain/models";
type TerminalBackendApi = {
openExternalAvailable: () => boolean;
@@ -66,6 +67,9 @@ export type CreateXTermRuntimeContext = {
((data: string, sourceSessionId: string) => void) | undefined
>;
// Snippets for shortkey support
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>;
sessionId: string;
statusRef: RefObject<TerminalSession["status"]>;
onCommandExecuted?: (
@@ -333,12 +337,41 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
// Check snippet shortcuts first (even if hotkeys are disabled)
const snippets = ctx.snippetsRef?.current;
if (snippets && snippets.length > 0) {
for (const snippet of snippets) {
if (snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)) {
const id = ctx.sessionRef.current;
if (id && ctx.statusRef.current === "connected") {
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
}
if (ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
}
return false;
}
return true;
}
}
}
const currentBindings = ctx.keyBindingsRef.current;
if (currentScheme === "disabled" || currentBindings.length === 0) {
return true;
}
const isMac = currentScheme === "mac";
const matched = checkAppShortcut(e, currentBindings, isMac);
if (!matched) return true;

View File

@@ -136,6 +136,7 @@ export interface Snippet {
tags?: string[];
package?: string; // package path
targets?: string[]; // host ids
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
}
export interface TerminalLine {
@@ -173,7 +174,7 @@ export interface KeyBinding {
label: string;
mac: string; // e.g., '⌘+1', '⌘+⌥+arrows'
pc: string; // e.g., 'Ctrl+1', 'Ctrl+Alt+arrows'
category: 'tabs' | 'terminal' | 'navigation' | 'app';
category: 'tabs' | 'terminal' | 'navigation' | 'app' | 'sftp';
}
// User's custom key bindings - only stores overrides from defaults
@@ -261,6 +262,12 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
if (!parsed) return false;
const { modifiers, key } = parsed;
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
const hasPcModifiers = modifiers.some((modifier) => ['Ctrl', 'Alt', 'Win'].includes(modifier));
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
return false;
}
// Check modifiers
if (isMac) {
@@ -285,18 +292,26 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
if (e.metaKey !== needMeta) return false;
}
// Check key
let eventKey = e.key;
if (eventKey === ' ') eventKey = 'Space';
else if (eventKey === 'ArrowUp') eventKey = '↑';
else if (eventKey === 'ArrowDown') eventKey = '↓';
else if (eventKey === 'ArrowLeft') eventKey = '←';
else if (eventKey === 'ArrowRight') eventKey = '→';
else if (eventKey === 'Escape') eventKey = 'Esc';
else if (eventKey === '[') eventKey = '[';
else if (eventKey === ']') eventKey = ']';
return eventKey.toLowerCase() === key.toLowerCase();
const normalizeKey = (rawKey: string): string => {
let normalizedKey = rawKey;
if (normalizedKey === ' ') normalizedKey = 'Space';
else if (normalizedKey === 'ArrowUp') normalizedKey = '↑';
else if (normalizedKey === 'ArrowDown') normalizedKey = '↓';
else if (normalizedKey === 'ArrowLeft') normalizedKey = '←';
else if (normalizedKey === 'ArrowRight') normalizedKey = '→';
else if (normalizedKey === 'Escape') normalizedKey = 'Esc';
else if (normalizedKey === 'Backspace') normalizedKey = '';
else if (normalizedKey === 'Delete') normalizedKey = 'Del';
else if (normalizedKey === '[') normalizedKey = '[';
else if (normalizedKey === ']') normalizedKey = ']';
else if (normalizedKey === 'Del') normalizedKey = 'Del';
return normalizedKey;
};
const eventKey = normalizeKey(e.key);
const parsedKey = normalizeKey(key);
return eventKey.toLowerCase() === parsedKey.toLowerCase();
};
export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
@@ -328,6 +343,16 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
// SFTP Operations
{ id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' },
{ id: 'sftp-cut', action: 'sftpCut', label: 'Cut Files', mac: '⌘ + X', pc: 'Ctrl + X', category: 'sftp' },
{ id: 'sftp-paste', action: 'sftpPaste', label: 'Paste Files', mac: '⌘ + V', pc: 'Ctrl + V', category: 'sftp' },
{ id: 'sftp-select-all', action: 'sftpSelectAll', label: 'Select All Files', mac: '⌘ + A', pc: 'Ctrl + A', category: 'sftp' },
{ id: 'sftp-rename', action: 'sftpRename', label: 'Rename File', mac: 'F2', pc: 'F2', category: 'sftp' },
{ id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' },
{ id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' },
{ id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' },
];
// Terminal appearance settings
@@ -553,6 +578,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
export interface TransferTask {
id: string;
fileName: string;
originalFileName?: string;
sourcePath: string;
targetPath: string;
sourceConnectionId: string;

View File

@@ -10,6 +10,7 @@ const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
} = require("./sshAuthHelper.cjs");
// Active port forwarding tunnels
@@ -46,55 +47,59 @@ async function startPortForward(event, payload) {
passphrase,
} = payload;
const conn = new SSHClient();
const sender = event.sender;
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:portforward:status", { tunnelId, status, error });
}
};
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
};
if (privateKey) {
connectOpts.privateKey = privateKey;
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
if (password) {
connectOpts.password = password;
}
// Get default keys
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
// Build auth handler using shared helper
const authConfig = buildAuthHandler({
privateKey,
password,
passphrase,
username: connectOpts.username,
logPrefix: "[PortForward]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId: tunnelId,
hostname,
password,
logPrefix: "[PortForward]",
}));
return new Promise((resolve, reject) => {
const conn = new SSHClient();
const sender = event.sender;
const sendStatus = (status, error = null) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:portforward:status", { tunnelId, status, error });
}
};
const connectOpts = {
host: hostname,
port: port,
username: username || 'root',
readyTimeout: 120000, // 2 minutes for 2FA input
keepaliveInterval: 10000,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
};
if (privateKey) {
connectOpts.privateKey = privateKey;
}
if (passphrase) {
connectOpts.passphrase = passphrase;
}
if (password) {
connectOpts.password = password;
}
// 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", createKeyboardInteractiveHandler({
sender,
sessionId: tunnelId,
hostname,
password,
logPrefix: "[PortForward]",
}));
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);

View File

@@ -28,6 +28,7 @@ const {
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
@@ -171,6 +172,18 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
const normalized = path.posix.normalize(dirPath);
if (!normalized || normalized === ".") return;
// Optimization: Check if the full path already exists to avoid O(N) round trips
// This is the common case (e.g. uploading multiple files to the same directory)
const encodedFull = encodePath(normalized, encoding);
try {
const stats = await statAsync(sftp, encodedFull);
if (stats.isDirectory()) {
return;
}
} catch (err) {
// If path doesn't exist or other error, proceed to recursive check
}
const isAbsolute = normalized.startsWith("/");
const parts = normalized.split("/").filter(Boolean);
let current = isAbsolute ? "/" : "";
@@ -295,8 +308,20 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'chacha20-poly1305@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
};
@@ -325,6 +350,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
if (jump.password) connOpts.password = jump.password;
// Get default keys (either from options if pre-fetched, or fetch them now)
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeysFromHelper();
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
@@ -335,6 +363,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
username: connOpts.username,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -654,6 +683,9 @@ async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
// Get default keys early to use for both chain and target
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
// Check if we need to connect through jump hosts
const jumpHosts = options.jumpHosts || [];
const hasJumpHosts = jumpHosts.length > 0;
@@ -665,6 +697,10 @@ async function openSftp(event, options) {
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
// Pass default keys to chain connection
options._defaultKeys = defaultKeys;
const chainResult = await connectThroughChainForSftp(
event,
options,
@@ -731,6 +767,7 @@ async function openSftp(event, options) {
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);

View File

@@ -68,22 +68,21 @@ const passphraseHandler = require("./passphraseHandler.cjs");
/**
* 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}
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
function findDefaultPrivateKey() {
async 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 {
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
if (isKeyEncrypted(privateKey)) {
continue;
}
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
return null;
@@ -93,33 +92,34 @@ const passphraseHandler = require("./passphraseHandler.cjs");
* 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 }>}
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
*/
function findAllDefaultPrivateKeys(options = {}) {
async function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
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;
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
return null;
}
return {
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
};
} catch {
return null;
}
}
return keys;
});
const results = await Promise.all(promises);
return results.filter(Boolean);
}
/**
@@ -146,7 +146,7 @@ function findAllDefaultPrivateKeys(options = {}) {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -159,7 +159,6 @@ function findAllDefaultPrivateKeys(options = {}) {
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
@@ -465,7 +464,7 @@ function findAllDefaultPrivateKeys(options = {}) {
* @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 allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
if (encryptedKeys.length === 0) {

View File

@@ -13,9 +13,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
requestPassphrasesForEncryptedKeys,
@@ -76,31 +76,29 @@ function isKeyEncrypted(keyContent) {
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
function findDefaultPrivateKey() {
async 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
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;
}
log("Found default key", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} catch (e) {
log("Failed to read default key", { keyPath, error: e.message });
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
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;
}
log("Found default key", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} catch (e) {
log("Failed to read default key", { keyPath, error: e.message });
continue;
}
}
log("No suitable default SSH key found");
@@ -110,29 +108,33 @@ function findDefaultPrivateKey() {
/**
* 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 }>}
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string }>>}
*/
function findAllDefaultPrivateKeys() {
async 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 promises = DEFAULT_KEY_NAMES.map(async (name) => {
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 });
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const encrypted = isKeyEncrypted(privateKey);
if (!encrypted) {
log("Found default key for fallback", { keyPath, keyName: name });
return { privateKey, keyPath, keyName: name };
} else {
log("Skipping encrypted key", { keyPath, keyName: name });
return null;
}
} catch (e) {
log("Failed to read key", { keyPath, error: e.message });
return null;
}
}
});
const results = await Promise.all(promises);
const keys = results.filter(Boolean);
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
return keys;
}
@@ -277,9 +279,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
// Prioritize faster key exchange
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
};
@@ -308,6 +319,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
if (jump.password) connOpts.password = jump.password;
// Get default keys (either from options if pre-fetched, or fetch them now)
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeys();
// Build auth handler using shared helper
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
const authConfig = buildAuthHandler({
@@ -318,6 +332,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
username: connOpts.username,
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -452,9 +467,18 @@ async function startSSHSession(event, options) {
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
// Prioritize faster key exchange
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
};
@@ -508,19 +532,19 @@ async function startSSHSession(event, options) {
let defaultKeyInfo = null;
let allDefaultKeys = [];
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
const defaultKey = await 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();
allDefaultKeys = await 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", {
log("Using unlocked encrypted keys from retry", {
count: unlockedEncryptedKeys.length,
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
});
@@ -529,15 +553,15 @@ async function startSSHSession(event, options) {
// If no primary auth method configured, try ssh-agent first, then ALL default keys
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
// First, try to use ssh-agent if available (this is what regular SSH does)
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
const sshAgentSocket = process.platform === "win32"
? "\\\\.\\pipe\\openssh-ssh-agent"
: process.env.SSH_AUTH_SOCK;
if (sshAgentSocket) {
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
connectOpts.agent = sshAgentSocket;
}
// Mark that we need to try all default keys (handled in authMethods below)
if (allDefaultKeys.length > 0) {
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
@@ -612,11 +636,11 @@ async function startSSHSession(event, options) {
// This is critical because different servers may have different keys in authorized_keys
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
for (const keyInfo of allDefaultKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
isDefault: true,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
@@ -626,12 +650,12 @@ async function startSSHSession(event, options) {
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of unlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
isDefault: true,
id: `publickey-encrypted-${keyInfo.keyName}`
isDefault: true,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
@@ -754,7 +778,7 @@ async function startSSHSession(event, options) {
// Check if this method is still available on server
// Note: "agent" uses "publickey" as the underlying method type
const methodName = method.type === "password" ? "password" :
method.type === "publickey" ? "publickey" :
method.type === "publickey" ? "publickey" :
method.type === "agent" ? "publickey" : "keyboard-interactive";
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
log("Auth method not available on server, skipping", { method: method.id });
@@ -814,6 +838,9 @@ async function startSSHSession(event, options) {
// Handle chain/proxy connections
if (hasJumpHosts) {
// Pass fetched keys to chain connection to avoid re-reading files
options._defaultKeys = allDefaultKeys;
const chainResult = await connectThroughChain(
event,
options,
@@ -1098,12 +1125,18 @@ async function startSSHSession(event, options) {
* Execute a one-off command via SSH
*/
async function execCommand(event, payload) {
const enableKeyboardInteractive = !!payload.enableKeyboardInteractive;
const baseTimeoutMs = payload.timeout || 10000;
const timeoutMs = enableKeyboardInteractive ? Math.max(baseTimeoutMs, 120000) : baseTimeoutMs;
const sender = event.sender;
const sessionId = payload.sessionId || `exec-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const defaultKeys = enableKeyboardInteractive ? await findAllDefaultPrivateKeysFromHelper() : [];
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let stdout = "";
let stderr = "";
let settled = false;
const timeoutMs = payload.timeout || 10000;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
@@ -1159,7 +1192,7 @@ async function execCommand(event, payload) {
host: payload.hostname,
port: payload.port || 22,
username: payload.username,
readyTimeout: timeoutMs,
readyTimeout: enableKeyboardInteractive ? Math.max(timeoutMs, 120000) : timeoutMs,
keepaliveInterval: 0,
};
@@ -1183,7 +1216,29 @@ async function execCommand(event, payload) {
if (payload.password) connectOpts.password = payload.password;
if (authAgent) {
if (enableKeyboardInteractive) {
connectOpts.tryKeyboard = true;
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
passphrase: connectOpts.passphrase,
agent: connectOpts.agent,
username: connectOpts.username,
logPrefix: "[SSH Exec]",
defaultKeys,
});
applyAuthToConnOpts(connectOpts, authConfig);
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: payload.hostname,
password: payload.password,
logPrefix: "[SSH Exec]",
}));
} else if (authAgent) {
const order = ["agent"];
if (connectOpts.password) order.push("password");
connectOpts.authHandler = order;
@@ -1257,18 +1312,18 @@ async function startSSHSessionWrapper(event, options) {
// 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 allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
if (encryptedKeys.length > 0) {
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname
);
// If user cancelled, don't retry even if some keys were unlocked
if (passphraseResult.cancelled) {
console.log('[SSH] User cancelled passphrase flow, not retrying');
@@ -1277,7 +1332,7 @@ async function startSSHSessionWrapper(event, options) {
count: passphraseResult.keys.length,
keyNames: passphraseResult.keys.map(k => k.keyName)
});
// Retry connection with unlocked keys
// Wrap in try-catch to ensure consistent error handling for retry failures
try {
@@ -1290,7 +1345,7 @@ async function startSSHSessionWrapper(event, options) {
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
retryErr.message?.toLowerCase().includes('auth') ||
retryErr.level === 'client-authentication';
if (isRetryAuthError) {
const authError = new Error(retryErr.message);
authError.level = 'client-authentication';
@@ -1304,7 +1359,7 @@ async function startSSHSessionWrapper(event, options) {
}
}
}
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
const authError = new Error(err.message);
@@ -1720,8 +1775,11 @@ function registerHandlers(ipcMain) {
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
await fs.promises.access(keyPath, fs.constants.F_OK);
keys.push({ name, path: keyPath });
} catch {
// ignore missing keys
}
}
return keys;

View File

@@ -87,9 +87,9 @@ function loadWindowState() {
}
/**
* Save window state to disk
* Save window state to disk (synchronous)
*/
function saveWindowState(state) {
function saveWindowStateSync(state) {
try {
const statePath = getWindowStatePath();
if (!statePath) return false;
@@ -101,6 +101,47 @@ function saveWindowState(state) {
}
}
/**
* Save window state to disk (asynchronous)
*/
async function saveWindowState(state) {
try {
const statePath = getWindowStatePath();
if (!statePath) return false;
await fs.promises.writeFile(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return true;
} catch (err) {
debugLog("Failed to save window state:", err?.message || err);
return false;
}
}
let pendingWindowStateWrite = null;
let queuedWindowState = null;
let windowStateCloseRequested = false;
async function queueWindowStateSave(state) {
if (!state) return false;
if (windowStateCloseRequested) {
return pendingWindowStateWrite || false;
}
queuedWindowState = state;
if (pendingWindowStateWrite) {
return pendingWindowStateWrite;
}
pendingWindowStateWrite = (async () => {
let lastResult = true;
while (queuedWindowState) {
const nextState = queuedWindowState;
queuedWindowState = null;
lastResult = await saveWindowState(nextState);
}
pendingWindowStateWrite = null;
return lastResult;
})();
return pendingWindowStateWrite;
}
/**
* Get the current window bounds state for saving
* @param {BrowserWindow} win - The window to get bounds from
@@ -589,7 +630,7 @@ async function createWindow(electronModule, options) {
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
if (state) queueWindowStateSave(state);
}, 500);
};
@@ -611,11 +652,33 @@ async function createWindow(electronModule, options) {
});
// Save state when window is about to close
win.on("close", () => {
win.on("close", (event) => {
if (windowStateCloseRequested) {
return;
}
windowStateCloseRequested = true;
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
// Close settings window when main window closes
if (pendingWindowStateWrite) {
event.preventDefault();
if (state) queuedWindowState = state;
pendingWindowStateWrite
.catch(() => {
// ignore async write errors before closing
})
.finally(() => {
const finalState = getWindowBoundsState(win, lastNormalBounds);
if (finalState) saveWindowStateSync(finalState);
closeSettingsWindow();
try {
win.close();
} catch {
// ignore
}
});
return;
}
if (state) saveWindowStateSync(state);
closeSettingsWindow();
});

View File

@@ -267,22 +267,22 @@ let cloudSyncSessionPassword = null;
const CLOUD_SYNC_PASSWORD_FILE = "netcatty_cloud_sync_master_password_v1";
// Key management helpers
const ensureKeyDir = () => {
const ensureKeyDir = async () => {
try {
fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
await fs.promises.mkdir(keyRoot, { recursive: true, mode: 0o700 });
} catch (err) {
console.warn("Unable to ensure key cache dir", err);
}
};
const writeKeyToDisk = (keyId, privateKey) => {
const writeKeyToDisk = async (keyId, privateKey) => {
if (!privateKey) return null;
ensureKeyDir();
await ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
fs.writeFileSync(target, normalized, { mode: 0o600 });
await fs.promises.writeFile(target, normalized, { mode: 0o600 });
return target;
} catch (err) {
console.error("Failed to persist private key", err);
@@ -559,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

@@ -705,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 }),

5
global.d.ts vendored
View File

@@ -182,6 +182,8 @@ declare global {
privateKey?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
@@ -536,6 +538,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 }>;

View File

@@ -26,11 +26,13 @@ import {
type SyncHistoryEntry,
type WebDAVConfig,
type S3Config,
type SyncedFile,
SYNC_CONSTANTS,
SYNC_STORAGE_KEYS,
generateDeviceId,
getDefaultDeviceName,
} from '../../domain/sync';
import packageJson from '../../package.json';
import { EncryptionService } from './EncryptionService';
import { createAdapter, type CloudAdapter } from './adapters';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
@@ -795,6 +797,105 @@ export class CloudSyncManager {
// Sync Operations
// ==========================================================================
/**
* Helper: Check for conflicts with a specific provider
*/
private async checkProviderConflict(
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
error?: string;
remoteFile?: SyncedFile;
}> {
try {
const remoteFile = await adapter.download();
if (remoteFile) {
// Compare versions
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
return {
conflict: true,
remoteFile,
};
}
}
return { conflict: false };
} catch (error) {
return { conflict: false, error: String(error) };
}
}
/**
* Helper: Upload encrypted file to a provider
*/
private async uploadToProvider(
provider: CloudProvider,
adapter: CloudAdapter,
syncedFile: SyncedFile
): Promise<SyncResult> {
try {
await adapter.upload(syncedFile);
// Update local state (safe to do multiple times if values are same)
this.state.localVersion = syncedFile.meta.version;
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
this.state.remoteVersion = syncedFile.meta.version;
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
this.saveSyncConfig();
this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: true,
localVersion: syncedFile.meta.version,
remoteVersion: syncedFile.meta.version,
deviceName: this.state.deviceName,
});
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'upload',
version: syncedFile.meta.version,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
} catch (error) {
this.updateProviderStatus(provider, 'error', String(error));
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: false,
localVersion: this.state.localVersion,
deviceName: this.state.deviceName,
error: String(error),
});
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
return {
success: false,
provider,
action: 'none',
error: String(error),
};
}
}
/**
* Build sync payload from current app state
*/
@@ -855,81 +956,61 @@ export class CloudSyncManager {
this.emit({ type: 'SYNC_STARTED', provider });
try {
// Check for remote version first
const remoteFile = await adapter.download();
// 1. Check for conflict
const checkResult = await this.checkProviderConflict(provider, adapter);
if (remoteFile) {
// Compare versions
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
// Remote is newer - conflict
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
};
this.emit({ type: 'CONFLICT_DETECTED', conflict: this.state.currentConflict });
return {
success: false,
provider,
action: 'none',
conflictDetected: true,
};
}
if (checkResult.error) {
throw new Error(checkResult.error);
}
// Encrypt and upload
if (checkResult.conflict && checkResult.remoteFile) {
const remoteFile = checkResult.remoteFile;
// Remote is newer - conflict
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
return {
success: false,
provider,
action: 'none',
conflictDetected: true,
};
}
// 2. Encrypt
const syncedFile = await EncryptionService.encryptPayload(
payload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
'1.0.0', // TODO: Get from package.json
packageJson.version,
this.state.localVersion
);
await adapter.upload(syncedFile);
// 3. Upload
const result = await this.uploadToProvider(provider, adapter, syncedFile);
// Update local state
this.state.localVersion = syncedFile.meta.version;
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
this.state.remoteVersion = syncedFile.meta.version;
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
this.saveSyncConfig();
this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange(); // Notify UI immediately after version update
// Add to sync history
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'upload',
success: true,
localVersion: syncedFile.meta.version,
remoteVersion: syncedFile.meta.version,
deviceName: this.state.deviceName,
});
this.state.syncState = 'IDLE';
this.updateProviderStatus(provider, 'connected');
const result: SyncResult = {
success: true,
provider,
action: 'upload',
version: syncedFile.meta.version,
};
this.emit({ type: 'SYNC_COMPLETED', provider, result });
if (result.success) {
this.state.syncState = 'IDLE';
} else {
this.state.syncState = 'ERROR';
if (result.error) {
this.state.lastError = result.error;
}
}
return result;
} catch (error) {
@@ -1050,20 +1131,178 @@ export class CloudSyncManager {
return results;
}
if (this.state.securityState !== 'UNLOCKED') {
return results; // Or throw? Caller handles it.
}
if (!this.masterPassword) {
return results;
}
const connectedProviders = Object.entries(this.state.providers)
.filter(([_, conn]) => conn.status === 'connected')
.map(([p]) => p as CloudProvider);
for (const provider of connectedProviders) {
const result = await this.syncToProvider(provider, payload);
results.set(provider, result);
// Stop on conflict
if (result.conflictDetected) {
break;
}
if (connectedProviders.length === 0) {
return results;
}
this.state.syncState = 'SYNCING';
// 1. Parallel Checks
const checkTasks = connectedProviders.map(async (provider) => {
try {
// We handle connection error here to prevent one provider blocking others
const adapter = await this.getConnectedAdapter(provider);
this.updateProviderStatus(provider, 'syncing');
this.emit({ type: 'SYNC_STARTED', provider });
const check = await this.checkProviderConflict(provider, adapter);
return { provider, adapter, check };
} catch (error) {
return { provider, error: String(error) };
}
});
const checkResults = await Promise.all(checkTasks);
// 2. Analyze Results & Handle Conflicts
const conflict = checkResults.find((r) => !r.error && r.check?.conflict);
if (conflict && conflict.check?.remoteFile) {
const { provider, check } = conflict;
const remoteFile = check.remoteFile!;
this.state.syncState = 'CONFLICT';
this.state.currentConflict = {
provider: provider as CloudProvider,
localVersion: this.state.localVersion,
localUpdatedAt: this.state.localUpdatedAt,
localDeviceName: this.state.deviceName,
remoteVersion: remoteFile.meta.version,
remoteUpdatedAt: remoteFile.meta.updatedAt,
remoteDeviceName: remoteFile.meta.deviceName,
};
this.emit({
type: 'CONFLICT_DETECTED',
conflict: this.state.currentConflict,
});
// Populate results
for (const r of checkResults) {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
} else if (r.provider === provider) {
results.set(provider as CloudProvider, {
success: false,
provider: provider as CloudProvider,
action: 'none',
conflictDetected: true,
});
} else {
// Others are reset to connected
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
results.set(r.provider as CloudProvider, {
success: true, // Should we mark as success if skipped?
provider: r.provider as CloudProvider,
action: 'none',
});
}
}
return results;
}
// 3. Encrypt Once
const validUploads = checkResults.filter(
(r) => !r.error && !r.check?.conflict && r.adapter
) as { provider: CloudProvider; adapter: CloudAdapter }[];
if (validUploads.length === 0) {
// Process errors if any
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
this.state.syncState = 'ERROR';
return results;
}
let syncedFile: SyncedFile;
try {
syncedFile = await EncryptionService.encryptPayload(
payload,
this.masterPassword,
this.state.deviceId,
this.state.deviceName,
packageJson.version,
this.state.localVersion
);
} catch (error) {
const msg = String(error);
this.state.syncState = 'ERROR';
this.state.lastError = msg;
// Fail all
for (const r of validUploads) {
this.updateProviderStatus(r.provider, 'error', msg);
this.emit({ type: 'SYNC_ERROR', provider: r.provider, error: msg });
results.set(r.provider, {
success: false,
provider: r.provider,
action: 'none',
error: msg,
});
}
return results;
}
// 4. Parallel Uploads
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
const result = await this.uploadToProvider(provider, adapter, syncedFile);
results.set(provider, result);
});
await Promise.all(uploadTasks);
// 5. Final State Update
const hasSuccess = Array.from(results.values()).some((r) => r.success);
if (hasSuccess) {
this.state.syncState = 'IDLE';
} else {
this.state.syncState = 'ERROR';
// lastError is set by uploadToProvider
}
// Process errors from initial checks (if any)
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
return results;
}
@@ -1071,6 +1310,12 @@ export class CloudSyncManager {
// Auto-Sync
// ==========================================================================
setDeviceName(name: string): void {
this.state.deviceName = name;
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, name);
this.notifyStateChange();
}
setAutoSync(enabled: boolean, intervalMinutes?: number): void {
this.state.autoSyncEnabled = enabled;
if (intervalMinutes) {

View File

@@ -12,4 +12,15 @@ export function cn(...inputs: ClassValue[]) {
*/
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* Detect if the current platform is macOS.
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.
*/
export function isMacPlatform(): boolean {
if (typeof navigator !== 'undefined') {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
}
return false;
}

2241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,7 @@
"@vitejs/plugin-react": "^5.1.2",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.2.6",
"electron": "^40.1.0",
"electron-builder": "^26.0.12",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -80,5 +80,8 @@
"typescript": "~5.9.3",
"vite": "^7.2.7",
"wait-on": "^9.0.3"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0"
}
}
}

View File

@@ -10,6 +10,7 @@
"DOM.Iterable"
],
"skipLibCheck": true,
"resolveJsonModule": true,
"types": [
"node",
"vite/client"