Compare commits

...

74 Commits

Author SHA1 Message Date
bincxz
f6f0d0ead1 feat: sync Monaco editor background with UI theme
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
Define custom Monaco themes (netcatty-dark, netcatty-light) that inherit from built-in themes but use the app's background color from CSS variables. This ensures the text editor background matches the current UI theme, including Pure Black mode.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 20:41:48 +08:00
bincxz
dbfd50a8e0 feat: add Pure Black and Lavender UI themes
Add Pure Black dark theme with true black background (#000000) for AMOLED screens, and Lavender light theme to balance theme count. Also increase sidebar nav item selection contrast from 5% to 10% for better visibility in dark themes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 20:22:51 +08:00
陈大猫
17ffe5d1ee Merge pull request #119 from binaricat/copilot/add-quick-host-copy-function
Add duplicate host and save password toggle features
2026-01-22 19:13:38 +08:00
copilot-swe-agent[bot]
394cd539b3 Fix duplicate host not being saved - check host existence instead of editingHost flag
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:37:58 +00:00
copilot-swe-agent[bot]
1289223523 Address code review: improve savePassword toggle clarity with nullish coalescing
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:13:32 +00:00
copilot-swe-agent[bot]
1d29167b97 Add duplicate host and save password features
- Add "Duplicate" option to host context menu to quickly copy hosts
- Add "Save Password" toggle in host details panel
- Add savePassword field to Host interface
- Add i18n translations for both features (en, zh-CN)
- Internationalize existing hardcoded context menu items

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:09:14 +00:00
copilot-swe-agent[bot]
143f6d993e Initial plan 2026-01-22 10:03:15 +00:00
bincxz
7ee45ed7aa fix: intercept aria-hidden via property descriptor and setAttribute
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
Use both Object.defineProperty for the ariaHidden property and
setAttribute override to catch all ways aria-hidden can be set.
The MutationObserver approach was too late (async) to prevent
the browser warning.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:17:18 +08:00
bincxz
fb7b0aee86 fix: use MutationObserver to prevent aria-hidden on context menu portal
The previous setAttribute override wasn't catching all cases where
aria-hidden was being set. Using MutationObserver to watch for
attribute changes and immediately remove aria-hidden when the
context menu is open (has children).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:14:53 +08:00
bincxz
54cd97d3f1 fix: remove duplicate encodePathForSession declaration
Remove residual code from merge conflict that caused duplicate variable
declaration error. The lazy-loading pattern is now correctly placed in
handleFileChange function only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:11:19 +08:00
陈大猫
0bb876ea32 Avoid circular require in file watcher 2026-01-22 08:55:21 +08:00
bincxz
77a80f6dcb perf: only force reload when filenameEncoding changes
The useEffect that triggers loadFiles with force:true was running on
every navigation because currentPath was in its dependencies. This
caused redundant SFTP list calls that bypassed caching.

Now we track the previous encoding with a ref and only force reload
when filenameEncoding actually changes, not on every path change.
Regular navigation still works through useSftpModalSession's own
path change handling.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:52:52 +08:00
bincxz
0989328afa fix: preserve encoding when directory listing is empty
When auto-detection mode is used and a directory is empty (or all
filenames are valid UTF-8), detectEncodingFromList now returns null
instead of defaulting to "utf-8". The caller then preserves the
previously resolved session encoding.

This fixes the issue where navigating into an empty directory on a
GB18030 server would silently flip the session to UTF-8, causing
subsequent creates/renames/uploads to send UTF-8 bytes and produce
garbled filenames.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:35:44 +08:00
bincxz
c8621960d5 fix: address reviewer feedback for encoding and aria-hidden issues
1. Fix getStringLen to honor encoding parameter in SFTP patch
   - When a non-UTF-8 encoding is provided, the function now correctly
     calculates byte length using that encoding instead of defaulting to UTF-8
   - This prevents protocol errors when writing data with custom encodings

2. Fix aria-hidden warning on context menu portal
   - Override setAttribute on the portal container to block aria-hidden="true"
     when the menu is open (has children)
   - This prevents "Blocked aria-hidden on an element because its descendant
     retained focus" warnings when context menu opens inside a dialog

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:21:28 +08:00
bincxz
8fddc63777 fix: filter out serial hosts from SFTP host picker
Serial connections don't support SFTP, so they shouldn't appear
in the host picker when adding a new SFTP connection.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:59:57 +08:00
bincxz
185143cedc fix: pass rootTaskId through nested transfers for cancellation
The previous fix only checked the direct parent task ID, which doesn't
work for deeply nested folders:
- Top-level task "A" (user cancels this)
- Child folder "B" with parentTaskId="A" (detected cancellation)
- Grandchild file "C" with parentTaskId="B" (NOT detected!)

Now we pass rootTaskId through all levels of transferDirectory and
transferFile calls, so all nested operations check against the
original top-level task ID.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:57:36 +08:00
bincxz
5ec91ea89d fix: properly cancel pane-to-pane transfers
Previously, clicking cancel only updated the task status but didn't
stop the actual file transfer operations. The transferFile and
transferDirectory functions would continue running.

Changes:
- Add cancelledTasksRef to track cancelled task IDs
- Check cancellation status at start of transferFile/transferDirectory
- Check cancellation status during directory iteration
- Add cancelled task ID to ref in cancelTransfer
- Handle cancellation errors gracefully in processTransfer
- Clean up cancelled IDs after 5s delay

Now cancelling a folder transfer actually stops the operation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:53:40 +08:00
bincxz
77c700e666 fix: adapt UploadBridge to NetcattyBridge interface
The UploadBridge interface has a different parameter order than
NetcattyBridge for writeSftpBinaryWithProgress:
- UploadBridge: (sftpId, path, data, taskId, onProgress, ...)
- NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, ...)

Previously, the bridge was passed directly which caused callbacks
to be passed where encoding was expected, leading to
"An object could not be cloned" errors in Electron IPC.

Now we wrap the function to properly adapt the interfaces.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:41:57 +08:00
bincxz
9590d3a67d fix: reset encoding when connecting to new host
When connecting to a new host, the pane's filenameEncoding was being
preserved from the previous connection. This caused issues when:
1. A pane was set to explicit encoding (e.g., UTF-8)
2. User connects to a host configured for GB18030
3. The bridge wouldn't auto-detect because encoding wasn't "auto"

Now the encoding is reset to the host's configured sftpEncoding or
"auto" when a new connection is established, ensuring proper
auto-detection and respecting host-level encoding settings.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:36:29 +08:00
bincxz
2de37c3d53 fix: address code review feedback
- Fix temporal dead zone issue in useSftpTransfers.ts by moving
  sourceEncoding declaration before its usage (P1 issue)
- Initialize filenameEncoding from host.sftpEncoding in SFTPModal.tsx
  and add useEffect to update when host changes (P2 issue)
- Fix duplicate Host import in SftpModalHeader.tsx

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:23:58 +08:00
bincxz
994b5d1325 Merge main into feature/gb18030-encoding
Merged the latest changes from main including:
- Bundled folder uploads with UploadController
- Fast delete using SSH exec
- UI improvements and bug fixes

Resolved conflicts in:
- useSftpExternalOperations.ts
- SFTPModal.tsx
- sftpBridge.cjs

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:20:56 +08:00
陈大猫
5bdd187365 Merge pull request #116 from binaricat/feature/bundled-folder-uploads-and-fast-delete
feat(sftp): bundle folder uploads and improve cancel/delete operations
2026-01-22 02:08:32 +08:00
bincxz
c1959adbf6 fix: distinguish upload errors from user cancellations
- uploadService.ts: Mark all bundle tasks as cancelled on early loop exit
- uploadService.ts: Don't set wasCancelled for actual errors, preserving
  the error result for proper UI feedback
- useSftpModalTransfers.ts: Re-throw real errors instead of masking them
  as cancellations, only return cancelled:true for user-initiated cancels

This ensures genuine failures (permission denied, disk full, connection
loss) are reported as errors with proper toast messages, while user
cancellations are correctly identified and handled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:04:13 +08:00
bincxz
6196c6e3c3 Optimizes render tracking and dialog accessibility
Disables render tracking by default, making it an opt-in debugging feature. This change significantly reduces development log noise by only logging render information when explicitly enabled via a debug flag.

Also, explicitly sets `aria-describedby={undefined}` on dialog content. This ensures proper accessibility semantics when a description is not provided, preventing potential issues or warnings.
2026-01-22 01:44:01 +08:00
bincxz
7980a62d55 Generalizes SFTP upload error handling
Adopts a "fail-fast" strategy for SFTP uploads, stopping the entire transfer process upon encountering any error during file or directory operations.

Introduces a new `isFatalUploadError` helper to consolidate and expand the definition of errors that should halt an upload, now explicitly including cases where the target directory is deleted or inaccessible during the transfer.

Removes specific fatal error checks from various components, streamlining error propagation and simplifying the overall error handling logic.

Ensures "Scanning files..." placeholder tasks are consistently removed when actual upload tasks are added, preventing potential state inconsistencies.
2026-01-22 01:39:31 +08:00
bincxz
52b18d825d feat(sftp): Bundle folder uploads and improve cancellation
Refactors the SFTP upload mechanism to provide a more unified and robust experience.

- **Bundles folder uploads**: When uploading a folder from the local machine, it now appears as a single, aggregated task in the UI, showing overall progress instead of individual files.
- **Enhances cancellation**: Implements a new upload service and controller to manage transfers, allowing for more immediate and reliable cancellation of both individual files and bundled folder uploads.
- **Improves UI feedback**: Adds dedicated buttons for cancelling active uploads and dismissing completed, failed, or cancelled tasks.
- **Faster folder deletion**: Utilizes SSH `rm -rf` command for rapid remote folder deletion, falling back to SFTP rmdir if SSH exec is unavailable.
- Updates internationalization keys for single item deletion confirmation.
2026-01-22 01:09:41 +08:00
bincxz
70a172216a feat(sftp): bundle folder uploads and improve cancel/delete operations
- Bundle folder uploads as single tasks showing aggregate progress
- Add unique file transfer IDs for proper cancellation tracking
- Fix cancel button to call cancelExternalUpload for external uploads
- Improve backend cancel detection using cancelled flag instead of error message
- Use SSH exec with rm -rf for fast folder deletion on remote servers
- Add FolderUp icon for folder upload tasks in transfer queue
- Add i18n key for upload cancelled message

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 00:19:46 +08:00
TachibanaLolo
24edf6e1df chore: sync package-lock 2026-01-21 23:52:36 +08:00
TachibanaLolo
a4abcab019 Merge feature/gb18030 into main 2026-01-21 23:30:11 +08:00
TachibanaLolo
ca91483d01 Merge upstream/main 2026-01-21 23:13:31 +08:00
bincxz
dfcdcda189 Refactors module imports and updates hook deps
Adjusts relative import paths across the application to reflect recent changes in the project's directory structure. This primarily affects SFTP state management and modal components.

Updates React hook dependency arrays for `useCallback` and `useEffect` to ensure proper memoization and re-execution, addressing `react-hooks/exhaustive-deps` linting rules.

Improves type safety for translation function parameters by changing `Record<string, any>` to `Record<string, unknown>`.

Adds the `ls` command to the allowed commands for the Claude AI assistant in local settings.
2026-01-21 22:25:38 +08:00
TachibanaLolo
7514f2eae3 Harden GB18030 SFTP listing against buffer path errors 2026-01-21 22:07:54 +08:00
bincxz
951d1307cd Refactors SFTP features for improved modularity and readability
Decomposes the core SFTP state management hook and related UI components into smaller, domain-specific custom hooks and specialized sub-components.

This refactoring aims to:
- Break down the monolithic `useSftpState` hook into focused hooks like `useSftpConnections`, `useSftpTransfers`, and `useSftpPaneActions`.
- Extract complex logic and rendering from `SFTPModal` and `SftpView` into dedicated presentational components and custom hooks.
- Centralize SFTP-related types and utility functions into new `sftp/types.ts` and `sftp/utils.ts` modules.

The changes enhance code organization, maintainability, and testability across the SFTP functionality.
2026-01-21 21:41:37 +08:00
陈大猫
0c14aed55a Merge pull request #113 from binaricat/fix/issue-80-performance-and-serial-port
fix: add renderer selection setting and fix serial port dropdown
2026-01-21 19:08:00 +08:00
bincxz
2fde490ee7 fix: use 3-value renderer option to preserve low-memory auto fallback
Change rendererType from boolean to 'auto' | 'webgl' | 'canvas':
- 'auto' (default): Uses Canvas on low-memory devices (<=4GB), WebGL otherwise
- 'webgl': Force WebGL renderer
- 'canvas': Force Canvas renderer

This preserves the automatic low-memory device detection that was
inadvertently disabled when always passing a boolean value.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:39:39 +08:00
bincxz
b6d0a3c698 fix: add renderer selection setting and fix serial port dropdown
- Add renderer selection (WebGL/Canvas) in terminal settings
- Remove macOS restriction for Canvas renderer, allow all platforms
- Fix Combobox dropdown to show all options when opened instead of
  filtering by current value
- Add i18n translations for new rendering settings

Closes #80

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:28:58 +08:00
陈大猫
5b2cf535e5 Merge pull request #112 from binaricat/feat/server-stats-display
feat: display Linux server stats in terminal statusbar
2026-01-21 18:08:42 +08:00
bincxz
3c8ff48b4e fix: calculate CPU usage from delta instead of cumulative values
The previous implementation read /proc/stat once and calculated CPU
percentage from cumulative counters since boot. This gave a long-term
average that barely reflected short-term load changes.

Now we store the previous /proc/stat reading and calculate the delta
between consecutive samples, giving accurate real-time CPU usage that
properly reflects current system load. This is the same approach we
already use for network speed calculation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:07:58 +08:00
bincxz
5dcddfd0ff fix: prevent server stats statusbar from wrapping on small windows
Add overflow-hidden and flex-nowrap to the stats container so items
are hidden instead of wrapping to a new line when window is resized.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:03:50 +08:00
bincxz
95bc7f018c feat: display Linux server stats in terminal statusbar
Add real-time monitoring of Linux server resources in the terminal statusbar:
- CPU usage with per-core breakdown in hover popup
- Memory usage with htop-style colored bar (Used/Buffers/Cache/Free)
- Top 10 processes by memory consumption
- Disk usage for all mounted partitions
- Network speed (RX/TX) with per-interface details

Features:
- Only enabled for Linux servers (detected automatically)
- Configurable refresh interval (default 5 seconds)
- Toggle in Settings > Terminal tab
- Hover-triggered popups using Radix UI HoverCard
- BusyBox compatibility with fallback commands

Closes #108

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:58:55 +08:00
陈大猫
7dee34f7d8 Merge pull request #110 from binaricat/fix/quick-switcher-improvements
fix: improve QuickSwitcher performance and remove host limit
2026-01-21 15:53:21 +08:00
bincxz
9b9cbb6068 fix: improve QuickSwitcher performance and remove host limit
- Remove 8-host limit to display all hosts in QuickSwitcher
- Move isMac detection to module level (computed once)
- Memoize orphanSessions with useMemo
- Memoize flatItems and build itemIndexMap for O(1) lookup
- Extract HostItem as a memo component to prevent re-renders
- Wrap getHotkeyLabel with useCallback

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:44:31 +08:00
陈大猫
940e49d2db Merge pull request #109 from binaricat/feat/session-logs-export
feat: add session logs export and auto-save functionality
2026-01-21 15:28:04 +08:00
bincxz
bb25285349 fix: escape HTML in session log export to prevent XSS
- Add escapeHtml function to sanitize special characters
- Split text on ANSI sequences and escape only text parts
- Escape hostLabel and dateStr in HTML template

Addresses code review feedback on PR #109.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:26:32 +08:00
bincxz
be44f38911 fix: prevent syncWithBackend spam when opening Settings
- Move autoStartExecutedRef assignment before async work to ensure
  effect only runs once regardless of auto-start rules
- Memoize keys array in App.tsx to prevent new references on each render

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:20:12 +08:00
bincxz
4749bef906 feat: add session logs export and auto-save functionality
- Add manual export button in LogView to save session logs to file
- Add auto-save settings in System tab to automatically save logs when sessions end
- Support three formats: plain text (.txt), raw ANSI (.log), and HTML (.html)
- Logs are organized by host in subdirectories with timestamp filenames
- Add i18n support for English and Chinese

Closes #104

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:50:07 +08:00
陈大猫
797c607b0a Merge pull request #107 from binaricat/copilot/add-font-setting-option
Add UI font customization in Appearance settings
2026-01-21 13:56:52 +08:00
bincxz
7e3e4ce3b8 Ensures UI font family applies after loading
Introduces a `useUIFontsLoaded` hook to track the loading status of UI fonts.

Updates the `useLayoutEffect` responsible for applying the UI font family to re-run once fonts are loaded. This ensures that local fonts, whose family properties might only be correctly resolved after loading, are applied accurately to the UI.

Also updates local Claude settings to allow GitHub CLI commands for development convenience.
2026-01-21 13:55:30 +08:00
bincxz
929d7dbe74 Enhances UI font management and connection logs
Refactors UI font handling to support dynamic font loading and improves selection in settings.

Implements "Load More" pagination for connection logs, enhancing performance and user experience with large datasets. Adds lazy loading for the Connection Logs Manager.

Addresses an issue in `usePortForwardingState` to prevent duplicate backend sync calls in React StrictMode. Improves IPC communication robustness by checking window destruction status. Refines the visual presentation of the snippets empty state.
2026-01-21 12:23:21 +08:00
copilot-swe-agent[bot]
33313f71fb feat: add UI font customization in Appearance settings
- Add UI fonts configuration with 21 font options
- Add state management for UI font preference
- Add i18n translations (English/Chinese)
- Apply font via CSS variable --font-sans
- Support cross-window sync via IPC/localStorage

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-21 03:33:47 +00:00
copilot-swe-agent[bot]
266e9f637c Initial plan 2026-01-21 03:18:47 +00:00
陈大猫
4d03c469e8 Merge pull request #106 from binaricat/feature/decoration-based-keyword-highlight
refactor: use xterm Decoration API for keyword highlighting
2026-01-21 11:15:08 +08:00
bincxz
7846c5e046 perf: pre-compile regex patterns for keyword highlighting
Address reviewer feedback: avoid creating new RegExp objects on every
viewport refresh. Now patterns are compiled once in setRules() and
reused across refreshes.

Changes:
- Add CompiledRule interface with pre-compiled regex + color
- Move regex compilation from refreshViewport() to setRules()
- Reset regex.lastIndex before each line scan for correct reuse
- Remove try/catch from hot path (errors caught at compile time)

This reduces CPU/GC pressure especially with many rules or high-frequency
terminal output.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:11:45 +08:00
bincxz
a03df92dd1 fix: map string indices to cell columns for wide character support
Address review feedback: regex match indices are JS string positions,
but xterm Decoration API expects terminal cell columns. This caused
highlight misalignment on lines with tabs, CJK characters, or emoji.

Add buildStringToCellMap() helper that iterates buffer cells to build
a mapping from string index to cell column, correctly handling:
- Wide characters (CJK, emoji): 1 char = 2 cells
- Combining characters: multiple chars = 1 cell
- Regular ASCII: 1 char = 1 cell

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:08:55 +08:00
bincxz
a855323912 refactor: use xterm Decoration API for keyword highlighting
Replace ANSI escape sequence-based text processing with xterm.js's
Decoration API for keyword highlighting. This "lazy" approach only
processes visible viewport rows and uses debounced refresh, ensuring
zero impact on scrolling performance.

Key changes:
- New KeywordHighlighter class using IDecoration/IMarker APIs
- Decorations refresh on scroll, write, and resize events
- Skip highlighting in alternate buffer (vim, htop, etc.)
- Add configurable debounce delay (200ms default)
- Remove inline ANSI color injection from data stream

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:00:34 +08:00
陈大猫
1dbda5bec3 Merge pull request #103 from binaricat/fix/skip-encrypted-default-key
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: skip encrypted default SSH keys to allow password auth fallback
2026-01-21 02:03:14 +08:00
bincxz
2da63c0180 fix: also detect PKCS#8 encrypted keys
Add check for -----BEGIN ENCRYPTED PRIVATE KEY----- format to cover
PKCS#8 encrypted keys that were missed in the initial implementation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 02:01:35 +08:00
bincxz
2af9cfccb3 fix: skip encrypted default SSH keys to allow password auth fallback
When no auth method is configured, the default key fallback now detects
and skips passphrase-protected keys. This allows password/keyboard-interactive
authentication to proceed instead of failing immediately with encrypted keys.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 01:55:22 +08:00
陈大猫
ffb736eeea Merge pull request #102 from binaricat/feature/ssh-default-key-and-agent-check
feat: add default SSH key fallback and Windows SSH Agent status check
2026-01-21 01:45:36 +08:00
bincxz
83cd65ef63 feat: add default SSH key fallback and Windows SSH Agent status check
- Auto-discover and use default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa) when no credentials configured
- Add Windows SSH Agent service status detection with UI warning
- Remove file extension filter on key import to allow any file type
- Unify Host Details panel card styles with consistent icons and titles

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 01:43:05 +08:00
陈大猫
e46046081a Merge pull request #101 from binaricat/copilot/fix-sftp-file-transfer-issue 2026-01-21 01:15:03 +08:00
bincxz
7f75fadb31 Optimizes SFTP transfers and folder processing
Enhances SFTP file upload performance and responsiveness by:
- Throttling UI progress updates with `requestAnimationFrame` to reduce re-renders.
- Optimizing Electron-side binary writes with larger chunk sizes, `subarray` for buffers, and efficient buffer handling.
- Implementing IPC progress event throttling to reduce communication overhead between main and renderer processes.
- Speeding up folder processing by traversing directory entries in parallel batches, with yielding to maintain UI responsiveness.

Improves robustness by:
- Ensuring folder uploads stop immediately upon user cancellation.
- Preventing division-by-zero errors in the progress bar calculation.

Also updates Claude settings to allow `npm run build:*` commands.
2026-01-21 01:14:35 +08:00
TachibanaLolo
1958648f63 Fix ssh2 patch file to exclude build artifacts 2026-01-21 01:10:26 +08:00
TachibanaLolo
e830b9362a Add GB18030 filename encoding support 2026-01-21 00:58:13 +08:00
copilot-swe-agent[bot]
2997ed6b3c Add logging for stream destruction errors during upload cancellation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 16:24:55 +00:00
copilot-swe-agent[bot]
2b03db1142 Fix SFTP folder upload progress bar and cancel functionality
- Add byte-level progress tracking for current file upload
- Add cancellation support that aborts the current file transfer
- Update UI to show real-time progress bar with bytes/speed
- Add cancelSftpUpload IPC handler for immediate cancellation

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 16:22:24 +00:00
copilot-swe-agent[bot]
513309ba7c Initial plan 2026-01-20 16:09:49 +00:00
bincxz
5918f91132 Improves 2FA and SSH authentication handling
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
Enhances keyboard-interactive (2FA/MFA) authentication by implementing a queue-based system, allowing multiple concurrent requests to be processed sequentially.

Previously, password prompts during keyboard-interactive authentication were auto-filled if a saved password was available. This change removes the auto-fill behavior to prevent issues with custom or ambiguous prompt texts, instead providing a user-initiated "Use saved password" option in the UI.

Increases the connection timeout to 120 seconds to provide ample time for users to complete 2FA challenges. A new UI indicator shows when additional 2FA requests are pending.

Also, refines SSH authentication logic to strictly respect explicit password authentication, preventing unintended attempts to use private keys when password authentication is selected.
2026-01-20 17:59:42 +08:00
陈大猫
7347b04461 Merge pull request #98 from binaricat:copilot/add-ioskeleymono-font-support
Add Ioskeley Mono font support
2026-01-20 17:16:29 +08:00
copilot-swe-agent[bot]
d8990dd4b1 Add Ioskeley Mono font support to terminal fonts configuration
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 09:04:09 +00:00
copilot-swe-agent[bot]
538dd71084 Initial plan 2026-01-20 08:58:32 +00:00
陈大猫
c43f485bee Merge pull request #96 from AkarinServer/pr/sftp-sudo
Add SFTP sudo mode support and fix sudo handshake
2026-01-20 16:57:31 +08:00
bincxz
839cce58ac Enhances SFTP Sudo usability and diagnostics
Adds client-side warnings to alert users when SFTP Sudo is enabled but a password is not configured, particularly for key-based authentication. This helps prevent connection issues by prompting users to address the missing password proactively.

Improves server-side error messages for SFTP Sudo failures, providing more specific diagnostic information for issues such as platform unavailability, handshake timeouts, and various exit codes (e.g., incorrect password, missing sftp-server, TTY requirement). This makes troubleshooting connection problems more effective.
2026-01-20 16:55:34 +08:00
TachibanaLolo
1324bf95cb Add SFTP sudo mode support and fix handshake 2026-01-20 15:27:44 +08:00
117 changed files with 17511 additions and 9009 deletions

View File

@@ -2,7 +2,18 @@
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
"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\\)\")"
]
}
}

69
App.tsx
View File

@@ -8,6 +8,7 @@ import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
@@ -28,6 +29,7 @@ import type { TerminalLayer as TerminalLayerComponent } from './components/Termi
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
// Visibility container for VaultView - isolates isActive subscription
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -151,8 +153,8 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication state (2FA/MFA)
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
const {
theme,
@@ -167,6 +169,9 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
} = settings;
const {
@@ -288,10 +293,16 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
[keys]
);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys: portForwardingKeys,
});
// Keyboard-interactive authentication (2FA/MFA) event listener
@@ -301,13 +312,15 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onKeyboardInteractive((request) => {
console.log('[App] Keyboard-interactive request received:', request);
setKeyboardInteractiveRequest({
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
});
savedPassword: request.savedPassword,
}]);
});
return () => {
@@ -321,7 +334,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle keyboard-interactive cancel
@@ -330,7 +344,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
@@ -610,7 +625,7 @@ function App({ settings }: { settings: SettingsState }) {
(h.group || '').toLowerCase().includes(term)
)
: hosts;
return filtered.slice(0, 8);
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
@@ -661,7 +676,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
@@ -679,7 +694,7 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -747,10 +762,32 @@ function App({ settings }: { settings: SettingsState }) {
terminalData: data,
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog]);
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -1032,12 +1069,18 @@ function App({ settings }: { settings: SettingsState }) {
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveRequest}
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
</div>
);
}

View File

@@ -77,6 +77,24 @@ const en: Messages = {
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
'settings.sessionLogs.autoSave': 'Auto-Save',
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
'settings.sessionLogs.directory': 'Save Directory',
'settings.sessionLogs.noDirectory': 'No directory selected',
'settings.sessionLogs.browse': 'Browse',
'settings.sessionLogs.openFolder': 'Open folder',
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
'settings.sessionLogs.format': 'Log Format',
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
@@ -119,6 +137,8 @@ const en: Messages = {
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
@@ -201,6 +221,18 @@ const en: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -431,6 +463,10 @@ const en: Messages = {
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.goUp': 'Go up',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
@@ -441,6 +477,7 @@ const en: Messages = {
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.error.loadFailed': 'Failed to load directory',
@@ -553,6 +590,7 @@ const en: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
@@ -610,6 +648,12 @@ const en: Messages = {
'hostDetails.section.address': 'Address',
'hostDetails.hostname.placeholder': 'IP or Hostname',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -618,6 +662,7 @@ const en: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
@@ -630,6 +675,9 @@ const en: Messages = {
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -719,7 +767,7 @@ const en: Messages = {
'logs.empty.title': 'No Connection Logs',
'logs.empty.desc':
'Your connection history will appear here when you connect to hosts or open local terminals.',
'logs.showing': 'Showing {limit} of {total} logs.',
'logs.loadMore': 'Load {count} more logs',
'logs.ongoing': 'ongoing',
'logs.localTerminal': 'Local Terminal',
'logs.action.save': 'Save',
@@ -730,6 +778,7 @@ const en: Messages = {
'logView.customizeAppearance': 'Customize appearance',
'logView.appearance': 'Appearance',
'logView.readOnly': 'Read-only',
'logView.export': 'Export',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
@@ -747,6 +796,20 @@ const en: Messages = {
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.closeSession': 'Close session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
@@ -1139,6 +1202,10 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
};
export default en;

View File

@@ -65,6 +65,24 @@ const zhCN: Messages = {
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
'settings.sessionLogs.autoSave': '自动保存',
'settings.sessionLogs.enableAutoSave': '启用自动保存',
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
'settings.sessionLogs.directory': '保存目录',
'settings.sessionLogs.noDirectory': '未选择目录',
'settings.sessionLogs.browse': '浏览',
'settings.sessionLogs.openFolder': '打开文件夹',
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
'settings.sessionLogs.format': '日志格式',
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
'settings.application.reportProblem': '反馈问题',
@@ -106,6 +124,8 @@ const zhCN: Messages = {
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
// Context menus / common actions
'action.newHost': '新建主机',
@@ -296,6 +316,10 @@ const zhCN: Messages = {
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.goUp': '上一级',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
@@ -306,6 +330,7 @@ const zhCN: Messages = {
'sftp.rename.newName': '新名称',
'sftp.rename.placeholder': '输入新名称',
'sftp.confirm.deleteOne': '删除 "{name}"',
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.error.loadFailed': '加载目录失败',
@@ -370,6 +395,12 @@ const zhCN: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.sftp.encoding': '文件名编码',
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -378,6 +409,7 @@ const zhCN: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
@@ -390,6 +422,9 @@ const zhCN: Messages = {
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -450,7 +485,7 @@ const zhCN: Messages = {
'logs.table.saved': '收藏',
'logs.empty.title': '暂无连接日志',
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
'logs.showing': '显示 {limit}/{total} 条日志。',
'logs.loadMore': '加载更多 ({count} 条)',
'logs.ongoing': '进行中',
'logs.localTerminal': '本地终端',
'logs.action.save': '收藏',
@@ -461,6 +496,7 @@ const zhCN: Messages = {
'logView.customizeAppearance': '自定义外观',
'logView.appearance': '外观',
'logView.readOnly': '只读',
'logView.export': '导出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
@@ -478,6 +514,20 @@ const zhCN: Messages = {
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
@@ -792,6 +842,7 @@ const zhCN: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
@@ -886,6 +937,18 @@ const zhCN: Messages = {
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -1128,6 +1191,10 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
};
export default zhCN;

View File

@@ -0,0 +1,34 @@
export const isSessionError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
/**
* Check if an error message indicates a fatal error that should stop the entire upload.
* This includes session errors AND target directory deletion errors.
*/
export const isFatalUploadError = (errorMessage: string): boolean => {
const msg = errorMessage.toLowerCase();
return (
// Session-related errors
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("connection") ||
msg.includes("disconnected") ||
// Target directory was deleted during upload
msg.includes("no such file") ||
msg.includes("enoent") ||
msg.includes("does not exist") ||
msg.includes("write stream error") ||
// Directory was removed
msg.includes("directory not found") ||
msg.includes("not a directory")
);
};

View File

@@ -0,0 +1,454 @@
import { SftpFileEntry } from "../../../domain/models";
import { formatDate } from "./utils";
// Mock local file data for development (when backend is not available)
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
const mockData: Record<string, SftpFileEntry[]> = {
// Unix-style paths
"/": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Applications",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "System",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Shared",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
// Windows-style paths (normalized to forward slashes for matching)
"C:": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Program Files",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Windows",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Public",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Default",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
],
"C:/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
"C:/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"C:/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
};
return mockData[normPath] || [];
}

View File

@@ -0,0 +1,55 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
connection: SftpConnection | null;
files: SftpFileEntry[];
loading: boolean;
reconnecting: boolean;
error: string | null;
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
activeTabId: string | null;
}
// Constants for empty placeholder pane IDs
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (id?: string): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
});
// File watch event types
export interface FileWatchSyncedEvent {
watchId: string;
localPath: string;
remotePath: string;
bytesWritten: number;
}
export interface FileWatchErrorEvent {
watchId: string;
localPath: string;
remotePath: string;
error: string;
}
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
}

View File

@@ -0,0 +1,427 @@
import { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
rightTabs: { tabs: SftpPane[] };
leftPane: SftpPane;
rightPane: SftpPane;
setLeftTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
setRightTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (prev: SftpPane) => SftpPane) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string) => SftpPane;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
}
export const useSftpConnections = ({
hosts,
keys,
identities,
leftTabsRef,
rightTabsRef,
leftTabs,
rightTabs: _rightTabs,
leftPane,
rightPane,
setLeftTabs,
setRightTabs,
getActivePane,
updateTab,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
} else {
activeTabId = sideTabs.activeTabId;
}
if (!activeTabId) return;
const connectionId = `${side}-${Date.now()}`;
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
// This ensures proper auto-detection works and respects host-level encoding settings
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
if (host === "local") {
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) {
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
homeDir,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
loading: true,
reconnecting: false,
error: null,
filenameEncoding, // Reset encoding for new connection
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
files,
timestamp: Date.now(),
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
}
} else {
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
loading: true,
reconnecting: prev.reconnecting,
error: null,
files: prev.reconnecting ? prev.files : [],
filenameEncoding, // Reset encoding for new connection
}));
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("authentication") ||
msg.includes("auth") ||
msg.includes("password") ||
msg.includes("permission denied")
);
};
const hasKey = !!credentials.privateKey;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
}
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
}
: null,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
loading: false,
reconnecting: false,
}));
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
getHostCredentials,
getActivePane,
updateTab,
clearCacheForConnection,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
],
);
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
initialConnectDoneRef.current = true;
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && reconnectingRef.current[side]) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (reconnectingRef.current[side]) {
connect(side, lastHost);
}
}
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
const disconnect = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
if (!pane || !activeTabId) return;
navSeqRef.current[side] += 1;
if (pane.connection) {
clearCacheForConnection(pane.connection.id);
}
reconnectingRef.current[side] = false;
lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],
);
return {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -0,0 +1,61 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
return getMockLocalFiles(path);
}
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
};
});
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
if (!rawFiles) return [];
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[],
);
return {
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -0,0 +1,430 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
// Re-export UploadResult for external usage
export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
dismissExternalUpload?: (taskId: string) => void;
}
interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const readBinaryFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
return await bridge.readLocalFile(filePath);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) {
throw new Error("Binary file reading not supported");
}
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const writeTextFile = useCallback(
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return;
}
throw new Error("Local file writing not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
console.log("[SFTP] File downloaded to temp", { localTempPath });
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
}
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef],
);
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
if (addExternalUpload) {
const scanningTask: TransferTask = {
id: taskId,
fileName: "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
};
addExternalUpload(scanningTask);
}
},
onScanningEnd: (taskId: string) => {
if (dismissExternalUpload) {
dismissExternalUpload(taskId);
}
},
onTaskCreated: (task: UploadTaskInfo) => {
if (addExternalUpload) {
const transferTask: TransferTask = {
id: task.id,
fileName: task.displayName,
sourcePath: "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
};
addExternalUpload(transferTask);
}
},
onTaskProgress: (taskId: string, progress) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
transferredBytes: progress.transferred,
speed: progress.speed,
});
}
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
});
}
},
onTaskFailed: (taskId: string, error: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
}
},
onTaskCancelled: (taskId: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
}
},
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
// NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError)
writeSftpBinaryWithProgress: bridge?.writeSftpBinaryWithProgress
? async (sftpId, path, data, taskId, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.writeSftpBinaryWithProgress) return undefined;
// Pass undefined for encoding to use session default, and forward callbacks
return b.writeSftpBinaryWithProgress(
sftpId,
path,
data,
taskId,
undefined, // encoding - use session default
onProgress,
onComplete,
onError
);
}
: undefined,
cancelSftpUpload: bridge?.cancelSftpUpload,
};
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
try {
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: pane.connection.currentPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await refresh(side);
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
);
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
}
}, []);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return null;
}
return await bridge.selectApplication();
},
[],
);
return {
readTextFile,
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
cancelExternalUpload,
selectApplication,
};
};

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
});
return () => {
try {
unsubscribeSynced?.();
unsubscribeError?.();
} catch {
// ignore cleanup errors
}
};
}, [options]);
};

View File

@@ -0,0 +1,75 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
}
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
};
},
[hosts, identities, keys],
);

View File

@@ -0,0 +1,535 @@
import { useCallback } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
interface UseSftpPaneActionsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
dirCacheTtlMs: number;
}
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
refresh: (side: "left" | "right") => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
clearSelection: (side: "left" | "right") => void;
selectAll: (side: "left" | "right") => void;
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
export const useSftpPaneActions = ({
getActivePane,
updateTab,
updateActiveTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
listRemoteFiles,
handleSessionError,
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const navigateTo = useCallback(
async (
side: "left" | "right",
path: string,
options?: { force?: boolean },
) => {
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
console.log("[SFTP navigateTo] state check", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
activeTabId,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection || !activeTabId) {
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
return;
}
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
if (
cached &&
Date.now() - cached.timestamp < dirCacheTtlMs &&
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
}));
return;
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
try {
let files: SftpFileEntry[];
if (pane.connection.isLocal) {
files = await listLocalFiles(path);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
return;
}
try {
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session expired. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
return;
}
throw err as Error;
}
}
if (navSeqRef.current[side] !== requestId) return;
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files,
loading: false,
selectedFiles: new Set(),
}));
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
}
},
[
getActivePane,
updateTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
makeCacheKey,
dirCacheTtlMs,
listLocalFiles,
listRemoteFiles,
sftpSessionsRef,
clearCacheForConnection,
isSessionError,
],
);
const refresh = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
if (pane?.connection) {
await navigateTo(side, pane.connection.currentPath, { force: true });
} else if (!pane?.connection && pane?.error) {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.reconnecting.title",
}));
} else if (!lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
error: "sftp.error.connectionLostManual",
}));
}
}
},
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
);
const navigateUp = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
await navigateTo(side, parentPath);
}
},
[getActivePane, navigateTo],
);
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
const pane = getActivePane(side);
console.log("[SFTP openEntry] getActivePane result", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection) {
console.log("[SFTP openEntry] No pane or connection, returning early");
return;
}
if (entry.name === "..") {
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
console.log("[SFTP openEntry] Navigating up from '..'", {
currentPath,
isAtRoot,
isWindowsRoot: isWindowsRoot(currentPath),
});
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
await navigateTo(side, parentPath);
} else {
console.log("[SFTP openEntry] Already at root, not navigating");
}
return;
}
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
await navigateTo(side, newPath);
}
},
[getActivePane, navigateTo],
);
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
newSelection.add(name);
}
}
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
}, [updateActiveTab]);
const selectAll = useCallback(
(side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane) return;
updateActiveTab(side, (prev) => ({
...prev,
selectedFiles: new Set(
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
),
}));
},
[getActivePane, updateActiveTab],
);
const setFilter = useCallback((side: "left" | "right", filter: string) => {
updateActiveTab(side, (prev) => ({ ...prev, filter }));
}, [updateActiveTab]);
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
const term = pane.filter.trim().toLowerCase();
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, []);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeLocalFile(fullPath, emptyBuffer);
} else {
throw new Error("Local file writing not supported");
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const bridge = netcattyBridge.get();
if (bridge?.writeSftpBinary) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
} else if (bridge?.writeSftp) {
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
} else {
throw new Error("No write method available");
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) {
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const renameFile = useCallback(
async (side: "left" | "right", oldName: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
filePath: string,
mode: string,
) => {
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) {
logger.warn("Cannot change permissions on local files");
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
try {
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
logger.error("Failed to change permissions:", err);
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
return {
navigateTo,
refresh,
navigateUp,
openEntry,
toggleSelection,
rangeSelect,
clearSelection,
selectAll,
setFilter,
getFilteredFiles,
createDirectory,
createFile,
deleteFiles,
renameFile,
changePermissions,
};
};

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
useEffect(() => {
const sessionsRef = sftpSessionsRef.current;
return () => {
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
};
}, [sftpSessionsRef]);
};

View File

@@ -0,0 +1,78 @@
import { useCallback } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
interface UseSftpSessionErrorsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
updateActiveTab: (
side: "left" | "right",
updater: (prev: SftpPane) => SftpPane,
) => void;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
clearCacheForConnection: (connectionId: string) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
}
export const useSftpSessionErrors = ({
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
}: UseSftpSessionErrorsParams) =>
useCallback(
(side: "left" | "right", _error: Error) => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!pane || !sideTabs.activeTabId) return;
if (pane.connection) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
}
navSeqRef.current[side] += 1;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.error.connectionLostReconnecting",
}));
} else {
updateActiveTab(side, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "sftp.error.sessionLost",
selectedFiles: new Set(),
filter: "",
}));
}
},
[
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
],
);

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
import { logger } from "../../../lib/logger";
export interface SftpTabsState {
leftTabs: SftpSideTabs;
rightTabs: SftpSideTabs;
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
rightTabsRef: React.MutableRefObject<SftpSideTabs>;
setLeftTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
setRightTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
leftPane: SftpPane;
rightPane: SftpPane;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
selectTab: (side: "left" | "right", tabId: string) => void;
reorderTabs: (
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
moveTabToOtherSide: (fromSide: "left" | "right", tabId: string) => void;
getTabsInfo: (side: "left" | "right") => Array<{
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
}>;
getActiveTabId: (side: "left" | "right") => string | null;
}
export const useSftpTabsState = (): SftpTabsState => {
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const [rightTabs, setRightTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const leftTabsRef = useRef(leftTabs);
const rightTabsRef = useRef(rightTabs);
leftTabsRef.current = leftTabs;
rightTabsRef.current = rightTabs;
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return null;
return sideTabs.tabs.find((t) => t.id === sideTabs.activeTabId) || null;
}, []);
const leftPane = useMemo(() => {
const pane = leftTabs.activeTabId
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
}, [leftTabs]);
const rightPane = useMemo(() => {
const pane = rightTabs.activeTabId
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
}, [rightTabs]);
const updateTab = useCallback(
(side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
tabs: prev.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
}));
},
[],
);
const updateActiveTab = useCallback(
(side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return;
updateTab(side, sideTabs.activeTabId, updater);
},
[updateTab],
);
const addTab = useCallback(
(side: "left" | "right") => {
const newPane = createEmptyPane();
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
return newPane.id;
},
[],
);
const closeTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) return prev;
let newActiveTabId: string | null = null;
if (prev.tabs.length > 1) {
if (prev.activeTabId === tabId) {
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
newActiveTabId = prev.tabs[nextIndex]?.id || null;
} else {
newActiveTabId = prev.activeTabId;
}
}
return {
tabs: prev.tabs.filter((t) => t.id !== tabId),
activeTabId: newActiveTabId,
};
});
},
[],
);
const selectTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
activeTabId: tabId,
}));
},
[],
);
const reorderTabs = useCallback(
(
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabs = [...prev.tabs];
const draggedIndex = tabs.findIndex((t) => t.id === draggedId);
const targetIndex = tabs.findIndex((t) => t.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return prev;
const [draggedTab] = tabs.splice(draggedIndex, 1);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
const adjustedIndex = draggedIndex < targetIndex ? insertIndex - 1 : insertIndex;
tabs.splice(adjustedIndex, 0, draggedTab);
return { ...prev, tabs };
});
},
[],
);
const moveTabToOtherSide = useCallback(
(fromSide: "left" | "right", tabId: string) => {
const sourceTabs = fromSide === "left" ? leftTabsRef.current : rightTabsRef.current;
const setSourceTabs = fromSide === "left" ? setLeftTabs : setRightTabs;
const setTargetTabs = fromSide === "left" ? setRightTabs : setLeftTabs;
const tabToMove = sourceTabs.tabs.find((t) => t.id === tabId);
if (!tabToMove) return;
logger.info("[SFTP] Moving tab to other side", {
fromSide,
toSide: fromSide === "left" ? "right" : "left",
tabId,
hostLabel: tabToMove.connection?.hostLabel,
});
setSourceTabs((prev) => {
const newTabs = prev.tabs.filter((t) => t.id !== tabId);
let newActiveTabId: string | null = null;
if (newTabs.length > 0) {
if (prev.activeTabId === tabId) {
newActiveTabId = newTabs[0].id;
} else {
newActiveTabId = prev.activeTabId;
}
}
return { tabs: newTabs, activeTabId: newActiveTabId };
});
setTargetTabs((prev) => ({
tabs: [...prev.tabs, tabToMove],
activeTabId: tabToMove.id,
}));
},
[],
);
const DEFAULT_TAB_LABEL = "New Tab";
const getTabsInfo = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || DEFAULT_TAB_LABEL,
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
}));
},
[],
);
const getActiveTabId = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.activeTabId;
},
[],
);
return {
leftTabs,
rightTabs,
leftTabsRef,
rightTabsRef,
setLeftTabs,
setRightTabs,
leftPane,
rightPane,
getActivePane,
updateTab,
updateActiveTab,
addTab,
closeTab,
selectTab,
reorderTabs,
moveTabToOtherSide,
getTabsInfo,
getActiveTabId,
};
};

View File

@@ -0,0 +1,785 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FileConflict,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
TransferTask,
} from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { joinPath } from "./utils";
interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
}
interface UseSftpTransfersResult {
transfers: TransferTask[];
conflicts: FileConflict[];
activeTransfersCount: number;
startTransfer: (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
) => Promise<void>;
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
}
export const useSftpTransfers = ({
getActivePane,
refresh,
sftpSessionsRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
}: UseSftpTransfersParams): UseSftpTransfersResult => {
const [transfers, setTransfers] = useState<TransferTask[]>([]);
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
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());
useEffect(() => {
const intervalsRef = progressIntervalsRef.current;
return () => {
intervalsRef.forEach((interval) => {
clearInterval(interval);
});
intervalsRef.clear();
};
}, []);
const startProgressSimulation = useCallback(
(taskId: string, estimatedBytes: number) => {
const existing = progressIntervalsRef.current.get(taskId);
if (existing) clearInterval(existing);
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
const variability = 0.3;
let transferred = 0;
const interval = setInterval(() => {
const speedFactor = 1 + (Math.random() - 0.5) * variability;
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
transferred = Math.min(transferred + chunkSize, estimatedBytes);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId || t.status !== "transferring") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: estimatedBytes,
speed: chunkSize * 10,
};
}),
);
if (transferred >= estimatedBytes * 0.95) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, 100);
progressIntervalsRef.current.set(taskId, interval);
},
[],
);
const stopProgressSimulation = useCallback((taskId: string) => {
const interval = progressIntervalsRef.current.get(taskId);
if (interval) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, []);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: total || t.totalBytes,
speed,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
let content: ArrayBuffer | string;
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
}
} else {
throw new Error("No source connection");
}
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
targetEncoding,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
}
} else {
throw new Error("No target connection");
}
};
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (targetIsLocal) {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
}
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
} else {
throw new Error("No source connection");
}
for (const file of files) {
if (file.name === "..") continue;
// Check if root task was cancelled during iteration
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const childTask: TransferTask = {
...task,
id: crypto.randomUUID(),
fileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: file.type === "directory",
parentTaskId: task.id,
};
if (file.type === "directory") {
await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
);
} else {
await transferFile(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
);
}
}
};
const processTransfer = async (
task: TransferTask,
sourcePane: SftpPane,
targetPane: SftpPane,
targetSide: "left" | "right",
) => {
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
);
};
// Initialize encoding early to avoid temporal dead zone issues
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection?.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const targetEncoding: SftpFilenameEncoding = targetPane.connection?.isLocal
? "auto"
: targetPane.filenameEncoding || "auto";
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) actualFileSize = stat.size;
}
} catch {
// Ignore stat errors
}
}
const estimatedSize =
actualFileSize > 0
? actualFileSize
: task.isDirectory
? 1024 * 1024
: 256 * 1024;
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
const targetSftpId = targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
const sourceSide = targetSide === "left" ? "right" : "left";
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
throw new Error("Source SFTP session not found");
}
if (!targetPane.connection?.isLocal && !targetSftpId) {
handleSessionError(targetSide, new Error("Target SFTP session lost"));
throw new Error("Target SFTP session not found");
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer || task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
try {
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
let sourceStat: { size: number; mtime: number } | null = null;
try {
if (sourcePane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
try {
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
if (targetExists && existingStat) {
stopProgressSimulation(task.id);
const newConflict: FileConflict = {
transferId: task.id,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
existingSize: existingStat.size,
newSize: sourceStat?.size || estimatedSize,
existingModified: existingStat.mtime,
newModified: sourceStat?.mtime || Date.now(),
};
setConflicts((prev) => [...prev, newConflict]);
updateTask({
status: "pending",
totalBytes: sourceStat?.size || estimatedSize,
});
return;
}
}
if (task.isDirectory) {
await transferDirectory(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
);
} else {
await transferFile(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
);
}
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
return {
...t,
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: t.totalBytes,
speed: 0,
};
}),
);
await refresh(targetSide);
} catch (err) {
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
// Check if this was a cancellation
const isCancelled = cancelledTasksRef.current.has(task.id) ||
(err instanceof Error && err.message === "Transfer cancelled");
if (isCancelled) {
// Don't update status - cancelTransfer already set it to cancelled
return;
}
updateTask({
status: "failed",
error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(),
speed: 0,
});
}
};
const startTransfer = useCallback(
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
) => {
const sourcePane = getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
if (!sourcePane?.connection || !targetPane?.connection) return;
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const sourcePath = sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection.id);
const newTasks: TransferTask[] = [];
for (const file of sourceFiles) {
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
? "upload"
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
? "download"
: "remote-to-remote";
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
// ignore
}
}
newTasks.push({
id: crypto.randomUUID(),
fileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
targetPath: joinPath(targetPath, file.name),
sourceConnectionId: sourcePane.connection!.id,
targetConnectionId: targetPane.connection!.id,
direction,
status: "pending" as TransferStatus,
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: file.isDirectory,
});
}
setTransfers((prev) => [...prev, ...newTasks]);
for (const task of newTasks) {
await processTransfer(task, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, sftpSessionsRef],
);
const cancelTransfer = useCallback(
async (transferId: string) => {
// Add to cancelled set so async operations can check
cancelledTasksRef.current.add(transferId);
stopProgressSimulation(transferId);
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? {
...t,
status: "cancelled" as TransferStatus,
endTime: Date.now(),
}
: t,
),
);
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
try {
await netcattyBridge.get()!.cancelTransfer!(transferId);
} catch (err) {
logger.warn("Failed to cancel transfer at backend:", err);
}
}
// Clean up cancelled task ID after a delay to ensure all async ops see it
setTimeout(() => {
cancelledTasksRef.current.delete(transferId);
}, 5000);
},
[stopProgressSimulation],
);
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
const sourcePane = getActivePane(sourceSide as "left" | "right");
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
: t,
),
);
await processTransfer(task, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[transfers, getActivePane],
);
const clearCompletedTransfers = useCallback(() => {
setTransfers((prev) =>
prev.filter((t) => t.status !== "completed" && t.status !== "cancelled"),
);
}, []);
const dismissTransfer = useCallback((transferId: string) => {
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
const addExternalUpload = useCallback((task: TransferTask) => {
// Filter out any pending scanning tasks before adding the new task.
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
// (due to React state batching), the scanning placeholder will still be removed.
setTransfers((prev) => [
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
task
]);
}, []);
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
);
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
const conflict = conflicts.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfers.find((t) => t.id === conflictId);
if (!task) return;
if (action === "skip") {
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
: t,
),
);
return;
}
let updatedTask = { ...task };
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = task.targetPath.replace(task.fileName, newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
const sourceSide = updatedTask.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = updatedTask.targetConnectionId.startsWith("left") ? "left" : "right";
const sourcePane = getActivePane(sourceSide as "left" | "right");
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
setTimeout(async () => {
await processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[conflicts, transfers, getActivePane],
);
const activeTransfersCount = useMemo(() => transfers.filter(
(t) => t.status === "pending" || t.status === "transferring",
).length, [transfers]);
return {
transfers,
conflicts,
activeTransfersCount,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
};
};

View File

@@ -0,0 +1,94 @@
import { SftpFileEntry } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
};
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
export const getFileExtension = (name: string): string => {
if (name === "..") return "folder";
const ext = name.split(".").pop()?.toLowerCase();
return ext || "file";
};
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
};
// Check if path is Windows-style
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const isWindowsRoot = (path: string): boolean => {
if (!isWindowsPath(path)) return false;
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
};
export const joinPath = (base: string, name: string): string => {
if (isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const getParentPath = (path: string): string => {
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
if (isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
console.log("[SFTP getParentPath] Windows result", { result });
return result;
}
if (path === "/") {
console.log("[SFTP getParentPath] Unix root, returning /");
return "/";
}
const parts = path.split("/").filter(Boolean);
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
parts.pop();
const result = parts.length ? `/${parts.join("/")}` : "/";
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
return result;
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";
};

View File

@@ -0,0 +1,207 @@
import { useSyncExternalStore } from 'react';
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
/**
* UI Font Store - singleton pattern using useSyncExternalStore
* Fetches system fonts and combines with bundled fonts
*/
type Listener = () => void;
interface UIFontStoreState {
availableFonts: UIFont[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
/**
* Type definition for Local Font Access API
*/
interface LocalFontData {
family: string;
}
class UIFontStore {
private state: UIFontStoreState = {
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
private notify = () => {
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<UIFontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
initialize = async (): Promise<void> => {
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
const localFonts = await this.getLocalFonts();
// Use a Map to deduplicate by normalized font name
const fontMap = new Map<string, UIFont>();
// Add bundled fonts first (they have priority)
UI_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace
localFonts.forEach(font => {
const localId = `local-${font.id}`;
// Skip if a bundled font with similar name exists
if (!fontMap.has(font.id)) {
fontMap.set(localId, { ...font, id: localId });
}
});
this.setState({
availableFonts: Array.from(fontMap.values()),
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local UI fonts, using defaults:', error);
this.setState({
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
private async getLocalFonts(): Promise<UIFont[]> {
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Deduplicate by family name
const uniqueFamilies = new Set<string>();
const dedupedFonts = fonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
return true;
});
// Map to UIFont structure
return dedupedFonts.map(f => ({
id: f.family.toLowerCase().replace(/\s+/g, '-'),
name: f.family,
family: withUiCjkFallback(`"${f.family}", system-ui`),
}));
} catch (error) {
console.warn('Failed to query local fonts:', error);
return [];
}
}
getFontById = (fontId: string): UIFont => {
const fonts = this.state.availableFonts;
const found = fonts.find(f => f.id === fontId);
if (found) return found;
// For local fonts that haven't been loaded yet, construct a fallback
// This handles the case when main window receives a local font ID before fonts are loaded
if (fontId.startsWith('local-')) {
const fontName = fontId
.replace(/^local-/, '')
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
id: fontId,
name: fontName,
family: withUiCjkFallback(`"${fontName}", system-ui`),
};
}
return fonts[0] || UI_FONTS[0];
};
}
// Singleton instance
export const uiFontStore = new UIFontStore();
/**
* Get available UI fonts - triggers initialization on first use
*/
export const useAvailableUIFonts = (): UIFont[] => {
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
uiFontStore.initialize();
}
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getAvailableFonts
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
export const useUIFontsLoaded = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoaded
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
export const initializeUIFonts = (): void => {
uiFontStore.initialize();
};

View File

@@ -7,6 +7,12 @@ export type ApplicationInfo = {
platform: string;
};
export type SshAgentStatus = {
running: boolean;
startupType: string | null;
error: string | null;
};
export const useApplicationBackend = () => {
const openExternal = useCallback(async (url: string) => {
try {
@@ -27,6 +33,12 @@ export const useApplicationBackend = () => {
return info ?? null;
}, []);
return { openExternal, getApplicationInfo };
const checkSshAgent = useCallback(async (): Promise<SshAgentStatus | null> => {
const bridge = netcattyBridge.get();
const status = await bridge?.checkSshAgent?.();
return status ?? null;
}, []);
return { openExternal, getApplicationInfo, checkSshAgent };
};

View File

@@ -76,10 +76,14 @@ export const usePortForwardingAutoStart = ({
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
const runAutoStart = async () => {
// First sync with backend to get any active tunnels
await syncWithBackend();
// Load rules from storage
const rules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
@@ -95,8 +99,6 @@ export const usePortForwardingAutoStart = ({
});
if (autoStartRules.length === 0) return;
autoStartExecutedRef.current = true;
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
// Start each auto-start rule

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
@@ -76,6 +76,9 @@ 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);
@@ -84,9 +87,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
// Load rules from storage on mount and sync with backend
useEffect(() => {
const loadAndSync = async () => {
// First, sync with backend to get any active tunnels
await syncWithBackend();
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
if (!syncExecutedRef.current) {
syncExecutedRef.current = true;
await syncWithBackend();
}
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);

View File

@@ -1,5 +1,5 @@
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage } from '../../domain/models';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -16,14 +16,20 @@ STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -44,6 +50,10 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
if (!raw) return null;
@@ -70,6 +80,14 @@ const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
return list.some((preset) => preset.id === value);
};
const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
@@ -112,6 +130,7 @@ const applyThemeTokens = (
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
@@ -134,6 +153,10 @@ export const useSettingsState = () => {
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
});
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
@@ -174,6 +197,20 @@ export const useSettingsState = () => {
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
});
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
});
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
try {
@@ -233,6 +270,15 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
// Re-run when fonts finish loading to get correct family for local fonts
useLayoutEffect(() => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -257,6 +303,11 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
@@ -343,6 +394,11 @@ export const useSettingsState = () => {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
@@ -415,7 +471,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -484,6 +540,22 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -564,6 +636,8 @@ export const useSettingsState = () => {
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
syncConfig,
updateSyncConfig,
uiLanguage,
@@ -597,5 +671,12 @@ export const useSettingsState = () => {
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
};
};

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { RemoteFile } from "../../types";
import type { RemoteFile, SftpFilenameEncoding } from "../../types";
export const useSftpBackend = () => {
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
@@ -15,34 +15,34 @@ export const useSftpBackend = () => {
return bridge.closeSftp(sftpId);
}, []);
const listSftp = useCallback(async (sftpId: string, path: string) => {
const listSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
return bridge.listSftp(sftpId, path);
return bridge.listSftp(sftpId, path, encoding);
}, []);
const readSftp = useCallback(async (sftpId: string, path: string) => {
const readSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
return bridge.readSftp(sftpId, path);
return bridge.readSftp(sftpId, path, encoding);
}, []);
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
const readSftpBinary = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
return bridge.readSftpBinary(sftpId, path);
return bridge.readSftpBinary(sftpId, path, encoding);
}, []);
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
const writeSftp = useCallback(async (sftpId: string, path: string, content: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
return bridge.writeSftp(sftpId, path, content);
return bridge.writeSftp(sftpId, path, content, encoding);
}, []);
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
return bridge.writeSftpBinary(sftpId, path, content);
return bridge.writeSftpBinary(sftpId, path, content, encoding);
}, []);
const writeSftpBinaryWithProgress = useCallback(
@@ -51,6 +51,7 @@ export const useSftpBackend = () => {
path: string,
content: ArrayBuffer,
transferId: string,
encoding?: SftpFilenameEncoding,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
@@ -62,6 +63,7 @@ export const useSftpBackend = () => {
path,
content,
transferId,
encoding,
onProgress,
onComplete,
onError,
@@ -70,34 +72,34 @@ export const useSftpBackend = () => {
[],
);
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
const mkdirSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
return bridge.mkdirSftp(sftpId, path);
return bridge.mkdirSftp(sftpId, path, encoding);
}, []);
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
const deleteSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
return bridge.deleteSftp(sftpId, path);
return bridge.deleteSftp(sftpId, path, encoding);
}, []);
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
return bridge.renameSftp(sftpId, oldPath, newPath);
return bridge.renameSftp(sftpId, oldPath, newPath, encoding);
}, []);
const statSftp = useCallback(async (sftpId: string, path: string) => {
const statSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
return bridge.statSftp(sftpId, path);
return bridge.statSftp(sftpId, path, encoding);
}, []);
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
return bridge.chmodSftp(sftpId, path, mode);
return bridge.chmodSftp(sftpId, path, mode, encoding);
}, []);
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
@@ -168,6 +170,12 @@ export const useSftpBackend = () => {
return bridge.cancelTransfer(transferId);
}, []);
const cancelSftpUpload = useCallback(async (transferId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.cancelSftpUpload) return undefined;
return bridge.cancelSftpUpload(transferId);
}, []);
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTransferProgress) return undefined;
@@ -185,7 +193,7 @@ export const useSftpBackend = () => {
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
options?: { enableWatch?: boolean; encoding?: SftpFilenameEncoding }
): Promise<{ localTempPath: string; watchId?: string }> => {
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
@@ -194,7 +202,7 @@ export const useSftpBackend = () => {
// Download the file to temp
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
@@ -217,7 +225,7 @@ export const useSftpBackend = () => {
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
@@ -257,9 +265,9 @@ export const useSftpBackend = () => {
startStreamTransfer,
cancelTransfer,
cancelSftpUpload,
onTransferProgress,
selectApplication,
downloadSftpToTempAndOpen,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,12 @@ export const useTerminalBackend = () => {
return bridge.getSessionPwd(sessionId);
}, []);
const getServerStats = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
@@ -138,6 +144,7 @@ export const useTerminalBackend = () => {
listSerialPorts,
execCommand,
getSessionPwd,
getServerStats,
writeToSession,
resizeSession,
closeSession,

View File

@@ -7,7 +7,7 @@ import {
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
@@ -149,7 +149,11 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
onOpenLogView,
}) => {
const { t } = useI18n();
const RENDER_LIMIT = 100;
const INITIAL_RENDER_LIMIT = 30;
const LOAD_MORE_COUNT = 30;
// Track how many items to show
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
// Sort logs by newest first
const filteredLogs = useMemo(() => {
@@ -157,10 +161,14 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
}, [logs]);
const displayedLogs = useMemo(() => {
return filteredLogs.slice(0, RENDER_LIMIT);
}, [filteredLogs]);
return filteredLogs.slice(0, renderLimit);
}, [filteredLogs, renderLimit]);
const hasMore = filteredLogs.length > RENDER_LIMIT;
const hasMore = filteredLogs.length > renderLimit;
const handleLoadMore = useCallback(() => {
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
}, []);
const handleToggleSaved = useCallback(
(id: string) => onToggleSaved(id),
@@ -222,9 +230,12 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
<>
{renderedItems}
{hasMore && (
<div className="text-center py-4 text-sm text-muted-foreground">
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
</div>
<button
onClick={handleLoadMore}
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
>
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
</button>
)}
</>
)}

View File

@@ -1,20 +1,29 @@
import {
AlertTriangle,
Check,
ChevronDown,
FolderLock,
FolderPlus,
Forward,
Globe,
Key,
KeyRound,
Link2,
MapPin,
Palette,
Plus,
Settings2,
Shield,
Tag,
TerminalSquare,
User,
Variable,
Wifi,
X,
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
@@ -28,11 +37,13 @@ import {
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
// Import host-details sub-panels
import {
@@ -80,6 +91,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateTag,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -115,6 +127,22 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
// SSH Agent status for Windows (only checked when agentForwarding is enabled)
const [sshAgentStatus, setSshAgentStatus] = useState<{
running: boolean;
startupType: string | null;
error: string | null;
} | null>(null);
// Check SSH Agent status when agentForwarding is toggled on (Windows only)
useEffect(() => {
if (form.agentForwarding) {
checkSshAgent().then(setSshAgentStatus);
} else {
setSshAgentStatus(null);
}
}, [form.agentForwarding, checkSshAgent]);
// Group input state for inline creation suggestion
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
@@ -222,6 +250,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
group: groupInputValue.trim() || form.group,
tags: form.tags || [],
port: form.port || 22,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
};
onSave(cleaned);
};
@@ -480,9 +510,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
>
<AsidePanelContent>
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
@@ -503,9 +536,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.general")}
</p>
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.general")}
</p>
</div>
<Input
placeholder={t("hostDetails.label.placeholder")}
value={form.label}
@@ -556,9 +592,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.portCredentials")}
</p>
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.portCredentials")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
<span className="text-xs text-muted-foreground">SSH on</span>
@@ -770,6 +809,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
/>
)}
{/* Save Password toggle - shown when password is entered */}
{!selectedIdentity && !form.identityId && form.password && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
{t("hostDetails.password.save")}
</span>
<Switch
checked={form.savePassword ?? true}
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
/>
</div>
)}
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
@@ -926,9 +978,61 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}
</p>
<div className="flex items-center gap-2">
<FolderLock size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.sftp")}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.sudo")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</div>
</div>
<Switch
checked={form.sftpSudo || false}
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</div>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.appearance")}
</p>
</div>
{/* SSH Theme Selection */}
<button
@@ -1015,7 +1119,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
<div className="flex items-center gap-2">
<Wifi size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.mosh")}</p>
</div>
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
@@ -1025,6 +1132,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{/* Agent Forwarding */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Forward size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.agentForwarding")}</p>
</div>
<ToggleRow
label={t("hostDetails.agentForwarding")}
enabled={!!form.agentForwarding}
@@ -1033,6 +1144,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.desc")}
</p>
{form.agentForwarding && sshAgentStatus && !sshAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{t("hostDetails.agentForwarding.agentNotRunning")}
</p>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.agentNotRunningHint")}
</p>
</div>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}

View File

@@ -14,6 +14,7 @@ import {
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
@@ -257,6 +258,52 @@ const HostForm: React.FC<HostFormProps> = ({
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div

View File

@@ -28,6 +28,7 @@ export interface KeyboardInteractiveRequest {
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
}
interface KeyboardInteractiveModalProps {
@@ -137,11 +138,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isPassword
? t("keyboard.interactive.enterCode")
: t("keyboard.interactive.enterResponse")
}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
@@ -149,7 +146,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
{isPassword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
@@ -157,6 +154,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
)}
</div>
);
})}

View File

@@ -2,7 +2,7 @@ import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { FileText, Palette, X } from "lucide-react";
import { FileText, Download, Palette, X } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
@@ -34,6 +34,7 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const fitAddonRef = useRef<FitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
@@ -67,6 +68,30 @@ const LogViewComponent: React.FC<LogViewProps> = ({
onUpdateLog(log.id, { fontSize });
}, [log.id, onUpdateLog]);
// Handle export
const handleExport = useCallback(async () => {
if (!log.terminalData || isExporting) return;
setIsExporting(true);
try {
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
const bridge = netcattyBridge.get();
if (bridge?.exportSessionLog) {
await bridge.exportSessionLog({
terminalData: log.terminalData,
hostLabel: log.hostLabel,
hostname: log.hostname,
startTime: log.startTime,
format: 'txt',
});
}
} catch (err) {
console.error('Failed to export session log:', err);
} finally {
setIsExporting(false);
}
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
// Initialize terminal
useEffect(() => {
if (!containerRef.current || !isVisible) return;
@@ -216,6 +241,21 @@ const LogViewComponent: React.FC<LogViewProps> = ({
</div>
</div>
<div className="flex items-center gap-2">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
title={t("logView.export")}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
</Button>
)}
{/* Theme & font customization button */}
<Button
variant="ghost"

View File

@@ -6,7 +6,7 @@ import {
Terminal,
TerminalSquare,
} from "lucide-react";
import React, { memo, useEffect, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
@@ -21,6 +21,42 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
// Compute once at module level
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
// Memoized host item component to prevent unnecessary re-renders
const HostItem = memo(({
host,
isSelected,
onSelect,
onMouseEnter,
}: {
host: Host;
isSelected: boolean;
onSelect: (host: Host) => void;
onMouseEnter: () => void;
}) => (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => onSelect(host)}
onMouseEnter={onMouseEnter}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
));
HostItem.displayName = "HostItem";
interface QuickSwitcherProps {
isOpen: boolean;
query: string;
@@ -52,12 +88,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}) => {
const { t } = useI18n();
// Get hotkey display strings
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
const getHotkeyLabel = (actionId: string) => {
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
if (!binding) return '';
return isMac ? binding.mac : binding.pc;
};
return IS_MAC ? binding.mac : binding.pc;
}, [keyBindings]);
const quickSwitchKey = getHotkeyLabel('quick-switch');
const [isFocused, setIsFocused] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -93,15 +128,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Memoize orphan sessions
const orphanSessions = useMemo(
() => sessions.filter((s) => !s.workspaceId),
[sessions]
);
const showCategorized = isFocused || query.trim().length > 0;
// Get orphan sessions (sessions without workspace)
const orphanSessions = sessions.filter((s) => !s.workspaceId);
// Build categorized items for navigation
const buildFlatItems = () => {
// Memoize flat items list and index map
const { flatItems, itemIndexMap } = useMemo(() => {
const items: QuickSwitcherItem[] = [];
if (showCategorized) {
@@ -127,10 +163,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
}
return items;
};
// Build index map for O(1) lookup
const indexMap = new Map<string, number>();
items.forEach((item, idx) => {
indexMap.set(`${item.type}:${item.id}`, idx);
});
const flatItems = buildFlatItems();
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
return itemIndexMap.get(`${type}:${id}`) ?? -1;
}, [itemIndexMap]);
if (!isOpen) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
@@ -165,40 +212,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}
};
// Helper to get item index in flat list
const getItemIndex = (type: string, id: string) => {
return flatItems.findIndex((item) => item.type === type && item.id === id);
};
const renderHostItem = (host: Host) => {
const idx = getItemIndex("host", host.id);
const isSelected = idx === selectedIndex;
return (
<div
key={host.id}
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelect(host);
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
);
};
return (
<div
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
@@ -260,7 +273,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<div>
{results.length > 0 ? (
results.map(renderHostItem)
results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))
) : (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
No recent connections
@@ -289,7 +310,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
Hosts
</span>
</div>
{results.map(renderHostItem)}
{results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
}, [closeSettingsWindow]);
return (
<div className="h-screen flex flex-col bg-background text-foreground">
<div className="h-screen flex flex-col bg-background text-foreground font-sans">
<div className="shrink-0 border-b border-border app-drag">
<div className="flex items-center justify-between px-4 pt-3">
{isMac && <div className="h-6" />}
@@ -158,6 +158,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setAccentMode={settings.setAccentMode}
customAccent={settings.customAccent}
setCustomAccent={settings.setCustomAccent}
uiFontFamilyId={settings.uiFontFamilyId}
setUiFontFamilyId={settings.setUiFontFamilyId}
uiLanguage={settings.uiLanguage}
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
@@ -201,7 +203,16 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</React.Suspense>
)}
{mountedTabs.has("system") && <SettingsSystemTab />}
{mountedTabs.has("system") && (
<SettingsSystemTab
sessionLogsEnabled={settings.sessionLogsEnabled}
setSessionLogsEnabled={settings.setSessionLogsEnabled}
sessionLogsDir={settings.sessionLogsDir}
setSessionLogsDir={settings.setSessionLogsDir}
sessionLogsFormat={settings.sessionLogsFormat}
setSessionLogsFormat={settings.setSessionLogsFormat}
/>
)}
</div>
</Tabs>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -565,12 +565,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{!snippets.length && displayedPackages.length === 0 && (
<div className="flex-1 flex items-center justify-center px-4">
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<FileCode size={22} />
<div className="flex flex-col items-center justify-center text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<FileCode size={32} className="opacity-60" />
</div>
<div className="text-sm font-semibold text-foreground">{t('snippets.empty.title')}</div>
<div className="text-xs text-muted-foreground px-8">{t('snippets.empty.desc')}</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
</div>
</div>
)}

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -26,6 +26,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
@@ -34,13 +35,13 @@ import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { createHighlightProcessor } from "./terminal/keywordHighlight";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
interface TerminalProps {
host: Host;
@@ -88,6 +89,19 @@ interface TerminalProps {
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
// Helper function to format network speed (bytes/sec) to human-readable format
function formatNetSpeed(bytesPerSec: number): string {
if (bytesPerSec < 1024) {
return `${bytesPerSec}B/s`;
} else if (bytesPerSec < 1024 * 1024) {
return `${(bytesPerSec / 1024).toFixed(1)}K/s`;
} else if (bytesPerSec < 1024 * 1024 * 1024) {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M/s`;
} else {
return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)}G/s`;
}
}
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -128,7 +142,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleBroadcast,
onBroadcastInput,
}) => {
const CONNECTION_TIMEOUT = 12000;
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
@@ -148,12 +163,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
useEffect(() => {
highlightProcessorRef.current = createHighlightProcessor(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false,
);
if (xtermRuntimeRef.current) {
xtermRuntimeRef.current.keywordHighlighter.setRules(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false
);
}
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
const hotkeySchemeRef = useRef(hotkeyScheme);
@@ -207,6 +223,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Server stats (CPU, Memory, Disk) for Linux servers
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isConnected: status === 'connected',
});
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -297,7 +322,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disposeExitRef,
fitAddonRef,
serializeAddonRef,
highlightProcessorRef,
pendingAuthRef,
updateStatus,
setStatus,
@@ -524,7 +548,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
| 700
| 800
| 900;
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
const resolvedFontWeightBold = (() => {
const fontFamily = termRef.current?.options.fontFamily || "";
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
@@ -600,6 +635,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
logger.warn("Fit after fonts ready failed", err);
}
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
}
const id = sessionRef.current;
if (id && term) {
try {
@@ -617,7 +673,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, sessionId, resizeSession]);
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
@@ -897,6 +953,266 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
: serverStats.diskPercent !== null
? `${serverStats.diskPercent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
{disk.mountPoint}
</span>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{disk.used}/{disk.total}G ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{serverStats.netInterfaces.length > 0 && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)}
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
@@ -1079,6 +1395,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
};
})()}
open={showSFTP && status === "connected"}

View File

@@ -7,7 +7,7 @@ import {
Search,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader } from '@monaco-editor/react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -82,6 +82,50 @@ const languageIdToMonaco = (langId: string): string => {
return mapping[langId] || 'plaintext';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
@@ -90,12 +134,13 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave,
}) => {
const { t } = useI18n();
const monaco = useMonaco();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
@@ -104,13 +149,49 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Listen for theme changes via MutationObserver on <html> class
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
setBgColor(getBackgroundColor());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
return () => observer.disconnect();
}, []);
@@ -185,7 +266,6 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
@@ -265,7 +345,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={monacoTheme}
theme={customThemeName}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />

View File

@@ -2,6 +2,7 @@ import {
Activity,
BookMarked,
ChevronDown,
Copy,
Edit2,
FileCode,
FolderPlus,
@@ -41,7 +42,6 @@ import {
TerminalSession,
} from "../types";
import { AppLogo } from "./AppLogo";
import ConnectionLogsManager from "./ConnectionLogsManager";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import KeychainManager from "./KeychainManager";
@@ -76,6 +76,7 @@ import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import { toast } from "./ui/toast";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
@@ -301,6 +302,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsHostPanelOpen(true);
}, []);
const handleDuplicateHost = useCallback((host: Host) => {
// Create a copy of the host with a new ID and modified label
const duplicatedHost: Host = {
...host,
id: crypto.randomUUID(),
label: `${host.label} (${t('action.copy')})`,
createdAt: Date.now(),
};
// Open the edit panel with the duplicated host for modification
setEditingHost(duplicatedHost);
setIsHostPanelOpen(true);
}, [t]);
const readTextFile = useCallback(async (file: File): Promise<string> => {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
@@ -747,7 +761,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
@@ -761,7 +775,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "keys" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
@@ -774,7 +788,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "port" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
@@ -785,7 +799,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "snippets" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
@@ -798,7 +812,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "knownhosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
@@ -809,7 +823,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "logs" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
@@ -1219,18 +1233,23 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem
onClick={() => handleHostConnect(host)}
>
<Plug className="mr-2 h-4 w-4" /> Connect
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
<Edit2 className="mr-2 h-4 w-4" /> Edit
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleDuplicateHost(host)}
>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> Delete
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -1350,14 +1369,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Connection Logs */}
{currentSection === "logs" && (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-muted-foreground">Loading...</div>}>
<LazyConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
</Suspense>
)}
</div>
@@ -1377,8 +1398,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
onUpdateHosts(
editingHost
hostExists
? hosts.map((h) => (h.id === host.id ? host : h))
: [...hosts, host],
);

View File

@@ -84,7 +84,6 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.pub,.ppk,*"
className="hidden"
onChange={handleFileImport}
/>

View File

@@ -0,0 +1,77 @@
import React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { UIFont } from '../../infrastructure/config/uiFonts';
interface FontSelectProps {
value: string;
fonts: UIFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
export const FontSelect: React.FC<FontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
}) => {
const selectedFont = fonts.find(f => f.id === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: selectedFont?.family }}>
{selectedFont?.name || value}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: font.family }}>{font.name}</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default FontSelect;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { TerminalFont } from '../../infrastructure/config/fonts';
interface TerminalFontSelectProps {
value: string;
fonts: TerminalFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
}) => {
const selectedFont = fonts.find(f => f.id === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: selectedFont?.family }}>
{selectedFont?.name || value}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: font.family }}>{font.name}</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default TerminalFontSelect;

View File

@@ -2,9 +2,11 @@ import React, { useCallback } from "react";
import { Check, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light";
@@ -17,12 +19,15 @@ export default function SettingsAppearanceTab(props: {
setAccentMode: (mode: "theme" | "custom") => void;
customAccent: string;
setCustomAccent: (color: string) => void;
uiFontFamilyId: string;
setUiFontFamilyId: (fontId: string) => void;
uiLanguage: string;
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
const {
theme,
setTheme,
@@ -34,6 +39,8 @@ export default function SettingsAppearanceTab(props: {
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
uiLanguage,
setUiLanguage,
customCSS,
@@ -130,6 +137,17 @@ export default function SettingsAppearanceTab(props: {
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.uiFont")}
description={t("settings.appearance.uiFont.desc")}
>
<FontSelect
value={uiFontFamilyId}
fonts={availableUIFonts}
onChange={(v) => setUiFontFamilyId(v)}
className="w-48"
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />

View File

@@ -1,12 +1,14 @@
/**
* Settings System Tab - System information and temp file management
* Settings System Tab - System information, temp file management, and session logs
*/
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { SessionLogFormat } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
interface TempDirInfo {
path: string;
@@ -22,9 +24,25 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const SettingsSystemTab: React.FC = () => {
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
sessionLogsDir: string;
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
}) => {
const { t } = useI18n();
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
@@ -33,7 +51,7 @@ const SettingsSystemTab: React.FC = () => {
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
setIsLoading(true);
try {
const info = await bridge.getTempDirInfo();
@@ -52,7 +70,7 @@ const SettingsSystemTab: React.FC = () => {
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
setIsClearing(true);
setClearResult(null);
try {
@@ -73,6 +91,37 @@ const SettingsSystemTab: React.FC = () => {
await bridge.openTempDir();
}, [tempDirInfo]);
const handleSelectSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectSessionLogsDir) return;
try {
const result = await bridge.selectSessionLogsDir();
if (result.success && result.directory) {
setSessionLogsDir(result.directory);
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to select directory:", err);
}
}, [setSessionLogsDir]);
const handleOpenSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
try {
await bridge.openSessionLogsDir(sessionLogsDir);
} catch (err) {
console.error("[SettingsSystemTab] Failed to open directory:", err);
}
}, [sessionLogsDir]);
const formatOptions = [
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
];
return (
<TabsContent
value="system"
@@ -171,6 +220,81 @@ const SettingsSystemTab: React.FC = () => {
{t("settings.system.tempDirectoryHint")}
</p>
</div>
{/* Session Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Enable Toggle */}
<SettingRow
label={t("settings.sessionLogs.enableAutoSave")}
description={t("settings.sessionLogs.enableAutoSaveDesc")}
>
<Toggle
checked={sessionLogsEnabled}
onChange={setSessionLogsEnabled}
/>
</SettingRow>
{/* Directory Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectSessionLogsDir}
className="shrink-0"
>
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
title={t("settings.sessionLogs.openFolder")}
>
<FolderOpen size={16} />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.directoryHint")}
</p>
</div>
{/* Format Selection */}
<SettingRow
label={t("settings.sessionLogs.format")}
description={t("settings.sessionLogs.formatDesc")}
>
<Select
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.hint")}
</p>
</div>
</div>
</div>
</TabsContent>

View File

@@ -17,6 +17,7 @@ import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
// Theme preview button component
const ThemePreviewButton: React.FC<{
@@ -207,11 +208,11 @@ export default function SettingsTerminalTab(props: {
label={t("settings.terminal.font.family")}
description={t("settings.terminal.font.family.desc")}
>
<Select
<TerminalFontSelect
value={terminalFontFamilyId}
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
fonts={availableFonts}
onChange={(id) => setTerminalFontFamilyId(id)}
className="w-40"
className="w-48"
/>
</SettingRow>
@@ -608,6 +609,62 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.serverStats.show")}
description={t("settings.terminal.serverStats.show.desc")}
>
<Toggle
checked={terminalSettings.showServerStats}
onChange={(v) => updateTerminalSetting("showServerStats", v)}
/>
</SettingRow>
{terminalSettings.showServerStats && (
<SettingRow
label={t("settings.terminal.serverStats.refreshInterval")}
description={t("settings.terminal.serverStats.refreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={300}
value={terminalSettings.serverStatsRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value) || 5;
if (val >= 5 && val <= 300) {
updateTerminalSetting("serverStatsRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
)}
</div>
<SectionHeader title={t("settings.terminal.section.rendering")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.rendering.renderer")}
description={t("settings.terminal.rendering.renderer.desc")}
>
<Select
value={terminalSettings.rendererType}
options={[
{ value: "auto", label: t("settings.terminal.rendering.auto") },
{ value: "webgl", label: "WebGL" },
{ value: "canvas", label: "Canvas" },
]}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
className="w-32"
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,139 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import type { RemoteFile } from "../../types";
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface SftpModalDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => void;
isChangingPermissions: boolean;
}
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
t,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
handleRename,
isRenaming,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
isChangingPermissions,
}) => (
<>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
<DialogDescription className="truncate">
{renameTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
<DialogDescription className="truncate">
{permissionsTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
{(["owner", "group", "others"] as const).map((role) => (
<div key={role} className="flex items-center gap-4">
<div className="w-16 text-sm font-medium">
{t(`sftp.permissions.${role}`)}
</div>
<div className="flex gap-3">
{(["read", "write", "execute"] as const).map((perm) => (
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={permissions[role][perm]}
onChange={() => togglePermission(role, perm)}
className="rounded border-border"
/>
<span className="text-xs">
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);

View File

@@ -0,0 +1,425 @@
import React from "react";
import { Download, Edit2, Folder, FolderOpen, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { RemoteFile } from "../../types";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
import { Button } from "../ui/button";
import { getFileIcon } from "./fileIcons";
interface VisibleRow {
file: RemoteFile;
index: number;
top: number;
}
interface SftpModalFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
currentPath: string;
isLocalSession: boolean;
files: RemoteFile[];
selectedFiles: Set<string>;
dragActive: boolean;
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
resolvedLocale: string | undefined;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: VisibleRow[];
fileListRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
handleSort: (field: "name" | "size" | "modified") => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
handleDownload: (file: RemoteFile) => void;
handleDelete: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleEditFile: (file: RemoteFile) => void;
openRenameDialog: (file: RemoteFile) => void;
openPermissionsDialog: (file: RemoteFile) => void;
handleNavigate: (path: string) => void;
handleCreateFolder: () => void;
handleCreateFile: () => void;
handleDownloadSelected: () => void;
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined, locale?: string) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
t,
currentPath,
isLocalSession,
files,
selectedFiles,
dragActive,
loading,
loadingTextContent,
reconnecting,
resolvedLocale,
columnWidths,
sortField,
sortOrder,
shouldVirtualize,
totalHeight,
visibleRows,
fileListRef,
inputRef,
handleSort,
handleResizeStart,
handleFileListScroll,
handleDrag,
handleDrop,
handleFileClick,
handleFileDoubleClick,
handleDownload,
handleDelete,
handleOpenFile,
openFileOpenerDialog,
handleEditFile,
openRenameDialog,
openPermissionsDialog,
handleNavigate,
handleCreateFolder,
handleCreateFile,
handleDownloadSelected,
handleDeleteSelected,
loadFiles,
formatBytes,
formatDate,
}) => (
<>
<div
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("size")}
>
<span>{t("sftp.columns.size")}</span>
{sortField === "size" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div className="text-right">{t("sftp.columns.actions")}</div>
</div>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
)}
onScroll={handleFileListScroll}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
<Upload size={32} />
<span>{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
{loading && files.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{loadingTextContent && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{t("sftp.status.loading")}
</span>
</div>
</div>
)}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">
{t("sftp.reconnecting.desc")}
</div>
</div>
</div>
</div>
)}
{files.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
</div>
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{visibleRows.map(({ file, index: idx, top }) => {
const isNavigableDirectory =
file.type === "directory" ||
(file.type === "symlink" && file.linkTarget === "directory");
const isDownloadableFile =
file.type === "file" ||
(file.type === "symlink" && file.linkTarget === "file");
const isParentEntry = file.name === "..";
return (
<ContextMenu key={file.name}>
<ContextMenuTrigger>
<div
data-sftp-modal-row="true"
className={cn(
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
)}
style={
shouldVirtualize
? {
top,
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
: {
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
}
onClick={(e) => handleFileClick(file, idx, e)}
onDoubleClick={() => handleFileDoubleClick(file)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
{getFileIcon(
file.name,
isNavigableDirectory,
file.type === "symlink" && !isNavigableDirectory,
)}
{file.type === "symlink" && (
<Link
size={10}
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</div>
<span
className={cn(
"truncate font-medium",
file.type === "symlink" && "italic pr-1",
)}
>
{file.name}
{file.type === "symlink" && (
<span className="sr-only"> (symbolic link)</span>
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified, resolvedLocale)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
title={t("sftp.context.download")}
>
<Download size={14} />
</Button>
)}
{!isParentEntry && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
title={t("sftp.context.delete")}
>
<Trash2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{isParentEntry ? (
<ContextMenuItem
onClick={() => {
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
const parentPath =
segments.length === 0 ? "/" : `/${segments.join("/")}`;
handleNavigate(parentPath);
}}
>
{t("sftp.context.open")}
</ContextMenuItem>
) : (
<>
{isNavigableDirectory && (
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
)}
{isDownloadableFile && (
<>
<ContextMenuItem onClick={() => handleOpenFile(file)}>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
<MoreHorizontal size={14} className="mr-2" />
{t("sftp.context.openWith")}
</ContextMenuItem>
{!isKnownBinaryFile(file.name) && (
<ContextMenuItem onClick={() => handleEditFile(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.edit")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={() => openRenameDialog(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.rename")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
<Shield size={14} className="mr-2" />
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => handleDelete(file)}
>
<Trash2 size={14} className="mr-2" />
{t("sftp.context.delete")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
</ContextMenuItem>
{selectedFiles.size > 0 && (
<>
<ContextMenuItem onClick={handleDownloadSelected}>
<Download className="h-4 w-4 mr-2" />
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
</>
);

View File

@@ -0,0 +1,61 @@
import React from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import type { RemoteFile } from "../../types";
interface SftpModalFooterProps {
t: (key: string, params?: Record<string, unknown>) => string;
files: RemoteFile[];
selectedFiles: Set<string>;
loading: boolean;
uploading: boolean;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
}
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
t,
files,
selectedFiles,
loading,
uploading,
onDownloadSelected,
onDeleteSelected,
}) => (
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{t("sftp.itemsCount", { count: files.length })}
{selectedFiles.size > 0 && (
<>
<span className="mx-2">|</span>
<span className="text-primary">
{t("sftp.selectedCount", { count: selectedFiles.size })}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
onClick={onDownloadSelected}
>
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
onClick={onDeleteSelected}
>
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
</Button>
</>
)}
</span>
<span>
{loading
? t("sftp.status.loading")
: uploading
? t("sftp.status.uploading")
: t("sftp.status.ready")}
</span>
</div>
);

View File

@@ -0,0 +1,253 @@
import React from "react";
import { ArrowUp, ChevronRight, Home, MoreHorizontal, Plus, RefreshCw, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
interface BreadcrumbPart {
part: string;
originalIndex: number;
}
interface SftpModalHeaderProps {
t: (key: string, params?: Record<string, unknown>) => string;
host: Host;
credentials: { username?: string; hostname: string; port?: number };
showEncoding: boolean;
filenameEncoding: SftpFilenameEncoding;
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
currentPath: string;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
isAtRoot: boolean;
rootLabel: string;
isRefreshing: boolean;
onUp: () => void;
onHome: () => void;
onRefresh: () => void;
visibleBreadcrumbs: BreadcrumbPart[];
hiddenBreadcrumbs: BreadcrumbPart[];
needsBreadcrumbTruncation: boolean;
breadcrumbs: string[];
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
t,
host,
credentials,
showEncoding,
filenameEncoding,
onFilenameEncodingChange,
currentPath,
isEditingPath,
editingPathValue,
setEditingPathValue,
handlePathSubmit,
handlePathKeyDown,
handlePathDoubleClick,
isAtRoot,
rootLabel,
isRefreshing,
onUp,
onHome,
onRefresh,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbs,
onBreadcrumbSelect,
onRootSelect,
inputRef,
pathInputRef,
uploading,
onTriggerUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
}) => (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
</div>
</DialogHeader>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
{showEncoding && (
<Select
value={filenameEncoding}
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
>
<SelectTrigger className="h-7 w-[130px] text-xs" title={t("sftp.encoding.label")}>
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
)}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFolder}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFile}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
</Button>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
</div>
</div>
</>
);

View File

@@ -0,0 +1,175 @@
import React from "react";
import { Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface UploadTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
error?: string;
}
interface SftpModalUploadTasksProps {
tasks: UploadTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
if (tasks.length === 0) return null;
return (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
{tasks.map((task) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const remainingTime =
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
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" && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
<Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
<Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
)}
{task.status === "cancelled" && (
<XCircle size={14} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{task.fileName}
</span>
{task.status === "uploading" && task.speed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(task.speed)}
</span>
)}
{task.status === "uploading" && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || 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
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading"
? `${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)}%` : "..."}
</span>
</div>
)}
{task.status === "uploading" && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
Completed - {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
Cancelled
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{task.status === "pending" && (
<span className="text-[10px] text-muted-foreground">
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "pending") && onCancel && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={onCancel}
title={t("sftp.action.cancel")}
>
<X size={12} />
</Button>
)}
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(task.id)}
title={t("sftp.action.dismiss")}
>
<X size={12} />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Lock,
Settings,
Terminal,
} from "lucide-react";
import React from "react";
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
if (isDirectory)
return (
<Folder
size={18}
fill="currentColor"
fillOpacity={0.2}
className="text-blue-400"
/>
);
if (isSymlink) {
return <ExternalLink size={18} className="text-cyan-500" />;
}
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "rtf", "odt"].includes(ext))
return <FileText size={18} className="text-blue-500" />;
if (["xls", "xlsx", "csv", "ods"].includes(ext))
return <FileSpreadsheet size={18} className="text-green-500" />;
if (["ppt", "pptx", "odp"].includes(ext))
return <FileType size={18} className="text-orange-500" />;
if (["pdf"].includes(ext))
return <FileText size={18} className="text-red-500" />;
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
return <FileCode size={18} className="text-yellow-500" />;
if (["py", "pyc", "pyw"].includes(ext))
return <FileCode size={18} className="text-blue-400" />;
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
return <Terminal size={18} className="text-green-400" />;
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
return <FileCode size={18} className="text-blue-600" />;
if (["java", "class", "jar"].includes(ext))
return <FileCode size={18} className="text-orange-600" />;
if (["go"].includes(ext))
return <FileCode size={18} className="text-cyan-500" />;
if (["rs"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["rb"].includes(ext))
return <FileCode size={18} className="text-red-400" />;
if (["php"].includes(ext))
return <FileCode size={18} className="text-purple-500" />;
if (["html", "htm", "xhtml"].includes(ext))
return <Globe size={18} className="text-orange-500" />;
if (["css", "scss", "sass", "less"].includes(ext))
return <FileCode size={18} className="text-blue-500" />;
if (["vue", "svelte"].includes(ext))
return <FileCode size={18} className="text-green-500" />;
if (["json", "json5"].includes(ext))
return <FileCode size={18} className="text-yellow-600" />;
if (["xml", "xsl", "xslt"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["yml", "yaml"].includes(ext))
return <Settings size={18} className="text-pink-400" />;
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
return <Settings size={18} className="text-gray-400" />;
if (["env"].includes(ext))
return <Lock size={18} className="text-yellow-500" />;
if (["sql", "sqlite", "db"].includes(ext))
return <Database size={18} className="text-blue-400" />;
if (
[
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"tif",
"heic",
"heif",
"avif",
].includes(ext)
)
return <FileImage size={18} className="text-purple-400" />;
if (
[
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
].includes(ext)
)
return <FileVideo size={18} className="text-pink-500" />;
if (
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
ext,
)
)
return <FileAudio size={18} className="text-green-400" />;
if (
[
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"tbz2",
"lz",
"lzma",
"cab",
"iso",
"dmg",
].includes(ext)
)
return <FileArchive size={18} className="text-yellow-600" />;
return <File size={18} className="text-muted-foreground" />;
};

View File

@@ -0,0 +1,108 @@
import { useCallback } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalCreateDeleteParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => Promise<void>;
handleCreateFile: () => Promise<void>;
}
export const useSftpModalCreateDelete = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
const handleDelete = useCallback(
async (file: RemoteFile) => {
if (file.name === "..") return;
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
},
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(async () => {
const folderName = prompt(t("sftp.prompt.newFolderName"));
if (!folderName) return;
try {
const fullPath = joinPath(currentPath, folderName);
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.createFolderFailed"),
"SFTP",
);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t]);
const handleCreateFile = useCallback(async () => {
const fileName = prompt(t("sftp.fileName.placeholder"));
if (!fileName) return;
try {
const fullPath = joinPath(currentPath, fileName);
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
}
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
"SFTP",
);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
return { handleDelete, handleCreateFolder, handleCreateFile };
};

View File

@@ -0,0 +1,252 @@
import type { RemoteFile } from "../../../types";
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
import { useSftpModalRename } from "./useSftpModalRename";
import { useSftpModalPermissions } from "./useSftpModalPermissions";
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileActionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
interface UseSftpModalFileActionsResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => Promise<void>;
handleCreateFile: () => Promise<void>;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
};
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
handleOpenFile: (file: RemoteFile) => Promise<void>;
}
export const useSftpModalFileActions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
writeSftpBinary,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
const { handleDelete, handleCreateFolder, handleCreateFile } =
useSftpModalCreateDelete({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
});
const {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
} = useSftpModalRename({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
});
const {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
} = useSftpModalPermissions({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
});
const {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
} = useSftpModalTextEditor({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
});
const {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpModalFileOpener({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
});
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
};
};

View File

@@ -0,0 +1,154 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileOpenerParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
t: (key: string, params?: Record<string, unknown>) => string;
handleEditFile: (file: RemoteFile) => Promise<void>;
}
interface UseSftpModalFileOpenerResult {
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const useSftpModalFileOpener = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
setFileOpenerTarget(file);
setShowFileOpenerDialog(true);
}, []);
const handleOpenFile = useCallback(async (file: RemoteFile) => {
const savedOpener = getOpenerForFile(file.name);
if (savedOpener) {
if (savedOpener.openerType === "builtin-editor") {
await handleEditFile(file);
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
} else {
openFileOpenerDialog(file);
}
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
await handleEditFile(fileOpenerTarget);
} else if (openerType === "system-app" && systemApp) {
try {
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
fileOpenerTarget.name,
systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
setFileOpenerTarget(null);
},
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [selectApplication]);
return {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,135 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
interface UseSftpModalPathParams {
currentPath: string;
isLocalSession: boolean;
localHomePath: string | null;
onNavigate: (path: string) => void;
maxVisibleBreadcrumbParts?: number;
}
interface UseSftpModalPathResult {
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
pathInputRef: React.RefObject<HTMLInputElement>;
handlePathDoubleClick: () => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
breadcrumbs: string[];
visibleBreadcrumbs: { part: string; originalIndex: number }[];
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
needsBreadcrumbTruncation: boolean;
breadcrumbPathAtForIndex: (index: number) => string;
rootLabel: string;
rootPath: string;
}
export const useSftpModalPath = ({
currentPath,
isLocalSession,
localHomePath,
onNavigate,
maxVisibleBreadcrumbParts = 4,
}: UseSftpModalPathParams): UseSftpModalPathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const handlePathDoubleClick = () => {
setEditingPathValue(currentPath);
setIsEditingPath(true);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = () => {
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
const newPath = editingPathValue.trim() || fallbackPath;
setIsEditingPath(false);
if (newPath !== currentPath) {
if (isLocalSession) {
onNavigate(newPath);
} else {
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handlePathSubmit();
} else if (e.key === "Escape") {
setIsEditingPath(false);
}
};
const breadcrumbs = useMemo(
() => getBreadcrumbs(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
useMemo(() => {
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
return {
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
needsBreadcrumbTruncation: false,
};
}
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: breadcrumbs.length - lastPartsCount + idx,
}));
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1,
}));
return {
visibleBreadcrumbs: [...firstPart, ...lastParts],
hiddenBreadcrumbs: hidden,
needsBreadcrumbTruncation: true,
};
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
const breadcrumbPathAtForIndex = useCallback(
(index: number) =>
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
[breadcrumbs, currentPath, isLocalSession],
);
const rootLabel = useMemo(
() =>
isLocalSession && isWindowsPath(currentPath)
? getWindowsDrive(currentPath) ?? "C:"
: "/",
[currentPath, isLocalSession],
);
const rootPath = useMemo(
() => getRootPath(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
return {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
};
};

View File

@@ -0,0 +1,189 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalPermissionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface UseSftpModalPermissionsResult {
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
}
export const useSftpModalPermissions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
});
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
const parsePermissions = useCallback((perms: string | undefined) => {
const defaultPerms = {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
};
if (!perms) return defaultPerms;
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
return {
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
};
}
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
return {
owner: {
read: pStr[0] === "r",
write: pStr[1] === "w",
execute: pStr[2] === "x" || pStr[2] === "s",
},
group: {
read: pStr[3] === "r",
write: pStr[4] === "w",
execute: pStr[5] === "x" || pStr[5] === "s",
},
others: {
read: pStr[6] === "r",
write: pStr[7] === "w",
execute: pStr[8] === "x" || pStr[8] === "t",
},
};
}
return defaultPerms;
}, []);
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
if (isLocalSession) {
toast.error("Permissions not available for local files", "SFTP");
return;
}
setPermissionsTarget(file);
let permsStr = file.permissions;
try {
const fullPath = joinPath(currentPath, file.name);
const stat = await statSftp(await ensureSftp(), fullPath);
if (stat.permissions) {
permsStr = stat.permissions;
}
} catch (e) {
console.warn("Failed to fetch file permissions:", e);
}
setPermissions(parsePermissions(permsStr));
setShowPermissionsDialog(true);
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
const togglePermission = useCallback(
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
setPermissions((prev) => ({
...prev,
[role]: { ...prev[role], [perm]: !prev[role][perm] },
}));
},
[],
);
const getOctalPermissions = useCallback(() => {
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
}, [permissions]);
const getSymbolicPermissions = useCallback(() => {
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
return (
getSym(permissions.owner) +
getSym(permissions.group) +
getSym(permissions.others)
);
}, [permissions]);
const handleSavePermissions = useCallback(async () => {
if (!permissionsTarget || isChangingPermissions) return;
setIsChangingPermissions(true);
try {
const fullPath = joinPath(currentPath, permissionsTarget.name);
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
setShowPermissionsDialog(false);
setPermissionsTarget(null);
await loadFiles(currentPath, { force: true });
toast.success(t("sftp.permissions.success"), "SFTP");
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.permissions.failed"),
"SFTP",
);
} finally {
setIsChangingPermissions(false);
}
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
return {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
};
};

View File

@@ -0,0 +1,85 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalRenameParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalRenameResult {
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
}
export const useSftpModalRename = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
const [renameName, setRenameName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const openRenameDialog = useCallback((file: RemoteFile) => {
setRenameTarget(file);
setRenameName(file.name);
setShowRenameDialog(true);
}, []);
const handleRename = useCallback(async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
if (renameName.trim() === renameTarget.name) {
setShowRenameDialog(false);
return;
}
setIsRenaming(true);
try {
const oldPath = joinPath(currentPath, renameTarget.name);
const newPath = joinPath(currentPath, renameName.trim());
if (isLocalSession) {
toast.error("Local rename not implemented", "SFTP");
} else {
await renameSftp(await ensureSftp(), oldPath, newPath);
}
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
"SFTP",
);
} finally {
setIsRenaming(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
return {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
};
};

View File

@@ -0,0 +1,99 @@
import React, { useCallback, useRef } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalSelectionParams {
files: RemoteFile[];
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
currentPath: string;
joinPath: (base: string, name: string) => string;
onNavigate: (path: string) => void;
onOpenFile: (file: RemoteFile) => void;
onNavigateUp: () => void;
}
interface UseSftpModalSelectionResult {
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
}
export const useSftpModalSelection = ({
files,
setSelectedFiles,
currentPath,
joinPath,
onNavigate,
onOpenFile,
onNavigateUp,
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
const lastSelectedIndexRef = useRef<number | null>(null);
const handleFileClick = useCallback(
(file: RemoteFile, index: number, e: React.MouseEvent) => {
if (file.name === "..") return;
if (file.type === "directory") {
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
return next;
});
}
return;
}
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file.name)) {
next.delete(file.name);
} else {
next.add(file.name);
}
return next;
});
lastSelectedIndexRef.current = index;
} else {
setSelectedFiles(new Set([file.name]));
lastSelectedIndexRef.current = index;
}
},
[files, setSelectedFiles],
);
const handleFileDoubleClick = useCallback(
(file: RemoteFile) => {
if (file.name === "..") {
onNavigateUp();
return;
}
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
onNavigate(joinPath(currentPath, file.name));
} else {
onOpenFile(file);
}
},
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
);
return { handleFileClick, handleFileDoubleClick };
};

View File

@@ -0,0 +1,392 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
open: boolean;
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
openSftp: (params: {
sessionId: string;
hostname: string;
username: string;
port: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
listLocalDir: (path: string) => Promise<RemoteFile[]>;
getHomeDir: () => Promise<string | null>;
onClearSelection: () => void;
}
interface UseSftpModalSessionResult {
currentPath: string;
setCurrentPath: (path: string) => void;
files: RemoteFile[];
setFiles: (files: RemoteFile[]) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
reconnecting: boolean;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
closeSftpSession: () => Promise<void>;
localHomeRef: React.MutableRefObject<string | null>;
}
export const useSftpModalSession = ({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp,
listSftp,
listLocalDir,
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const ensureSftp = useCallback(async () => {
if (isLocalSession) throw new Error("Local session does not use SFTP");
if (sftpIdRef.current) return sftpIdRef.current;
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
});
sftpIdRef.current = sftpId;
return sftpId;
}, [
isLocalSession,
host.id,
credentials.hostname,
credentials.username,
credentials.port,
credentials.password,
credentials.privateKey,
credentials.certificate,
credentials.passphrase,
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
openSftp,
]);
const closeSftpSession = useCallback(async () => {
if (!isLocalSession && sftpIdRef.current) {
try {
await closeSftp(sftpIdRef.current);
} catch {
// Silently ignore close errors - connection may already be closed
}
}
sftpIdRef.current = null;
}, [closeSftp, isLocalSession]);
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("client disconnected")
);
}, []);
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
reconnectingRef.current = true;
setReconnecting(true);
reconnectAttemptsRef.current = 0;
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
try {
reconnectAttemptsRef.current += 1;
if (sftpIdRef.current) {
try {
await closeSftp(sftpIdRef.current);
} catch {
// ignore
}
sftpIdRef.current = null;
}
await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
return;
} catch (err) {
logger.warn(
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
err,
);
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftp, ensureSftp, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
setLoading(true);
onClearSelection();
try {
if (isLocalSession) {
const list = await listLocalDir(path);
if (requestId === loadSeqRef.current) {
setFiles(list);
}
return;
}
const cacheKey = `${host.id}::${path}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh =
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (cached && isFresh && !options?.force) {
setFiles(cached.files);
return;
}
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, path);
if (requestId !== loadSeqRef.current) return;
setFiles(list);
dirCacheRef.current.set(cacheKey, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
setFiles([]);
} finally {
if (loadSeqRef.current === requestId) {
setLoading(false);
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
if (!open) return;
const cacheKey = `${host.id}::${currentPath}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (!isFresh) {
setFiles([]);
onClearSelection();
}
}, [currentPath, host.id, onClearSelection, open]);
useEffect(() => {
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
})();
return;
}
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
})();
return;
}
void loadFiles(currentPath);
} else {
loadSeqRef.current += 1;
void closeSftpSession();
initializedRef.current = false;
}
}, [
closeSftpSession,
currentPath,
ensureSftp,
getHomeDir,
host.id,
initialPath,
isLocalSession,
listLocalDir,
listSftp,
loadFiles,
onClearSelection,
open,
t,
]);
useEffect(() => {
return () => {
void closeSftpSession();
};
}, [closeSftpSession]);
return {
currentPath,
setCurrentPath,
files,
setFiles,
loading,
setLoading,
reconnecting,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
};
};

View File

@@ -0,0 +1,76 @@
import React, { useCallback, useRef, useState } from "react";
export type SortField = "name" | "size" | "modified";
export type SortOrder = "asc" | "desc";
interface UseSftpModalSortingResult {
sortField: SortField;
sortOrder: SortOrder;
columnWidths: { name: number; size: number; modified: number; actions: number };
handleSort: (field: SortField) => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
}
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState({
name: 45,
size: 15,
modified: 25,
actions: 15,
});
const resizingRef = useRef<{
field: string;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
const handleResizeStart = (field: string, e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field as keyof typeof columnWidths],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
columnWidths,
handleSort,
handleResizeStart,
};
};

View File

@@ -0,0 +1,87 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalTextEditorParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTextEditorResult {
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
}
export const useSftpModalTextEditor = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const handleEditFile = useCallback(async (file: RemoteFile) => {
try {
setLoadingTextContent(true);
setTextEditorTarget(file);
const fullPath = joinPath(currentPath, file.name);
const content = isLocalSession
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
: await readSftp(await ensureSftp(), fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
const handleSaveTextFile = useCallback(async (content: string) => {
if (!textEditorTarget) return;
const fullPath = joinPath(currentPath, textEditorTarget.name);
if (isLocalSession) {
const encoder = new TextEncoder();
await writeLocalFile(fullPath, encoder.encode(content).buffer);
} else {
await writeSftp(await ensureSftp(), fullPath, content);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
return {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
};
};

View File

@@ -0,0 +1,476 @@
import React, { useCallback, useState, useRef, useMemo } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
UploadBridge,
UploadCallbacks,
UploadTaskInfo,
UploadProgress,
} from "../../../lib/uploadService";
interface UploadTask {
id: string;
fileName: string;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
progress: number;
totalBytes: number;
transferredBytes: number;
speed: number;
startTime: number;
error?: string;
isDirectory?: boolean;
fileCount?: number;
completedCount?: number;
}
interface UseSftpModalTransfersParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress: (
sftpId: string,
path: string,
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
onComplete: () => void,
onError: (error: string) => void,
) => Promise<boolean>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
setLoading: (loading: boolean) => void;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTransfersResult {
uploading: boolean;
uploadTasks: UploadTask[];
dragActive: boolean;
handleDownload: (file: RemoteFile) => Promise<void>;
handleUploadMultiple: (fileList: FileList) => Promise<void>;
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
cancelUpload: () => Promise<void>;
dismissTask: (taskId: string) => void;
}
export const useSftpModalTransfers = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftpBinaryWithProgress,
writeSftpBinary,
mkdirLocal,
mkdirSftp,
cancelSftpUpload,
setLoading,
t,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
const [dragActive, setDragActive] = useState(false);
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Cached SFTP ID to avoid multiple calls to ensureSftp
const cachedSftpIdRef = useRef<string | null>(null);
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
const handleDownload = useCallback(
async (file: RemoteFile) => {
try {
const fullPath = joinPath(currentPath, file.name);
setLoading(true);
const content = isLocalSession
? await readLocalFile(fullPath)
: await readSftp(await ensureSftp(), fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
);
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
writeLocalFile,
mkdirLocal,
mkdirSftp: async (sftpId: string, path: string) => {
await mkdirSftp(sftpId, path);
},
writeSftpBinary: async (sftpId: string, path: string, data: ArrayBuffer) => {
await writeSftpBinary(sftpId, path, data);
},
writeSftpBinaryWithProgress: async (
sftpId: string,
path: string,
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
_onComplete?: () => void,
_onError?: (error: string) => void
) => {
try {
const result = await writeSftpBinaryWithProgress(
sftpId,
path,
data,
taskId,
onProgress,
() => { },
() => { }
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
}
return { success: result, cancelled: wasCancelled };
} catch (error) {
// Check if this was a user-initiated cancellation
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
return { success: false, cancelled: true };
}
// Real error - propagate it by re-throwing
throw error;
}
},
cancelSftpUpload,
};
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload]);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
const scanningTask: UploadTask = {
id: taskId,
fileName: "Scanning files...",
status: "pending",
progress: 0,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
};
setUploadTasks(prev => [...prev, scanningTask]);
},
onScanningEnd: (taskId: string) => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
},
onTaskCreated: (task: UploadTaskInfo) => {
const uploadTask: UploadTask = {
id: task.id,
fileName: task.displayName,
status: "uploading",
progress: 0,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
fileCount: task.fileCount,
completedCount: 0,
};
// Filter out any pending scanning tasks before adding the real task.
// This ensures that even if onScanningEnd's state update hasn't been applied yet
// (due to React state batching), the scanning placeholder will still be removed.
setUploadTasks(prev => [
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
uploadTask
]);
},
onTaskProgress: (taskId: string, progress: UploadProgress) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId && task.status === "uploading"
? {
...task,
transferredBytes: progress.transferred,
progress: progress.percent,
speed: progress.speed,
}
: task
)
);
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: totalBytes,
speed: 0,
}
: task
)
);
},
onTaskFailed: (taskId: string, error: string) => {
// Any error marks the task as failed
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "failed" as const,
error,
speed: 0,
}
: task
)
);
// Auto-clear failed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 3000);
},
onTaskCancelled: (taskId: string) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "cancelled" as const,
speed: 0,
}
: task
)
);
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 2000);
},
};
}, []);
const handleUploadMultiple = useCallback(
async (fileList: FileList) => {
if (fileList.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
fileList,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleUploadFromDrop = useCallback(
async (dataTransfer: DataTransfer) => {
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromDataTransfer(
dataTransfer,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
void handleUploadMultiple(e.target.files);
}
e.target.value = "";
},
[handleUploadMultiple],
);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
void handleUploadFromDrop(e.dataTransfer);
}
},
[handleUploadFromDrop],
);
const cancelUpload = useCallback(async () => {
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();
}
// Always clear all uploading/pending tasks immediately, even without controller
setUploadTasks(prev => {
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
if (!hasActiveTasks) return prev;
return prev.map(task =>
task.status === "uploading" || task.status === "pending"
? { ...task, status: "cancelled" as const, speed: 0 }
: task
);
});
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "cancelled"));
}, 2000);
// Also reset uploading state
setUploading(false);
}, []);
const dismissTask = useCallback((taskId: string) => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, []);
return {
uploading,
uploadTasks,
dragActive,
handleDownload,
handleUploadMultiple,
handleUploadFromDrop,
handleFileSelect,
handleDrag,
handleDrop,
cancelUpload,
dismissTask,
};
};

View File

@@ -0,0 +1,123 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalVirtualListParams {
open: boolean;
sortedFiles: RemoteFile[];
}
interface UseSftpModalVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { file: RemoteFile; index: number; top: number }[];
}
export const useSftpModalVirtualList = ({
open,
sortedFiles,
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [rowHeight, setRowHeight] = useState(40);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [open, sortedFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open || sortedFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-modal-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [open, rowHeight, sortedFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedFiles
.slice(startIndex, endIndex + 1)
.map((file, idx) => ({
file,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedFiles.map((file, index) => ({
file,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -0,0 +1,83 @@
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
export const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
if (isLocalSession && isWindowsPath(path)) {
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
}
return path === "/";
};
export const getParentPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) return `${drive}\\`;
parts.pop();
return `${drive}\\${parts.join("\\")}`;
}
if (path === "/") return "/";
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join("/")}` : "/";
};
export const getRootPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const drive = path.replace(/\//g, "\\").slice(0, 2);
return `${drive}\\`;
}
return "/";
};
export const getWindowsDrive = (path: string): string | null => {
if (!isWindowsPath(path)) return null;
const normalized = path.replace(/\//g, "\\");
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
};
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
return parts;
}
return path === "/" ? [] : path.split("/").filter(Boolean);
};
export const breadcrumbPathAt = (
breadcrumbs: string[],
idx: number,
currentPath: string,
isLocalSession: boolean,
): string => {
if (isLocalSession) {
const drive = getWindowsDrive(currentPath);
if (drive) {
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
return rest ? `${drive}\\${rest}` : `${drive}\\`;
}
}
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
};

View File

@@ -0,0 +1,15 @@
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined, locale?: string): string => {
if (!dateStr) return "--";
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
return date.toLocaleString(locale || undefined);
};

View File

@@ -7,7 +7,7 @@
*/
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry } from "../../types";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
// Types for the context
export interface SftpPaneCallbacks {
@@ -16,6 +16,7 @@ export interface SftpPaneCallbacks {
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;

View File

@@ -38,9 +38,11 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
const filteredHosts = useMemo(() => {
const term = hostSearch.trim().toLowerCase();
return hosts.filter(h =>
!term ||
// Filter out serial hosts - SFTP is not supported for serial connections
h.protocol !== "serial" &&
(!term ||
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term)
h.hostname.toLowerCase().includes(term))
).sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, hostSearch]);
const sideLabel = side === 'left' ? t('common.left') : t('common.right');

View File

@@ -0,0 +1,196 @@
import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import { Button } from "../ui/button";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
type SftpState = ReturnType<typeof useSftpState>;
interface SftpOverlaysProps {
hosts: Host[];
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: (open: boolean) => void;
setShowHostPickerRight: (open: boolean) => void;
setHostSearchLeft: (value: string) => void;
setHostSearchRight: (value: string) => void;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setTextEditorTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
textEditorContent: string;
setTextEditorContent: (content: string) => void;
handleSaveTextFile: (content: string) => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
hosts,
sftp,
visibleTransfers,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
handleSaveTextFile,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleFileOpenerSelect,
handleSelectSystemApp,
}) => {
return (
<>
{/* Host pickers for adding new tabs */}
<SftpHostPicker
open={showHostPickerLeft}
onOpenChange={setShowHostPickerLeft}
hosts={hosts}
side="left"
hostSearch={hostSearchLeft}
onHostSearchChange={setHostSearchLeft}
onSelectLocal={() => handleHostSelectLeft("local")}
onSelectHost={handleHostSelectLeft}
/>
<SftpHostPicker
open={showHostPickerRight}
onOpenChange={setShowHostPickerRight}
hosts={hosts}
side="right"
hostSearch={hostSearchRight}
onHostSearchChange={setHostSearchRight}
onSelectLocal={() => handleHostSelectRight("local")}
onSelectHost={handleHostSelectRight}
/>
{/* Transfer status area - shows folder uploads and file transfers */}
{sftp.transfers.length > 0 && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
</span>
)}
</span>
{sftp.transfers.some(
(t) => t.status === "completed" || t.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={sftp.clearCompletedTransfers}
>
Clear completed
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
// External uploads use a different cancel mechanism
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
/>
))}
</div>
</div>
)}
<SftpConflictDialog
conflicts={sftp.conflicts}
onResolve={sftp.resolveConflict}
formatFileSize={sftp.formatFileSize}
/>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(file, permissions) => {
if (permissionsState) {
const fullPath = sftp.joinPath(
permissionsState.side === "left"
? sftp.leftPane.connection?.currentPath || ""
: sftp.rightPane.connection?.currentPath || "",
file.name,
);
sftp.changePermissions(
permissionsState.side,
fullPath,
permissions,
);
}
setPermissionsState(null);
}}
/>
{/* Text Editor Modal */}
<TextEditorModal
open={showTextEditor}
onClose={() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}}
fileName={textEditorTarget?.file.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
/>
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.file.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
</>
);
};

View File

@@ -0,0 +1,313 @@
import React from "react";
import { Loader2, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { SftpHostPicker } from "./index";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
newFolderName: string;
setNewFolderName: (value: string) => void;
handleCreateFolder: () => void;
isCreating: boolean;
// New file
showNewFileDialog: boolean;
setShowNewFileDialog: (open: boolean) => void;
newFileName: string;
setNewFileName: (value: string) => void;
fileNameError: string | null;
setFileNameError: (value: string | null) => void;
handleCreateFile: () => void;
isCreatingFile: boolean;
// Overwrite confirm
showOverwriteConfirm: boolean;
setShowOverwriteConfirm: (open: boolean) => void;
overwriteTarget: string | null;
handleOverwriteConfirm: () => void;
// Rename
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
// Delete
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
deleteTargets: string[];
handleDelete: () => void;
isDeleting: boolean;
// Host picker (connected view)
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hosts: Host[];
side: "left" | "right";
hostSearch: string;
setHostSearch: (value: string) => void;
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
}
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
setNewFolderName,
handleCreateFolder,
isCreating,
showNewFileDialog,
setShowNewFileDialog,
newFileName,
setNewFileName,
fileNameError,
setFileNameError,
handleCreateFile,
isCreatingFile,
showOverwriteConfirm,
setShowOverwriteConfirm,
overwriteTarget,
handleOverwriteConfirm,
showRenameDialog,
setShowRenameDialog,
renameName,
setRenameName,
handleRename,
isRenaming,
showDeleteConfirm,
setShowDeleteConfirm,
deleteTargets,
handleDelete,
isDeleting,
showHostPicker,
setShowHostPicker,
hosts,
side,
hostSearch,
setHostSearch,
onConnect,
onDisconnect,
}) => (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.folderName")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("sftp.folderName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFolderDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isCreating}
>
{isCreating && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
setShowNewFileDialog(open);
if (!open) {
setFileNameError(null);
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.fileName")}</Label>
<Input
value={newFileName}
onChange={(e) => {
setNewFileName(e.target.value);
setFileNameError(null);
}}
placeholder={t("sftp.fileName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
autoFocus
/>
{fileNameError && (
<div className="text-xs text-destructive">{fileNameError}</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFileDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowOverwriteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleOverwriteConfirm}
>
{t("sftp.overwrite.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.rename.newName")}</Label>
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleRename}
disabled={!renameName.trim() || isRenaming}
>
{isRenaming && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription>
{t("sftp.deleteConfirm.desc")}
</DialogDescription>
</DialogHeader>
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteTargets.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => {
onDisconnect();
onConnect("local");
}}
onSelectHost={(host) => {
onDisconnect();
onConnect(host);
}}
/>
</>
);

View File

@@ -0,0 +1,80 @@
import React from "react";
import { HardDrive, Monitor, Plus } from "lucide-react";
import { Button } from "../ui/button";
import { SftpHostPicker } from "./SftpHostPicker";
import type { Host } from "../../domain/models";
interface SftpPaneEmptyStateProps {
side: "left" | "right";
showEmptyHeader: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hostSearch: string;
setHostSearch: (value: string) => void;
hosts: Host[];
onConnect: (hostId: string) => void;
}
export const SftpPaneEmptyState: React.FC<SftpPaneEmptyStateProps> = ({
side,
showEmptyHeader,
t,
showHostPicker,
setShowHostPicker,
hostSearch,
setHostSearch,
hosts,
onConnect,
}) => {
return (
<div className="absolute inset-0 flex flex-col">
{showEmptyHeader && (
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
{side === "left" ? <Monitor size={14} /> : <HardDrive size={14} />}
<span>
{side === "left" ? t("sftp.pane.local") : t("sftp.pane.remote")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3"
onClick={() => setShowHostPicker(true)}
>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 p-6">
<div className="h-14 w-14 rounded-xl bg-secondary/60 text-primary flex items-center justify-center">
{side === "left" ? <Monitor size={24} /> : <HardDrive size={24} />}
</div>
<div>
<div className="text-sm font-semibold mb-1">
{t("sftp.pane.selectHostToStart")}
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.pane.chooseFilesystem")}
</div>
</div>
<Button onClick={() => setShowHostPicker(true)}>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => onConnect("local")}
onSelectHost={onConnect}
/>
</div>
);
};

View File

@@ -0,0 +1,433 @@
import React, { useCallback, useMemo } from "react";
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
import { isNavigableDirectory } from "./index";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
side: "left" | "right";
columnWidths: ColumnWidths;
sortField: SortField;
sortOrder: SortOrder;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
fileListRef: React.RefObject<HTMLDivElement>;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
sortedDisplayFiles: SftpFileEntry[];
isDragOverPane: boolean;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onRefresh: () => void;
setShowNewFolderDialog: (open: boolean) => void;
setShowNewFileDialog: (open: boolean) => void;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
// Row rendering
dragOverEntry: string | null;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (targets: string[]) => void;
rowHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
t,
pane,
side,
columnWidths,
sortField,
sortOrder,
handleSort,
handleResizeStart,
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
sortedDisplayFiles,
isDragOverPane,
draggedFiles,
onRefresh,
setShowNewFolderDialog,
setShowNewFileDialog,
getNextUntitledName,
setNewFileName,
setFileNameError,
dragOverEntry,
handleRowSelect,
handleRowOpen,
handleFileDragStart,
onDragEnd,
handleEntryDragOver,
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onOpenFileWith,
onEditFile,
onDownloadFile,
onEditPermissions,
openRenameDialog,
openDeleteConfirm,
rowHeight,
visibleRows,
}) => {
const filesByName = useMemo(() => {
const map = new Map<string, SftpFileEntry>();
sortedDisplayFiles.forEach((entry) => {
map.set(entry.name, entry);
});
return map;
}, [sortedDisplayFiles]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
<ContextMenuTrigger>
<SftpFileRow
entry={entry}
index={index}
isSelected={pane.selectedFiles.has(entry.name)}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
onSelect={handleRowSelect}
onOpen={handleRowOpen}
onDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
onDragOver={handleEntryDragOver}
onDragLeave={handleRowDragLeave}
onDrop={handleEntryDrop}
/>
</ContextMenuTrigger>
{entry.name !== ".." && (
<ContextMenuContent>
<ContextMenuItem onClick={() => handleRowOpen(entry)}>
{isNavigableDirectory(entry) ? (
<>
<Folder size={14} className="mr-2" /> {t("sftp.context.open")}
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</>
)}
</ContextMenuItem>
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.openWith")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && (
<ContextMenuItem onClick={() => onEditFile(entry)}>
<Edit2 size={14} className="mr-2" />{" "}
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
onCopyToOtherPane(fileData);
}}
>
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
</ContextMenuItem>
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
<ContextMenuItem onClick={() => onEditPermissions(entry)}>
<Shield size={14} className="mr-2" />{" "}
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
openDeleteConfirm(files);
}}
>
<Trash2 size={14} className="mr-2" /> {t("action.delete")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" /> {t("common.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
),
[
columnWidths,
dragOverEntry,
filesByName,
handleEntryDragOver,
handleEntryDrop,
handleFileDragStart,
handleRowDragLeave,
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
openRenameDialog,
pane.connection,
pane.selectedFiles,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
],
);
const fileRows = useMemo(
() =>
shouldVirtualize
? visibleRows.map(({ entry, index, top }) => (
<div
key={entry.name}
className="absolute left-0 right-0 border-b border-border/30"
style={{ top, height: rowHeight }}
>
{renderRow(entry, index)}
</div>
))
: sortedDisplayFiles.map((entry, index) => (
<React.Fragment key={entry.name}>
{renderRow(entry, index)}
</React.Fragment>
)),
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
);
return (
<>
{/* File list header */}
<div
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.size")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.kind")}</span>
</div>
</div>
{/* File list with empty area context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
</div>
) : (
<div
className={cn(
shouldVirtualize ? "relative" : "divide-y divide-border/30",
)}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{fileRows}
</div>
)}
{/* Drop overlay */}
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowDown size={32} />
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}>
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Footer */}
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
<span>
{t("sftp.itemsCount", {
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
})}
{pane.selectedFiles.size > 0 &&
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
</span>
<span className="truncate max-w-[200px]">
{pane.connection.currentPath}
</span>
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
{pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 size={32} className="animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,277 @@
import React from "react";
import { ChevronLeft, FilePlus, Folder, FolderPlus, Home, RefreshCw, Search, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { cn } from "../../lib/utils";
import { SftpBreadcrumb } from "./index";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onNavigateUp: () => void;
onNavigateTo: (path: string) => void;
onSetFilter: (value: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onRefresh: () => void;
showFilterBar: boolean;
setShowFilterBar: (open: boolean) => void;
filterInputRef: React.RefObject<HTMLInputElement>;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (open: boolean) => void;
showPathSuggestions: boolean;
setPathSuggestionIndex: (value: number) => void;
pathSuggestions: { path: string; type: "folder" | "history" }[];
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
startTransition: React.TransitionStartFunction;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
}
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
t,
pane,
onNavigateUp,
onNavigateTo,
onSetFilter,
onSetFilenameEncoding,
onRefresh,
showFilterBar,
setShowFilterBar,
filterInputRef,
isEditingPath,
editingPathValue,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
showPathSuggestions,
pathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
startTransition,
getNextUntitledName,
setNewFileName,
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
}) => (
<>
{/* Toolbar - always visible when connected */}
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
>
<ChevronLeft size={12} />
</Button>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (
<Select
value={pane.filenameEncoding}
onValueChange={(value) => onSetFilenameEncoding(value as SftpFilenameEncoding)}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]" title={t("sftp.encoding.label")}>
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}
title={t("sftp.newFile")}
>
<FilePlus size={14} />
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}}
title={t("sftp.filter")}
>
<Search size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
title={t("common.close")}
>
<X size={14} />
</Button>
</div>
)}
</>
);

View File

@@ -0,0 +1,370 @@
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
import {
useActiveTabId,
useSftpDrag,
useSftpHosts,
useSftpPaneCallbacks,
useSftpShowHiddenFiles,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
interface SftpPaneWrapperProps {
side: "left" | "right";
paneId: string;
isFirstPane: boolean;
children: React.ReactNode;
}
const SftpPaneWrapper = memo<SftpPaneWrapperProps>(({ side, paneId, isFirstPane, children }) => {
const activeTabId = useActiveTabId(side);
const isActive = activeTabId ? paneId === activeTabId : isFirstPane;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: "hidden", pointerEvents: "none" };
return (
<div
className={cn("absolute inset-0", isActive ? "z-10" : "z-0")}
style={containerStyle}
>
{children}
</div>
);
});
SftpPaneWrapper.displayName = "SftpPaneWrapper";
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
showHeader?: boolean;
showEmptyHeader?: boolean;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
showHeader = true,
showEmptyHeader = true,
}) => {
const isActive = true;
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
const { t } = useI18n();
const [, startTransition] = useTransition();
const [showFilterBar, setShowFilterBar] = useState(false);
const filterInputRef = useRef<HTMLInputElement>(null);
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
paneConnected: pane.connected,
panePath: pane.currentPath,
showHeader,
draggedFilesCount: draggedFiles?.length ?? 0,
});
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } = useSftpPaneSorting();
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles,
sortField,
sortOrder,
});
const {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
} = useSftpPanePath({
connection: pane.connection,
filteredFiles,
onNavigateTo: callbacks.onNavigateTo,
});
const {
showHostPicker,
hostSearch,
showNewFolderDialog,
newFolderName,
showNewFileDialog,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget: _renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
} = useSftpPaneDialogs({
t,
pane,
onCreateDirectory: callbacks.onCreateDirectory,
onCreateFile: callbacks.onCreateFile,
onRenameFile: callbacks.onRenameFile,
onDeleteFiles: callbacks.onDeleteFiles,
onClearSelection: callbacks.onClearSelection,
});
const {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
} = useSftpPaneDragAndSelect({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
onUploadExternalFiles: callbacks.onUploadExternalFiles,
onOpenEntry: callbacks.onOpenEntry,
onRangeSelect: callbacks.onRangeSelect,
onToggleSelection: callbacks.onToggleSelection,
});
const {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpPaneVirtualList({
isActive,
sortedDisplayFiles,
});
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
};
useEffect(() => {
logger.debug("SftpPaneView active state", {
side,
paneId: pane.id,
isActive,
});
}, [isActive, pane.id, side]);
if (!pane.connection) {
return (
<SftpPaneEmptyState
side={side}
showEmptyHeader={showEmptyHeader}
t={t}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
hosts={hosts}
onConnect={callbacks.onConnect}
/>
);
}
return (
<div
ref={paneContainerRef}
className={cn(
"absolute inset-0 flex flex-col transition-colors",
isDragOverPane && "bg-primary/5",
)}
onDragOver={handlePaneDragOver}
onDragLeave={handlePaneDragLeave}
onDrop={handlePaneDrop}
>
<SftpPaneToolbar
t={t}
pane={pane}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
onRefresh={callbacks.onRefresh}
showFilterBar={showFilterBar}
setShowFilterBar={setShowFilterBar}
filterInputRef={filterInputRef}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
setShowPathSuggestions={setShowPathSuggestions}
showPathSuggestions={showPathSuggestions}
setPathSuggestionIndex={setPathSuggestionIndex}
pathSuggestions={pathSuggestions}
pathSuggestionIndex={pathSuggestionIndex}
pathInputRef={pathInputRef}
pathDropdownRef={pathDropdownRef}
handlePathBlur={handlePathBlur}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
startTransition={startTransition}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
/>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
fileListRef={fileListRef}
handleFileListScroll={handleFileListScroll}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={callbacks.onRefresh}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
dragOverEntry={dragOverEntry}
handleRowSelect={handleRowSelect}
handleRowOpen={handleRowOpen}
handleFileDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
handleEntryDragOver={handleEntryDragOver}
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onEditPermissions={callbacks.onEditPermissions}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
<SftpPaneDialogs
t={t}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
setNewFolderName={setNewFolderName}
handleCreateFolder={handleCreateFolder}
isCreating={isCreating}
showNewFileDialog={showNewFileDialog}
setShowNewFileDialog={setShowNewFileDialog}
newFileName={newFileName}
setNewFileName={setNewFileName}
fileNameError={fileNameError}
setFileNameError={setFileNameError}
handleCreateFile={handleCreateFile}
isCreatingFile={isCreatingFile}
showOverwriteConfirm={showOverwriteConfirm}
setShowOverwriteConfirm={setShowOverwriteConfirm}
overwriteTarget={overwriteTarget}
handleOverwriteConfirm={handleConfirmOverwrite}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
deleteTargets={deleteTargets}
handleDelete={handleDelete}
isDeleting={isDeleting}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
onConnect={callbacks.onConnect}
onDisconnect={callbacks.onDisconnect}
/>
</div>
);
};
const sftpPaneViewAreEqual = (
prev: SftpPaneViewProps,
next: SftpPaneViewProps,
): boolean => {
if (prev.pane !== next.pane) return false;
if (prev.side !== next.side) return false;
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
return true;
};
const SftpPaneView = memo(SftpPaneViewInner, sftpPaneViewAreEqual);
SftpPaneView.displayName = "SftpPaneView";
export { SftpPaneView, SftpPaneWrapper };

View File

@@ -5,12 +5,13 @@
import {
ArrowDown,
CheckCircle2,
FolderUp,
Loader2,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React,{ memo } from 'react';
import React,{ memo, useRef, useEffect } from 'react';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -26,11 +27,49 @@ interface SftpTransferItemProps {
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
const speedFormatted = formatSpeed(task.speed);
// Use refs to store stable display values and prevent flickering
const lastSpeedRef = useRef<number>(0);
const lastSpeedTimeRef = useRef<number>(Date.now());
const displaySpeedRef = useRef<string>('');
// Update speed display with smoothing - only update if speed is stable for a moment
useEffect(() => {
if (task.status !== 'transferring') {
displaySpeedRef.current = '';
lastSpeedRef.current = 0;
return;
}
const now = Date.now();
const timeSinceLastUpdate = now - lastSpeedTimeRef.current;
// Only update speed display if:
// 1. Speed is above threshold (100 bytes/s)
// 2. Either it's been at least 500ms since last update, or speed changed significantly (>50%)
if (task.speed > 100) {
const speedChange = Math.abs(task.speed - lastSpeedRef.current);
const significantChange = lastSpeedRef.current > 0 && speedChange / lastSpeedRef.current > 0.5;
if (timeSinceLastUpdate >= 500 || significantChange || lastSpeedRef.current === 0) {
lastSpeedRef.current = task.speed;
lastSpeedTimeRef.current = now;
displaySpeedRef.current = formatSpeed(task.speed);
}
} else if (task.speed === 0 && lastSpeedRef.current > 0) {
// Don't immediately clear speed when it drops to 0
// Keep showing last speed for a short period
if (timeSinceLastUpdate >= 1000) {
lastSpeedRef.current = 0;
displaySpeedRef.current = '';
}
}
}, [task.speed, task.status]);
// Calculate remaining time based on stable speed
const remainingBytes = task.totalBytes - task.transferredBytes;
const remainingTime = task.speed > 0
? Math.ceil(remainingBytes / task.speed)
const stableSpeed = lastSpeedRef.current > 0 ? lastSpeedRef.current : task.speed;
const remainingTime = stableSpeed > 0
? Math.ceil(remainingBytes / stableSpeed)
: 0;
const remainingFormatted = remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
@@ -45,11 +84,17 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
? formatTransferBytes(task.totalBytes)
: '';
// Use the stable display speed
const speedFormatted = displaySpeedRef.current;
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
{task.status === 'pending' && <ArrowDown size={14} className="text-muted-foreground animate-bounce" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
@@ -59,10 +104,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
<div className="flex items-center gap-2">
<span className="text-sm truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
<span className="text-xs text-primary/80 font-mono">{speedFormatted}</span>
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
<span className="text-xs text-muted-foreground">{remainingFormatted}</span>
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
@@ -133,5 +178,44 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
);
};
export const SftpTransferItem = memo(SftpTransferItemInner);
// Custom comparison function to reduce unnecessary re-renders
// Only re-render if meaningful values change
const arePropsEqual = (
prevProps: SftpTransferItemProps,
nextProps: SftpTransferItemProps
): boolean => {
const prev = prevProps.task;
const next = nextProps.task;
// Always re-render on status change
if (prev.status !== next.status) return false;
// Always re-render on error change
if (prev.error !== next.error) return false;
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
// For transferring status, throttle updates based on progress
if (next.status === 'transferring') {
// Re-render if progress changed by more than 0.5%
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
if (Math.abs(nextProgress - prevProgress) >= 0.5) return false;
// Re-render periodically for speed updates (every ~500ms based on speed changes)
// The component uses refs to smooth speed display, so we allow more frequent renders
const speedDiff = Math.abs(next.speed - prev.speed);
if (speedDiff > 1000) return false; // Re-render if speed changed by more than 1KB/s
}
// For pending status, don't re-render unless status changes
if (next.status === 'pending') {
return true;
}
return true;
};
export const SftpTransferItem = memo(SftpTransferItemInner, arePropsEqual);
SftpTransferItem.displayName = 'SftpTransferItem';

View File

@@ -0,0 +1,283 @@
import { useCallback, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onRenameFile: SftpPaneCallbacks["onRenameFile"];
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
}
interface UseSftpPaneDialogsResult {
showHostPicker: boolean;
hostSearch: string;
showNewFolderDialog: boolean;
newFolderName: string;
showNewFileDialog: boolean;
newFileName: string;
fileNameError: string | null;
showOverwriteConfirm: boolean;
overwriteTarget: string | null;
showRenameDialog: boolean;
renameTarget: string | null;
renameName: string;
showDeleteConfirm: boolean;
deleteTargets: string[];
isCreating: boolean;
isCreatingFile: boolean;
isRenaming: boolean;
isDeleting: boolean;
setShowHostPicker: (open: boolean) => void;
setHostSearch: (value: string) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
setShowNewFileDialog: (open: boolean) => void;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowOverwriteConfirm: (open: boolean) => void;
setShowRenameDialog: (open: boolean) => void;
setRenameName: (value: string) => void;
setShowDeleteConfirm: (open: boolean) => void;
handleCreateFolder: () => Promise<void>;
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
}
export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateFile,
onRenameFile,
onDeleteFiles,
onClearSelection,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const validateFileName = useCallback(
(name: string): string | null => {
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
const trimmed = name.trim();
if (!trimmed) return null;
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
if (invalidMatch) {
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
}
const baseName = trimmed.split(".")[0].toUpperCase();
if (RESERVED_NAMES.has(baseName)) {
return t("sftp.error.reservedName");
}
return null;
},
[t],
);
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
if (!existingSet.has("untitled.txt")) {
return "untitled.txt";
}
let counter = 1;
while (counter < 1000) {
const name = `untitled (${counter}).txt`;
if (!existingSet.has(name.toLowerCase())) {
return name;
}
counter++;
}
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = async () => {
if (!newFolderName.trim() || isCreating) return;
setIsCreating(true);
try {
await onCreateDirectory(newFolderName.trim());
setShowNewFolderDialog(false);
setNewFolderName("");
} catch {
/* Error handling */
} finally {
setIsCreating(false);
}
};
const handleCreateFile = async (forceOverwrite = false) => {
const trimmedName = newFileName.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
if (error) {
setFileNameError(error);
return;
}
if (!forceOverwrite) {
const existingFile = pane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
if (existingFile) {
setOverwriteTarget(trimmedName);
setShowOverwriteConfirm(true);
return;
}
}
setIsCreatingFile(true);
try {
await onCreateFile(trimmedName);
setShowNewFileDialog(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setNewFileName("");
setFileNameError(null);
} catch {
/* Error handling */
} finally {
setIsCreatingFile(false);
}
};
const handleConfirmOverwrite = async () => {
await handleCreateFile(true);
};
const handleRename = async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
setIsRenaming(true);
try {
await onRenameFile(renameTarget, renameName.trim());
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch {
/* Error handling */
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (deleteTargets.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
await onDeleteFiles(deleteTargets);
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch {
/* Error handling */
} finally {
setIsDeleting(false);
}
};
const openRenameDialog = useCallback((name: string) => {
setRenameTarget(name);
setRenameName(name);
setShowRenameDialog(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
}, []);
return {
showHostPicker,
hostSearch,
showNewFolderDialog,
newFolderName,
showNewFileDialog,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
};
};

View File

@@ -0,0 +1,206 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
import { isNavigableDirectory } from "../index";
interface UseSftpPaneDragAndSelectParams {
side: "left" | "right";
pane: { selectedFiles: Set<string> };
sortedDisplayFiles: SftpFileEntry[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onDragStart: SftpDragCallbacks["onDragStart"];
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
onToggleSelection: SftpPaneCallbacks["onToggleSelection"];
}
interface UseSftpPaneDragAndSelectResult {
dragOverEntry: string | null;
isDragOverPane: boolean;
paneContainerRef: React.RefObject<HTMLDivElement>;
handlePaneDragOver: (e: React.DragEvent) => void;
handlePaneDragLeave: (e: React.DragEvent) => void;
handlePaneDrop: (e: React.DragEvent) => Promise<void>;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
}
export const useSftpPaneDragAndSelect = ({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane,
onUploadExternalFiles,
onOpenEntry,
onRangeSelect,
onToggleSelection,
}: UseSftpPaneDragAndSelectParams): UseSftpPaneDragAndSelectResult => {
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
const [isDragOverPane, setIsDragOverPane] = useState(false);
const paneContainerRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
const selectedFilesRef = useRef(pane.selectedFiles);
const sortedFilesRef = useRef(sortedDisplayFiles);
useEffect(() => {
selectedFilesRef.current = pane.selectedFiles;
}, [pane.selectedFiles]);
useEffect(() => {
sortedFilesRef.current = sortedDisplayFiles;
}, [sortedDisplayFiles]);
const handlePaneDragOver = (e: React.DragEvent) => {
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
};
const handlePaneDragLeave = (e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
setIsDragOverPane(false);
setDragOverEntry(null);
};
const handlePaneDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
if (draggedFiles && draggedFiles.length > 0) {
if (draggedFiles[0]?.side !== side) {
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
return;
}
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
}
};
const handleFileDragStart = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (entry.name === "..") {
e.preventDefault();
return;
}
const selectedNames = Array.from(selectedFilesRef.current);
const files = selectedNames.includes(entry.name)
? sortedFilesRef.current
.filter((f) => selectedNames.includes(f.name))
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),
side,
}))
: [
{
name: entry.name,
isDirectory: isNavigableDirectory(entry),
side,
},
];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
},
[onDragStart, side],
);
const handleEntryDragOver = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
},
[draggedFiles, side],
);
const handleEntryDrop = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
},
[draggedFiles, onReceiveFromOtherPane, side],
);
const handleRowSelect = useCallback(
(entry: SftpFileEntry, index: number, e: React.MouseEvent) => {
if (entry.name === "..") return;
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const selectedFileNames = sortedDisplayFiles
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
onRangeSelect(selectedFileNames);
} else {
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
lastSelectedIndexRef.current = index;
}
},
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
);
const handleRowOpen = useCallback(
(entry: SftpFileEntry) => {
onOpenEntry(entry);
},
[onOpenEntry],
);
const handleRowDragLeave = useCallback(() => {
setDragOverEntry(null);
}, []);
return {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
};
};

View File

@@ -0,0 +1,99 @@
import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles } from "../index";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
sortField: SortField;
sortOrder: SortOrder;
}
interface UseSftpPaneFilesResult {
filteredFiles: SftpFileEntry[];
displayFiles: SftpFileEntry[];
sortedDisplayFiles: SftpFileEntry[];
}
export const useSftpPaneFiles = ({
files,
filter,
connection,
showHiddenFiles,
sortField,
sortOrder,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
const filteredFiles = useMemo(() => {
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [files, filter, showHiddenFiles]);
const displayFiles = useMemo(() => {
if (!connection) return [];
const isRootPath =
connection.currentPath === "/" ||
/^[A-Za-z]:[\\/]?$/.test(connection.currentPath);
if (isRootPath) return filteredFiles;
const parentEntry: SftpFileEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")] ;
}, [connection, filteredFiles]);
const sortedDisplayFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
if (sortField !== "type") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size":
cmp = (a.size || 0) - (b.size || 0);
break;
case "modified":
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case "type": {
const extA =
a.type === "directory"
? "folder"
: a.name.split(".").pop()?.toLowerCase() || "";
const extB =
b.type === "directory"
? "folder"
: b.name.split(".").pop()?.toLowerCase() || "";
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -0,0 +1,160 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isNavigableDirectory } from "../index";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
filteredFiles: SftpFileEntry[];
onNavigateTo: (path: string) => void;
}
interface UseSftpPanePathResult {
isEditingPath: boolean;
editingPathValue: string;
showPathSuggestions: boolean;
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
pathSuggestions: { path: string; type: "folder" | "history" }[];
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (value: boolean) => void;
setPathSuggestionIndex: (value: number) => void;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
}
export const useSftpPanePath = ({
connection,
filteredFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
const pathSuggestions = useMemo(() => {
if (!isEditingPath || !connection) return [];
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filteredFiles.filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
connection.currentPath === "/"
? `/${f.name}`
: `${connection.currentPath}/${f.name}`;
if (
!currentValue ||
fullPath.toLowerCase().includes(currentValue) ||
f.name.toLowerCase().includes(currentValue)
) {
suggestions.push({ path: fullPath, type: "folder" });
}
});
const quickPaths = ["/home", "/var", "/etc", "/tmp", "/usr", "/opt", "/root"];
quickPaths.forEach((qp) => {
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
if (!suggestions.some((s) => s.path === qp)) {
suggestions.push({ path: qp, type: "history" });
}
}
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
const handlePathDoubleClick = () => {
if (!connection) return;
setEditingPathValue(connection.currentPath);
setIsEditingPath(true);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = useCallback((pathOverride?: string) => {
const newPath = (pathOverride ?? editingPathValue).trim() || "/";
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
if (connection && newPath !== connection.currentPath) {
const isWindowsPath = /^[A-Za-z]:/.test(newPath);
if (isWindowsPath) {
let normalizedPath = newPath;
if (/^[A-Za-z]:[\\/]?$/.test(newPath)) {
normalizedPath = newPath.charAt(0).toUpperCase() + ":\\";
}
onNavigateTo(normalizedPath);
} else {
onNavigateTo(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
}, [connection, editingPathValue, onNavigateTo]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (showPathSuggestions && pathSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
);
return;
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
e.preventDefault();
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
return;
}
}
if (e.key === "Enter") {
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
} else {
handlePathSubmit();
}
} else if (e.key === "Escape") {
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
}
};
const handlePathBlur = useCallback(() => {
setTimeout(() => {
if (!pathDropdownRef.current?.contains(document.activeElement)) {
handlePathSubmit();
}
}, 150);
}, [handlePathSubmit]);
return {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
};
};

View File

@@ -0,0 +1,78 @@
import React, { useCallback, useRef, useState } from "react";
import type { ColumnWidths, SortField, SortOrder } from "../utils";
interface UseSftpPaneSortingResult {
sortField: SortField;
sortOrder: SortOrder;
columnWidths: ColumnWidths;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
}
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 45,
modified: 25,
size: 15,
type: 15,
});
const resizingRef = useRef<{
field: keyof ColumnWidths;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
e: React.MouseEvent,
) => {
e.preventDefault();
e.stopPropagation();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
columnWidths,
handleSort,
handleResizeStart,
};
};

View File

@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
interface UseSftpPaneVirtualListParams {
isActive: boolean;
sortedDisplayFiles: SftpFileEntry[];
}
interface UseSftpPaneVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
export const useSftpPaneVirtualList = ({
isActive,
sortedDisplayFiles,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const [rowHeight, setRowHeight] = useState(0);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf1 = window.requestAnimationFrame(update);
const raf2 = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
};
}, [isActive, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || sortedDisplayFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [isActive, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!isActive) return;
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[isActive],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = isActive && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedDisplayFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedDisplayFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedDisplayFiles
.slice(startIndex, endIndex + 1)
.map((entry, idx) => ({
entry,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedDisplayFiles.map((entry, index) => ({
entry,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -0,0 +1,441 @@
import React, { useCallback, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../index";
interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}
interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
textEditorTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setTextEditorTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
textEditorContent: string;
setTextEditorContent: React.Dispatch<React.SetStateAction<string>>;
loadingTextContent: boolean;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: React.Dispatch<React.SetStateAction<boolean>>;
fileOpenerTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setFileOpenerTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
handleSaveTextFile: (content: string) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
onEditPermissionsLeft: (file: SftpFileEntry) => void;
onEditPermissionsRight: (file: SftpFileEntry) => void;
onOpenEntryLeft: (entry: SftpFileEntry) => void;
onOpenEntryRight: (entry: SftpFileEntry) => void;
onEditFileLeft: (file: SftpFileEntry) => void;
onEditFileRight: (file: SftpFileEntry) => void;
onOpenFileLeft: (file: SftpFileEntry) => void;
onOpenFileRight: (file: SftpFileEntry) => void;
onOpenFileWithLeft: (file: SftpFileEntry) => void;
onOpenFileWithRight: (file: SftpFileEntry) => void;
onDownloadFileLeft: (file: SftpFileEntry) => void;
onDownloadFileRight: (file: SftpFileEntry) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer) => void;
}
export const useSftpViewFileOps = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
[],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
[],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath });
const content = await sftpRef.current.readTextFile(side, fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
setTextEditorTarget(null);
} finally {
setLoadingTextContent(false);
}
},
[sftpRef],
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const savedOpener = getOpenerForFileRef.current(file.name);
if (savedOpener && savedOpener.openerType) {
if (savedOpener.openerType === "builtin-editor") {
handleEditFileForSide(side, file);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
return;
}
}
setFileOpenerTarget({ file, side, fullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
fileOpenerTarget.side,
fileOpenerTarget.fullPath,
fileOpenerTarget.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
}
setFileOpenerTarget(null);
},
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await sftpRef.current.selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [sftpRef]);
const handleSaveTextFile = useCallback(
async (content: string) => {
if (!textEditorTarget) return;
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,
content,
);
},
[textEditorTarget, sftpRef],
);
const onEditFileLeft = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("left", file),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("right", file),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
[handleOpenFileForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
// Check if upload was cancelled
if (results.some((r) => r.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
const successCount = results.filter((r) => r.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0].fileName}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
[handleUploadExternalFilesForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
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);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
[handleDownloadFileForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("left", entry);
return;
}
if (behaviorRef.current === "transfer") {
const fileData = [{
name: entry.name,
isDirectory: isDir,
}];
sftpRef.current.startTransfer(fileData, "left", "right");
} else {
onOpenFileLeft(entry);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry) => {
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("right", entry);
return;
}
if (behaviorRef.current === "transfer") {
const fileData = [{
name: entry.name,
isDirectory: isDir,
}];
sftpRef.current.startTransfer(fileData, "right", "left");
} else {
onOpenFileRight(entry);
}
},
[sftpRef, onOpenFileRight, behaviorRef],
);
return {
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
handleFileOpenerSelect,
handleSelectSystemApp,
onEditPermissionsLeft,
onEditPermissionsRight,
onOpenEntryLeft,
onOpenEntryRight,
onEditFileLeft,
onEditFileRight,
onOpenFileLeft,
onOpenFileRight,
onOpenFileWithLeft,
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
};
};

View File

@@ -0,0 +1,224 @@
import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks } from "../SftpContext";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
}
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
onToggleSelectionRight: (name: string, multi: boolean) => void;
onRangeSelectLeft: (fileNames: string[]) => void;
onRangeSelectRight: (fileNames: string[]) => void;
onClearSelectionLeft: () => void;
onClearSelectionRight: () => void;
onSetFilterLeft: (filter: string) => void;
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onRenameFileLeft: (old: string, newName: string) => void;
onRenameFileRight: (old: string, newName: string) => void;
onCopyToOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onCopyToOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
}
export const useSftpViewPaneActions = ({
sftpRef,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const [draggedFiles, setDraggedFiles] = useState<
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
>(null);
const handleDragStart = useCallback(
(
files: { name: string; isDirectory: boolean }[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
},
[],
);
const handleDragEnd = useCallback(() => {
setDraggedFiles(null);
}, []);
const onCopyToOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
);
const onCopyToOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
);
const onConnectLeft = useCallback(
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("left", host),
[sftpRef],
);
const onConnectRight = useCallback(
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
[sftpRef],
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
);
const onNavigateToRight = useCallback(
(path: string) => sftpRef.current.navigateTo("right", path),
[sftpRef],
);
const onNavigateUpLeft = useCallback(() => sftpRef.current.navigateUp("left"), [sftpRef]);
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
[sftpRef],
);
const onSetFilenameEncodingRight = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("right", encoding),
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
[sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
[sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
[sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
[sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
const onSetFilterLeft = useCallback(
(filter: string) => sftpRef.current.setFilter("left", filter),
[sftpRef],
);
const onSetFilterRight = useCallback(
(filter: string) => sftpRef.current.setFilter("right", filter),
[sftpRef],
);
const onCreateDirectoryLeft = useCallback(
(name: string) => sftpRef.current.createDirectory("left", name),
[sftpRef],
);
const onCreateDirectoryRight = useCallback(
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
);
const onCreateFileRight = useCallback(
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
);
const onDeleteFilesRight = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
);
const onRenameFileRight = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
}),
[handleDragStart, handleDragEnd],
);
return {
dragCallbacks,
draggedFiles,
onConnectLeft,
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
onToggleSelectionRight,
onRangeSelectLeft,
onRangeSelectRight,
onClearSelectionLeft,
onClearSelectionRight,
onSetFilterLeft,
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateFileLeft,
onCreateFileRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onRenameFileLeft,
onRenameFileRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
};

View File

@@ -0,0 +1,124 @@
import { useMemo } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpPaneCallbacks } from "../SftpContext";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}
export const useSftpViewPaneCallbacks = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
});
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
onRangeSelect: paneActions.onRangeSelectLeft,
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateFile: paneActions.onCreateFileLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onRenameFile: paneActions.onRenameFileLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
onOpenFile: fileOps.onOpenFileLeft,
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
}),
[],
);
const rightCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
onRangeSelect: paneActions.onRangeSelectRight,
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateFile: paneActions.onCreateFileRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onRenameFile: paneActions.onRenameFileRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
onOpenFile: fileOps.onOpenFileRight,
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
}),
[],
);
/* eslint-enable react-hooks/exhaustive-deps */
return {
leftCallbacks,
rightCallbacks,
dragCallbacks: paneActions.dragCallbacks,
draggedFiles: paneActions.draggedFiles,
permissionsState: fileOps.permissionsState,
setPermissionsState: fileOps.setPermissionsState,
showTextEditor: fileOps.showTextEditor,
setShowTextEditor: fileOps.setShowTextEditor,
textEditorTarget: fileOps.textEditorTarget,
setTextEditorTarget: fileOps.setTextEditorTarget,
textEditorContent: fileOps.textEditorContent,
setTextEditorContent: fileOps.setTextEditorContent,
loadingTextContent: fileOps.loadingTextContent,
showFileOpenerDialog: fileOps.showFileOpenerDialog,
setShowFileOpenerDialog: fileOps.setShowFileOpenerDialog,
fileOpenerTarget: fileOps.fileOpenerTarget,
setFileOpenerTarget: fileOps.setFileOpenerTarget,
handleSaveTextFile: fileOps.handleSaveTextFile,
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
handleSelectSystemApp: fileOps.handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,159 @@
import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: React.Dispatch<React.SetStateAction<boolean>>;
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => void;
handleAddTabRight: () => void;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const handleAddTabLeft = useCallback(() => {
sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
sftpRef.current.addTab("right");
setShowHostPickerRight(true);
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {
sftpRef.current.closeTab("left", tabId);
}, [sftpRef]);
const handleCloseTabRight = useCallback((tabId: string) => {
sftpRef.current.closeTab("right", tabId);
}, [sftpRef]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);
}, [sftpRef]);
const handleSelectTabRight = useCallback((tabId: string) => {
sftpRef.current.selectTab("right", tabId);
}, [sftpRef]);
const leftPanes = useMemo(
() => (sftp.leftTabs.tabs.length > 0 ? sftp.leftTabs.tabs : [sftp.leftPane]),
[sftp.leftTabs.tabs, sftp.leftPane],
);
const rightPanes = useMemo(
() => (sftp.rightTabs.tabs.length > 0 ? sftp.rightTabs.tabs : [sftp.rightPane]),
[sftp.rightTabs.tabs, sftp.rightPane],
);
const handleReorderTabsLeft = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("left", draggedId, targetId, position);
},
[sftpRef],
);
const handleReorderTabsRight = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("right", draggedId, targetId, position);
},
[sftpRef],
);
const handleMoveTabFromLeftToRight = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("left", tabId);
}, [sftpRef]);
const handleMoveTabFromRightToLeft = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleHostSelectLeft = useCallback((host: Host | "local") => {
sftpRef.current.connect("left", host);
setShowHostPickerLeft(false);
}, [sftpRef]);
const handleHostSelectRight = useCallback((host: Host | "local") => {
sftpRef.current.connect("right", host);
setShowHostPickerRight(false);
}, [sftpRef]);
const leftTabsInfo = useMemo(
() =>
sftp.leftTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
})),
[sftp.leftTabs.tabs],
);
const rightTabsInfo = useMemo(
() =>
sftp.rightTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
})),
[sftp.rightTabs.tabs],
);
return {
leftPanes,
rightPanes,
leftTabsInfo,
rightTabsInfo,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleAddTabLeft,
handleAddTabRight,
handleCloseTabLeft,
handleCloseTabRight,
handleSelectTabLeft,
handleSelectTabRight,
handleReorderTabsLeft,
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleHostSelectLeft,
handleHostSelectRight,
};
};

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isLinux: boolean; // Only collect stats for Linux servers
isConnected: boolean; // Only collect when connected
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isLinux,
isConnected,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: [],
lastUpdated: null,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const fetchStats = useCallback(async () => {
if (!enabled || !isLinux || !isConnected || !sessionId) {
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) {
return;
}
setIsLoading(true);
setError(null);
try {
const result = await bridge.getServerStats(sessionId);
if (!isMountedRef.current) return;
if (result.success && result.stats) {
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
cpuPerCore: result.stats.cpuPerCore || [],
memTotal: result.stats.memTotal,
memUsed: result.stats.memUsed,
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
diskTotal: result.stats.diskTotal,
disks: result.stats.disks || [],
netRxSpeed: result.stats.netRxSpeed || 0,
netTxSpeed: result.stats.netTxSpeed || 0,
netInterfaces: result.stats.netInterfaces || [],
lastUpdated: Date.now(),
});
} else if (result.error) {
setError(result.error);
}
} catch (err) {
if (isMountedRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [sessionId, enabled, isLinux, isConnected]);
// Initial fetch and periodic refresh
useEffect(() => {
isMountedRef.current = true;
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Don't run if not enabled or not a Linux system
if (!enabled || !isLinux || !isConnected) {
// Reset stats when disabled or not connected
setStats({
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: [],
lastUpdated: null,
});
return;
}
// Initial fetch with a small delay to let the connection stabilize
const initialTimer = setTimeout(() => {
fetchStats();
}, 2000);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
intervalRef.current = setInterval(fetchStats, intervalMs);
return () => {
isMountedRef.current = false;
clearTimeout(initialTimer);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {
fetchStats();
}, [fetchStats]);
return {
stats,
isLoading,
error,
refresh,
};
}

View File

@@ -21,7 +21,7 @@ export type { TerminalContextMenuProps } from './TerminalContextMenu';
export { TerminalSearchBar } from './TerminalSearchBar';
export type { TerminalSearchBarProps } from './TerminalSearchBar';
export { createHighlightProcessor, highlightKeywords, compileHighlightRules } from './keywordHighlight';
export { KeywordHighlighter } from './keywordHighlight';
export { useTerminalSearch } from './hooks/useTerminalSearch';
export { useTerminalContextActions } from './hooks/useTerminalContextActions';

View File

@@ -1,136 +1,218 @@
import { Terminal as XTerm, IDecoration, IDisposable, IMarker, IBufferLine } from "@xterm/xterm";
import { KeywordHighlightRule } from "../../types";
// ESC character as unicode escape for ESLint compatibility
const ESC = "\u001b";
/**
* Convert a hex color to ANSI 24-bit true color escape sequence
* Format: ESC[38;2;R;G;Bm for foreground color
*/
function hexToAnsi(hex: string): string {
// Remove # if present
const cleanHex = hex.replace("#", "");
const r = parseInt(cleanHex.slice(0, 2), 16);
const g = parseInt(cleanHex.slice(2, 4), 16);
const b = parseInt(cleanHex.slice(4, 6), 16);
return `${ESC}[38;2;${r};${g};${b}m`;
}
const ANSI_RESET = `${ESC}[0m`;
// Regex to match ANSI escape sequences (to skip them during highlighting)
// Using RegExp constructor to avoid ESLint control character warning
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_REGEX = /\u001b\[[0-9;]*[a-zA-Z]/g;
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
/** Pre-compiled rule with regex ready for matching */
interface CompiledRule {
regex: RegExp;
ansiColor: string;
color: string;
}
/**
* Pre-compile keyword highlight rules for better performance
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
* This ensures zero impact on scrolling performance ("lazy" highlighting).
*/
export function compileHighlightRules(
rules: KeywordHighlightRule[],
enabled: boolean
): CompiledRule[] {
if (!enabled) return [];
return rules
.filter((rule) => rule.enabled && rule.patterns.length > 0)
.map((rule) => {
// Combine all patterns with OR, case-insensitive
const combinedPattern = rule.patterns.join("|");
return {
regex: new RegExp(`(${combinedPattern})`, "gi"),
ansiColor: hexToAnsi(rule.color),
};
});
}
export class KeywordHighlighter implements IDisposable {
private term: XTerm;
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private enabled: boolean = false;
private disposables: IDisposable[] = [];
/**
* Apply keyword highlighting to terminal output
* This processes text and adds ANSI color codes for matched keywords
*
* Note: This is a simplified approach that works well for most cases.
* It processes the text while preserving existing ANSI escape sequences.
*/
export function highlightKeywords(
text: string,
compiledRules: CompiledRule[]
): string {
if (compiledRules.length === 0 || !text) {
return text;
constructor(term: XTerm) {
this.term = term;
// Debug logging
console.log('[KeywordHighlighter] Initialized');
// Hook into terminal events to trigger highlighting
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
}),
// When new data is written, refresh
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh())
);
}
// Split text into segments: ANSI sequences and regular text
const segments: Array<{ isAnsi: boolean; content: string }> = [];
let lastIndex = 0;
// Find all ANSI escape sequences
let match: RegExpExecArray | null;
const ansiRegex = new RegExp(ANSI_ESCAPE_REGEX.source, "g");
while ((match = ansiRegex.exec(text)) !== null) {
// Add text before this ANSI sequence
if (match.index > lastIndex) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex, match.index),
});
}
// Add the ANSI sequence itself
segments.push({
isAnsi: true,
content: match[0],
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after last ANSI sequence
if (lastIndex < text.length) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex),
});
}
// Process only non-ANSI segments
const processedSegments = segments.map((segment) => {
if (segment.isAnsi) {
return segment.content;
}
let processed = segment.content;
// Apply each rule
for (const rule of compiledRules) {
processed = processed.replace(rule.regex, (matched) => {
return `${rule.ansiColor}${matched}${ANSI_RESET}`;
});
}
return processed;
});
return processedSegments.join("");
}
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
/**
* Create a highlight processor function with pre-compiled rules
* Use this for better performance when processing multiple chunks
*/
export function createHighlightProcessor(
rules: KeywordHighlightRule[],
enabled: boolean
): (text: string) => string {
const compiledRules = compileHighlightRules(rules, enabled);
if (compiledRules.length === 0) {
// Return identity function if no rules are enabled
return (text: string) => text;
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
this.compiledRules = [];
for (const rule of rules) {
if (!rule.enabled || rule.patterns.length === 0) continue;
for (const pattern of rule.patterns) {
try {
this.compiledRules.push({
regex: new RegExp(pattern, "gi"),
color: rule.color,
});
} catch (err) {
console.error("Invalid regex pattern:", pattern, err);
}
}
}
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
}
}
public dispose() {
this.clearDecorations();
this.disposables.forEach(d => d.dispose());
this.disposables = [];
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
}
private triggerRefresh() {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
// These apps manage their own highlighting and have rapid repaints.
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) {
this.clearDecorations();
}
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
}
private clearDecorations() {
this.decorations.forEach(({ decoration, marker }) => {
decoration.dispose();
marker.dispose();
});
this.decorations = [];
}
/**
* Build a mapping from string character index to terminal cell column.
* This handles wide characters (CJK, emoji) and combining characters correctly.
*
* For example, with "A中B":
* - String indices: 0='A', 1='中', 2='B'
* - Cell columns: 0='A', 1='中'(width 2), 3='B'
* - Result map: [0, 1, 3, 4] (includes end position)
*/
private buildStringToCellMap(line: IBufferLine): number[] {
const map: number[] = [];
let cellCol = 0;
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col);
if (!cell) break;
const chars = cell.getChars();
const width = cell.getWidth();
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
cellCol += width;
}
// Add final position for calculating end column of matches
map.push(cellCol);
return map;
}
private refreshViewport() {
// Safety check just in case
if (!this.term?.buffer?.active) return;
const buffer = this.term.buffer.active;
const viewportY = buffer.viewportY;
const rows = this.term.rows;
const cursorY = buffer.cursorY;
const baseY = buffer.baseY;
const cursorAbsoluteY = baseY + cursorY;
// Clear old decorations to avoid duplicates/memory leaks
this.clearDecorations();
// Iterate only over the visible rows
for (let y = 0; y < rows; y++) {
const lineY = viewportY + y;
const line = buffer.getLine(lineY);
if (!line) continue;
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
}
return (text: string) => highlightKeywords(text, compiledRules);
}

View File

@@ -74,7 +74,6 @@ export type TerminalSessionStartersContext = {
disposeExitRef: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
highlightProcessorRef: RefObject<(text: string) => string>;
pendingAuthRef: RefObject<PendingAuth>;
updateStatus: (next: TerminalSession["status"]) => void;
@@ -133,7 +132,7 @@ const attachSessionToTerminal = (
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(ctx.highlightProcessorRef.current(data));
term.write(data);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
@@ -218,12 +217,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
@@ -247,12 +246,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
: undefined;
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
@@ -348,9 +347,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
let id: string;
const hasKeyMaterial = !!key?.privateKey;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasPassword = !!effectivePassword;
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });
@@ -553,7 +555,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(ctx.highlightProcessorRef.current(chunk));
term.write(chunk);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
setTimeout(() => {

View File

@@ -11,6 +11,7 @@ import {
getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import {
XTERM_PERFORMANCE_CONFIG,
type XTermPlatform,
@@ -41,6 +42,7 @@ export type XTermRuntime = {
dispose: () => void;
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
keywordHighlighter: KeywordHighlighter;
};
export type CreateXTermRuntimeContext = {
@@ -111,9 +113,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? (navigator as { deviceMemory?: number }).deviceMemory
: undefined;
const settings = ctx.terminalSettingsRef.current;
const rendererType = settings?.rendererType ?? "auto";
const performanceConfig = resolveXTermPerformanceConfig({
platform,
deviceMemoryGb,
rendererType,
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
@@ -123,11 +129,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
const settings = ctx.terminalSettingsRef.current;
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
const fontLigatures = settings?.fontLigatures ?? true;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = settings?.fontWeight ?? 400;
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -136,6 +140,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const scrollOnUserInput = settings?.scrollOnInput ?? true;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
const resolvedFontWeightBold = (() => {
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
const term = new XTerm({
...performanceConfig.options,
@@ -153,7 +167,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
| 900
| "normal"
| "bold",
fontWeightBold: fontWeightBold as
fontWeightBold: resolvedFontWeightBold as
| 100
| 200
| 300
@@ -169,7 +183,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cursorStyle,
cursorBlink,
scrollback,
allowProposedApi: fontLigatures,
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
scrollOnUserInput,
@@ -534,13 +549,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}, resizeDebounceMs);
});
const keywordHighlighter = new KeywordHighlighter(term);
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
return {
term,
fitAddon,
serializeAddon,
searchAddon,
keywordHighlighter,
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -42,6 +42,8 @@ export function Combobox({
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
// Track if user is actively searching (typed something after opening)
const [isSearching, setIsSearching] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
// Sync input value with external value when not focused
@@ -49,11 +51,13 @@ export function Combobox({
if (!open) {
const selected = options.find((opt) => opt.value === value)
setInputValue(selected?.label || value || "")
setIsSearching(false)
}
}, [value, options, open])
// Show all options when dropdown is open but user hasn't started searching
const filteredOptions = React.useMemo(() => {
if (!inputValue.trim()) return options
if (!isSearching || !inputValue.trim()) return options
const lower = inputValue.toLowerCase()
return options.filter(
(opt) =>
@@ -61,13 +65,13 @@ export function Combobox({
opt.value.toLowerCase().includes(lower) ||
opt.sublabel?.toLowerCase().includes(lower)
)
}, [options, inputValue])
}, [options, inputValue, isSearching])
const showCreateOption = React.useMemo(() => {
if (!allowCreate || !inputValue.trim()) return false
if (!allowCreate || !inputValue.trim() || !isSearching) return false
const lower = inputValue.toLowerCase().trim()
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
}, [allowCreate, inputValue, options])
}, [allowCreate, inputValue, options, isSearching])
const handleSelect = (optValue: string) => {
onValueChange?.(optValue)
@@ -87,6 +91,7 @@ export function Combobox({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
setIsSearching(true)
if (!open) setOpen(true)
}

View File

@@ -20,6 +20,42 @@ const getContextMenuPortalEl = () => {
zIndex: "2147483647", // max safe z-index to avoid being covered
pointerEvents: "none",
});
// Intercept aria-hidden attribute to prevent it from being set when menu is open
// This avoids "Blocked aria-hidden on an element because its descendant retained focus" warnings
let ariaHiddenValue: string | null = null;
Object.defineProperty(portal, "ariaHidden", {
get() {
return ariaHiddenValue;
},
set(value: string | null) {
// Block aria-hidden="true" when there are children (menu is open)
if (value === "true" && portal && portal.children.length > 0) {
return;
}
ariaHiddenValue = value;
},
configurable: true,
});
// Also override setAttribute for aria-hidden
const originalSetAttribute = portal.setAttribute.bind(portal);
portal.setAttribute = function (name: string, value: string) {
if (name === "aria-hidden" && value === "true" && portal && portal.children.length > 0) {
return;
}
originalSetAttribute(name, value);
};
// Override removeAttribute to sync our internal state
const originalRemoveAttribute = portal.removeAttribute.bind(portal);
portal.removeAttribute = function (name: string) {
if (name === "aria-hidden") {
ariaHiddenValue = null;
}
originalRemoveAttribute(name);
};
document.body.appendChild(portal);
}
return portal;

View File

@@ -44,6 +44,7 @@ const DialogContent = React.forwardRef<
className
)}
style={{ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)' }}
aria-describedby={undefined}
{...props}
>
{children}

View File

@@ -0,0 +1,30 @@
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from "react"
import { cn } from "../../lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-[999999] rounded-md border border-border/60 bg-popover p-4 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -65,6 +65,7 @@ export interface Host {
identityFileId?: string; // Reference to SSHKey
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
password?: string;
savePassword?: boolean; // Whether to save the password (default: true)
authMethod?: 'password' | 'key' | 'certificate';
agentForwarding?: boolean;
createdAt?: number; // Timestamp when host was created
@@ -90,6 +91,9 @@ export interface Host {
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -383,6 +387,13 @@ export interface TerminalSettings {
// SSH Connection
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
// Server Stats Display (Linux only)
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
serverStatsRefreshInterval: number; // Seconds between stats refresh (default: 30)
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
}
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
@@ -421,6 +432,9 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
localShell: '', // Empty = use system default
localStartDir: '', // Empty = use home directory
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
rendererType: 'auto', // Auto-detect best renderer based on hardware
};
export interface TerminalTheme {
@@ -504,6 +518,8 @@ export interface Workspace {
}
// SFTP Types
export type SftpFilenameEncoding = 'auto' | 'utf-8' | 'gb18030';
export interface SftpFileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
@@ -629,3 +645,12 @@ export interface ConnectionLog {
themeId?: string; // Terminal theme ID for this log view
fontSize?: number; // Terminal font size for this log view
}
// Session Logs Settings - for auto-saving terminal logs to local filesystem
export type SessionLogFormat = 'txt' | 'raw' | 'html';
export interface SessionLogsSettings {
enabled: boolean; // Whether auto-save is enabled
directory: string; // Base directory for logs
format: SessionLogFormat; // Log file format
}

View File

@@ -57,11 +57,12 @@ export const resolveHostAuth = (args: {
host.username?.trim() ||
"";
const keyId =
override?.keyId ||
identity?.keyId ||
host.identityFileId ||
undefined;
// Don't load key when explicit password auth is requested
// This ensures user's auth method selection is strictly respected
const keyId = override?.authMethod === 'password'
? undefined
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;

View File

@@ -10,6 +10,9 @@ const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
// Lazy-load encodePathForSession to avoid circular dependency issues
let encodePathForSession = null;
// Map of watchId -> { watcher, localPath, remotePath, sftpId, lastModified, lastSize }
const activeWatchers = new Map();
@@ -78,7 +81,7 @@ function showSystemNotification(title, body) {
* Start watching a local file for changes
* Returns a watchId that can be used to stop watching
*/
async function startWatching(event, { localPath, remotePath, sftpId }) {
async function startWatching(event, { localPath, remotePath, sftpId, encoding }) {
const watchId = `watch-${crypto.randomUUID()}`;
console.log(`[FileWatcher] Starting watch: ${localPath} -> ${remotePath}`);
@@ -132,15 +135,16 @@ async function startWatching(event, { localPath, remotePath, sftpId }) {
debounceTimers.delete(watchId);
await handleFileChange(watchId, webContents);
}, 500); // 500ms debounce
debounceTimers.set(watchId, timer);
});
activeWatchers.set(watchId, {
watcher: null, // fs.watchFile doesn't return a watcher object
localPath,
remotePath,
sftpId,
encoding,
lastModified,
lastSize,
webContents,
@@ -158,8 +162,13 @@ async function handleFileChange(watchId, webContents) {
const watchInfo = activeWatchers.get(watchId);
if (!watchInfo) return;
const { localPath, remotePath, sftpId, lastModified: previousModified, lastSize: previousSize } = watchInfo;
const { localPath, remotePath, sftpId, encoding, lastModified: previousModified, lastSize: previousSize } = watchInfo;
// Lazy-load encodePathForSession to avoid circular dependency
if (encodePathForSession === null) {
({ encodePathForSession } = require("./sftpBridge.cjs"));
}
// Extract file name once for notifications and logging
const fileName = path.basename(remotePath);
@@ -195,7 +204,8 @@ async function handleFileChange(watchId, webContents) {
console.log(`[FileWatcher] Syncing ${content.length} bytes to ${remotePath}`);
// Upload to remote
await client.put(content, remotePath);
const encodedPath = encodePathForSession(sftpId, remotePath, encoding);
await client.put(content, encodedPath);
console.log(`[FileWatcher] Sync complete: ${remotePath}`);

View File

@@ -90,60 +90,21 @@ async function startPortForward(event, payload) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[PortForward] Auto-answering password prompt at index ${i}`);
responses[i] = password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[PortForward] Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -152,9 +113,11 @@ async function startPortForward(event, payload) {
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);

View File

@@ -0,0 +1,289 @@
/**
* Session Logs Bridge - Handles session log export and auto-save operations
* Provides functionality to export terminal logs to files and manage auto-save settings
*/
const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
/**
* Strip ANSI escape codes from text
* Used for plain text export format
*/
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
/**
* Escape HTML special characters to prevent XSS
* Must be applied before converting ANSI codes to HTML spans
*/
function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Convert terminal data to HTML with colors preserved
*/
function terminalDataToHtml(terminalData, hostLabel, timestamp) {
// Basic ANSI to HTML conversion for common codes
const ansiToHtml = (text) => {
const colorMap = {
"30": "color: #000",
"31": "color: #c00",
"32": "color: #0c0",
"33": "color: #cc0",
"34": "color: #00c",
"35": "color: #c0c",
"36": "color: #0cc",
"37": "color: #ccc",
"90": "color: #666",
"91": "color: #f66",
"92": "color: #6f6",
"93": "color: #ff6",
"94": "color: #66f",
"95": "color: #f6f",
"96": "color: #6ff",
"97": "color: #fff",
"40": "background: #000",
"41": "background: #c00",
"42": "background: #0c0",
"43": "background: #cc0",
"44": "background: #00c",
"45": "background: #c0c",
"46": "background: #0cc",
"47": "background: #ccc",
"1": "font-weight: bold",
"3": "font-style: italic",
"4": "text-decoration: underline",
};
// First, escape HTML in the text content (not the ANSI codes)
// We do this by splitting on ANSI sequences, escaping each text part, then rejoining
// eslint-disable-next-line no-control-regex
const ansiRegex = /(\x1B\[[0-9;]*m|\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/g;
const parts = text.split(ansiRegex);
let result = parts.map((part) => {
// Check if this part is an ANSI sequence
// eslint-disable-next-line no-control-regex
if (/^\x1B/.test(part)) {
// It's an ANSI sequence, convert to HTML span or remove
const match = part.match(/^\x1B\[([0-9;]*)m$/);
if (match) {
const codes = match[1];
if (codes === "0" || codes === "") {
return "</span>";
}
const styles = codes.split(";").map((c) => colorMap[c]).filter(Boolean);
if (styles.length > 0) {
return `<span style="${styles.join("; ")}">`;
}
}
// Other ANSI sequences are stripped
return "";
}
// It's regular text, escape HTML
return escapeHtml(part);
}).join("");
return result;
};
const htmlContent = ansiToHtml(terminalData);
const dateStr = new Date(timestamp).toLocaleString();
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
const safeDateStr = escapeHtml(dateStr);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Session Log - ${safeHostLabel}</title>
<style>
body {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'JetBrains Mono', 'SF Mono', Monaco, Menlo, monospace;
font-size: 13px;
line-height: 1.4;
padding: 20px;
white-space: pre-wrap;
word-wrap: break-word;
}
.header {
border-bottom: 1px solid #444;
padding-bottom: 10px;
margin-bottom: 20px;
color: #888;
}
</style>
</head>
<body>
<div class="header">
Host: ${safeHostLabel}<br>
Date: ${safeDateStr}
</div>
<div class="content">${htmlContent}</div>
</body>
</html>`;
}
/**
* Export a session log to a file (manual export via save dialog)
*/
async function exportSessionLog(event, payload) {
const { terminalData, hostLabel, hostname, startTime, format } = payload;
if (!terminalData) {
throw new Error("No terminal data to export");
}
// Generate default filename
const date = new Date(startTime);
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
const safeHostLabel = (hostLabel || hostname || "session").replace(/[^a-zA-Z0-9-_]/g, "_");
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
const defaultPath = `${safeHostLabel}_${dateStr}.${ext}`;
// Show save dialog
const result = await dialog.showSaveDialog({
defaultPath,
filters: [
{ name: "Text Files", extensions: ["txt"] },
{ name: "Log Files", extensions: ["log"] },
{ name: "HTML Files", extensions: ["html"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (result.canceled || !result.filePath) {
return { success: false, canceled: true };
}
// Prepare content based on format
let content;
const actualFormat = path.extname(result.filePath).slice(1) || format;
if (actualFormat === "html") {
content = terminalDataToHtml(terminalData, hostLabel, startTime);
} else if (actualFormat === "log" || actualFormat === "raw") {
// Raw format preserves ANSI codes
content = terminalData;
} else {
// Plain text - strip ANSI codes
content = stripAnsi(terminalData);
}
await fs.promises.writeFile(result.filePath, content, "utf8");
return { success: true, filePath: result.filePath };
}
/**
* Select a directory for session logs storage
*/
async function selectSessionLogsDir(event) {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
title: "Select Session Logs Directory",
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
return { success: true, directory: result.filePaths[0] };
}
/**
* Auto-save a session log to the configured directory
* Called when a terminal session ends
*/
async function autoSaveSessionLog(event, payload) {
const { terminalData, hostLabel, hostname, hostId, startTime, format, directory } = payload;
if (!terminalData || !directory) {
return { success: false, error: "Missing terminal data or directory" };
}
try {
// Create host subdirectory
const safeHostLabel = (hostLabel || hostname || hostId || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_");
const hostDir = path.join(directory, safeHostLabel);
await fs.promises.mkdir(hostDir, { recursive: true });
// Generate filename with timestamp
const date = new Date(startTime);
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
const fileName = `${dateStr}.${ext}`;
const filePath = path.join(hostDir, fileName);
// Prepare content based on format
let content;
if (format === "html") {
content = terminalDataToHtml(terminalData, hostLabel, startTime);
} else if (format === "raw") {
content = terminalData;
} else {
content = stripAnsi(terminalData);
}
await fs.promises.writeFile(filePath, content, "utf8");
return { success: true, filePath };
} catch (err) {
console.error("Failed to auto-save session log:", err);
return { success: false, error: err.message };
}
}
/**
* Open the session logs directory in the system file explorer
*/
async function openSessionLogsDir(event, payload) {
const { shell } = require("electron");
const { directory } = payload;
if (!directory) {
return { success: false, error: "No directory specified" };
}
try {
// Check if directory exists
await fs.promises.access(directory);
await shell.openPath(directory);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Register IPC handlers for session logs operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sessionLogs:export", exportSessionLog);
ipcMain.handle("netcatty:sessionLogs:selectDir", selectSessionLogsDir);
ipcMain.handle("netcatty:sessionLogs:autoSave", autoSaveSessionLog);
ipcMain.handle("netcatty:sessionLogs:openDir", openSessionLogsDir);
}
module.exports = {
registerHandlers,
exportSessionLog,
selectSessionLogsDir,
autoSaveSessionLog,
openSessionLogsDir,
stripAnsi,
terminalDataToHtml,
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,119 @@
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Extract the base64 content between the markers
const base64Match = keyContent.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
if (isKeyEncrypted(privateKey)) {
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;
}
}
}
return null;
}
/**
* Check if Windows SSH Agent service is running
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
*/
function checkWindowsSshAgent() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve({ running: true, startupType: null, error: null });
return;
}
exec("sc query ssh-agent", (err, stdout) => {
if (err) {
resolve({ running: false, startupType: null, error: "SSH Agent service not found" });
return;
}
const running = stdout.includes("RUNNING");
const stopped = stdout.includes("STOPPED");
resolve({
running,
startupType: stopped ? "stopped" : (running ? "running" : "unknown"),
error: null,
});
});
});
}
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
@@ -304,6 +412,17 @@ async function startSSHSession(event, options) {
connectOpts.password = options.password;
}
// Fallback to default SSH key if no authentication method is configured
let usedDefaultKey = null;
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
connectOpts.privateKey = defaultKey.privateKey;
usedDefaultKey = defaultKey;
}
}
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
@@ -522,61 +641,21 @@ async function startSSHSession(event, options) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`${logPrefix} Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// But only send the prompts that need user input
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -585,9 +664,11 @@ async function startSSHSession(event, options) {
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null, // Pass saved password for optional fill button
});
});
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
@@ -861,6 +942,320 @@ async function getSessionPwd(event, payload) {
});
}
/**
* Get server stats (CPU, Memory, Disk) from an active SSH session
* Only works for Linux servers
*/
async function getServerStats(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
const conn = session.conn;
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
// This command is designed to work across most Linux distributions
// Note: Using semicolons and avoiding comments for single-line execution
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
const statsCommand = [
// Get number of CPU cores
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
// We output raw values and calculate delta-based percentage on the backend
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get top 10 processes by memory - with BusyBox fallback
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
`procs=$(ps -eo pid,%mem,comm --sort=-%mem 2>/dev/null | awk 'NR>1 && NR<=11 {gsub(/;/, "_", $3); printf "%s;%.1f;%s,", $1, $2, $3}' | sed 's/,$//' || ps -o pid,vsz,comm 2>/dev/null | awk 'NR>1 {gsub(/;/, "_", $3); print $2, $1, $3}' | sort -rn | head -10 | awk -v total=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) '{if(total>0) pct=$1*100/total; else pct=0; printf "%s;%.1f;%s,", $2, pct, $3}' | sed 's/,$//' || echo "")`,
// Get all mounted disk info - with BusyBox fallback
// GNU df: df -BG (block size in GB)
// BusyBox fallback: df and calculate from 1K blocks, or df -h and parse units
`disks=$(df -BG 2>/dev/null | awk 'NR>1 && $1 ~ /^\\/dev/ {gsub(/G/,"",$2); gsub(/G/,"",$3); gsub(/%/,"",$5); printf "%s:%s:%s:%s,", $6, $3, $2, $5}' | sed 's/,$//' || df 2>/dev/null | awk 'NR>1 && $1 ~ /^\\/dev/ {total=$2/1048576; used=$3/1048576; pct=$5; gsub(/%/,"",pct); printf "%s:%.0f:%.0f:%s,", $6, used, total, pct}' | sed 's/,$//' || echo "")`,
// Get network interface stats from /proc/net/dev (interface:rx_bytes:tx_bytes), excluding lo and virtual interfaces
`net=$(cat /proc/net/dev 2>/dev/null | awk 'NR>2 {gsub(/^[ \\t]+/, ""); split($0, a, ":"); iface=a[1]; if(iface != "lo" && iface !~ /^veth/ && iface !~ /^docker/ && iface !~ /^br-/) {split(a[2], b); printf "%s:%s:%s,", iface, b[1], b[9]}}' | sed 's/,$//' || echo "")`,
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net"`
].join('; ');
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve({ success: false, error: 'Timeout getting server stats' });
}, 5000);
conn.exec(statsCommand, (err, stream) => {
if (err) {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.stderr.on('data', (data) => {
stderr += data.toString();
});
stream.on('close', () => {
clearTimeout(timeout);
// Parse the output
const output = stdout.trim();
const parts = output.split('|');
let cpuRawTotal = null;
let cpuRawIdle = null;
let cpuPerCoreRaw = []; // Array of { total, idle }
let cpuCores = null;
let memTotal = null;
let memFree = null;
let memBuffers = null;
let memCached = null;
let memUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
for (const part of parts) {
if (part.startsWith('CPURAW:')) {
const rawParts = part.substring(7).trim().split(/\s+/);
if (rawParts.length >= 2) {
cpuRawTotal = parseInt(rawParts[0], 10);
cpuRawIdle = parseInt(rawParts[1], 10);
}
} else if (part.startsWith('CORES:')) {
const coreStr = part.substring(6).trim();
const val = parseInt(coreStr, 10);
if (!isNaN(val) && val > 0) cpuCores = val;
} else if (part.startsWith('PERCORERAW:')) {
const coreStr = part.substring(11).trim();
if (coreStr && coreStr !== '') {
cpuPerCoreRaw = coreStr.split(',').map(v => {
const coreParts = v.trim().split(':');
if (coreParts.length >= 2) {
const total = parseInt(coreParts[0], 10);
const idle = parseInt(coreParts[1], 10);
if (!isNaN(total) && !isNaN(idle)) {
return { total, idle };
}
}
return null;
}).filter(v => v !== null);
}
} else if (part.startsWith('MEMINFO:')) {
const memParts = part.substring(8).trim().split(/\s+/);
if (memParts.length >= 4) {
const total = parseInt(memParts[0], 10);
const free = parseInt(memParts[1], 10);
const buffers = parseInt(memParts[2], 10);
const cached = parseInt(memParts[3], 10);
if (!isNaN(total)) memTotal = total;
if (!isNaN(free)) memFree = free;
if (!isNaN(buffers)) memBuffers = buffers;
if (!isNaN(cached)) memCached = cached;
// Calculate used memory (excluding buffers/cache)
if (memTotal !== null && memFree !== null && memBuffers !== null && memCached !== null) {
memUsed = memTotal - memFree - memBuffers - memCached;
if (memUsed < 0) memUsed = 0;
}
}
} else if (part.startsWith('PROCS:')) {
const procsStr = part.substring(6).trim();
if (procsStr && procsStr !== '') {
const procEntries = procsStr.split(',');
for (const entry of procEntries) {
const procParts = entry.split(';'); // Using ; as delimiter
if (procParts.length >= 3) {
const pid = procParts[0];
const memPercent = parseFloat(procParts[1]);
const command = procParts.slice(2).join(';'); // Command might contain semicolons
if (!isNaN(memPercent)) {
topProcesses.push({ pid, memPercent, command });
}
}
}
}
} else if (part.startsWith('DISKS:')) {
const disksStr = part.substring(6).trim();
if (disksStr && disksStr !== '') {
const diskEntries = disksStr.split(',');
for (const entry of diskEntries) {
const diskParts = entry.split(':');
if (diskParts.length >= 4) {
const mountPoint = diskParts[0];
const used = parseInt(diskParts[1], 10);
const total = parseInt(diskParts[2], 10);
const percent = parseInt(diskParts[3], 10);
if (!isNaN(used) && !isNaN(total) && !isNaN(percent)) {
disks.push({ mountPoint, used, total, percent });
}
}
}
}
} else if (part.startsWith('NET:')) {
const netStr = part.substring(4).trim();
if (netStr && netStr !== '') {
const netEntries = netStr.split(',');
for (const entry of netEntries) {
const netParts = entry.split(':');
if (netParts.length >= 3) {
const name = netParts[0];
const rxBytes = parseInt(netParts[1], 10);
const txBytes = parseInt(netParts[2], 10);
if (!isNaN(rxBytes) && !isNaN(txBytes)) {
networkInterfaces.push({ name, rxBytes, txBytes });
}
}
}
}
}
}
// Calculate network speed based on previous reading
const now = Date.now();
const prevNet = session.prevNetStats || { interfaces: [], timestamp: 0 };
const timeDelta = (now - prevNet.timestamp) / 1000; // seconds
let netRxSpeed = 0; // bytes per second
let netTxSpeed = 0; // bytes per second
const netInterfaces = [];
if (timeDelta > 0 && prevNet.interfaces.length > 0) {
for (const iface of networkInterfaces) {
const prevIface = prevNet.interfaces.find(p => p.name === iface.name);
if (prevIface) {
const rxDelta = iface.rxBytes - prevIface.rxBytes;
const txDelta = iface.txBytes - prevIface.txBytes;
// Only count positive deltas (handles counter reset)
const rxSpeed = rxDelta > 0 ? Math.round(rxDelta / timeDelta) : 0;
const txSpeed = txDelta > 0 ? Math.round(txDelta / timeDelta) : 0;
netRxSpeed += rxSpeed;
netTxSpeed += txSpeed;
netInterfaces.push({
name: iface.name,
rxBytes: iface.rxBytes,
txBytes: iface.txBytes,
rxSpeed,
txSpeed,
});
} else {
netInterfaces.push({
name: iface.name,
rxBytes: iface.rxBytes,
txBytes: iface.txBytes,
rxSpeed: 0,
txSpeed: 0,
});
}
}
} else {
// First reading - no speed data yet
for (const iface of networkInterfaces) {
netInterfaces.push({
name: iface.name,
rxBytes: iface.rxBytes,
txBytes: iface.txBytes,
rxSpeed: 0,
txSpeed: 0,
});
}
}
// Store current reading for next calculation
session.prevNetStats = {
interfaces: networkInterfaces,
timestamp: now,
};
// Calculate CPU usage based on delta from previous reading
const prevCpu = session.prevCpuStats || { total: 0, idle: 0, perCore: [], timestamp: 0 };
let cpu = null;
let cpuPerCore = [];
if (cpuRawTotal !== null && cpuRawIdle !== null && prevCpu.total > 0) {
const totalDelta = cpuRawTotal - prevCpu.total;
const idleDelta = cpuRawIdle - prevCpu.idle;
if (totalDelta > 0) {
// CPU% = 100 - (idleDelta / totalDelta * 100)
cpu = Math.round(100 - (idleDelta / totalDelta * 100));
// Clamp to valid range
if (cpu < 0) cpu = 0;
if (cpu > 100) cpu = 100;
}
}
// Calculate per-core CPU usage from deltas
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
const prevCore = prevCpu.perCore[index];
if (prevCore) {
const totalDelta = core.total - prevCore.total;
const idleDelta = core.idle - prevCore.idle;
if (totalDelta > 0) {
let usage = Math.round(100 - (idleDelta / totalDelta * 100));
if (usage < 0) usage = 0;
if (usage > 100) usage = 100;
return usage;
}
}
return 0;
});
} else if (cpuPerCoreRaw.length > 0) {
// First reading - no delta data yet, return zeros
cpuPerCore = cpuPerCoreRaw.map(() => 0);
}
// Store current CPU reading for next calculation
session.prevCpuStats = {
total: cpuRawTotal || 0,
idle: cpuRawIdle || 0,
perCore: cpuPerCoreRaw,
timestamp: now,
};
// For backward compatibility, extract root disk info
const rootDisk = disks.find(d => d.mountPoint === '/');
const diskPercent = rootDisk ? rootDisk.percent : null;
const diskUsed = rootDisk ? rootDisk.used : null;
const diskTotal = rootDisk ? rootDisk.total : null;
resolve({
success: true,
stats: {
cpu, // CPU usage percentage (0-100)
cpuCores, // Number of CPU cores
cpuPerCore, // Per-core CPU usage array
memTotal, // Total memory in MB
memUsed, // Used memory in MB (excluding buffers/cache)
memFree, // Free memory in MB
memBuffers, // Buffers in MB
memCached, // Cached in MB
topProcesses, // Top 10 processes by memory
diskPercent, // Disk usage percentage for root partition (backward compat)
diskUsed, // Disk used in GB for root partition (backward compat)
diskTotal, // Total disk in GB for root partition (backward compat)
disks, // Array of all mounted disks
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
netInterfaces, // Per-interface network stats
},
});
});
});
});
}
/**
* Register IPC handlers for SSH operations
*/
@@ -868,7 +1263,22 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:ssh:stats", getServerStats);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
ipcMain.handle("netcatty:ssh:check-agent", async () => {
return await checkWindowsSshAgent();
});
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
const sshDir = path.join(os.homedir(), ".ssh");
const keys = [];
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
if (fs.existsSync(keyPath)) {
keys.push({ name, path: keyPath });
}
}
return keys;
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
}
@@ -880,5 +1290,8 @@ module.exports = {
startSSHSession,
execCommand,
getSessionPwd,
getServerStats,
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
};

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