Compare commits

...

61 Commits

Author SHA1 Message Date
陈大猫
b8de9ce2b6 Merge pull request #571 from binaricat/ui/compact-host-select-panel
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-29 22:34:08 +08:00
bincxz
2c7bce31d4 style: reduce border-radius on distro avatars
sm: rounded-md → rounded (4px), md: rounded-xl → rounded-lg (8px),
SelectHostPanel inline: rounded-lg → rounded-md (6px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:36 +08:00
bincxz
004a5f18de fix: use rounded square distro avatar in port forwarding wizard
Use size="sm" (rounded-md) instead of className override that kept
the rounded-xl from the default md size, which appeared circular.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:03 +08:00
bincxz
731d57d355 fix: add missing TooltipProvider import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:25:36 +08:00
bincxz
8c6ff1a6a4 fix: wrap tooltips with TooltipProvider in SelectHostPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:24:36 +08:00
bincxz
f7630b3574 ui: compact host selection panel with smaller icons and text truncation
- Reduce item padding, gaps, icon sizes, and font sizes for a denser list
- Use rounded square (rounded-lg) avatars instead of circles, remove border
- Add tooltip on host label and connection string for long text overflow
- Shrink section headers and group items to match compact style
- Remove border from selected host items for cleaner look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:23:43 +08:00
陈大猫
76bfe26561 Merge pull request #570 from binaricat/fix/sftp-keyboard-action-repeated-across-tabs
fix: isolate SFTP actions and selection state across panes and tabs
2026-03-29 22:13:47 +08:00
bincxz
7079ea66aa fix sftp cross-pane tab focus selection retention 2026-03-29 21:53:11 +08:00
bincxz
6562351955 fix: scope dialog actions and refine selection clearing
- Add dialogActionScopeId to distinguish SftpView and SftpSidePanel
  dialog actions, preventing cross-instance interference
- Refine selectionScope to clear tree selections per-pane instead of
  using clearAllExcept, avoiding side effects on other SFTP surfaces
- Remove selection clearing from tab switch/move/add handlers; clearing
  now only happens on focus side change and file interaction
- Reset keyboard selection and lastSelectedIndex when selections are
  externally cleared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:44:15 +08:00
bincxz
986fdda008 fix sftp selection clearing across panes and tabs 2026-03-29 21:15:28 +08:00
bincxz
af2dc66113 fix: clear all selections when focus side changes
When the user switches focus between left and right panes, clear all
pane selections. Combined with the per-interaction clearing in
toggleSelection/rangeSelect, this ensures:
- Selecting files clears other panes' selections
- Switching sides clears all selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:15:01 +08:00
bincxz
cca4a3a37e fix: clear other selections on file interaction, not tab switch
Move selection clearing from tab switch and pane focus handlers into
toggleSelection/rangeSelect. This means:
- Switching tabs just to look around preserves all selections
- Actually clicking/selecting files clears other tabs' selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:13:24 +08:00
bincxz
75ec050c31 revert: restore clearSelectionsExcept to clear all tabs except target
Clearing same-side inactive tab selections on tab switch is intentional
UX — stale selections on hidden tabs would be confusing when switching
back. Reverts the "preserve same-side" change from 05c48b3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:04:08 +08:00
bincxz
db604e4c41 fix: localize delete dialog labels and preserve moved tab tree selection
- Add i18n keys for "Host" and "Path" labels in delete confirmation
  dialog (was hardcoded English, broken under zh-CN)
- Pass moved tab ID as extra keepId when clearing tree selections after
  moveTabToOtherSide, since the ref still has pre-move state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:50:18 +08:00
bincxz
05c48b3d28 fix: preserve selections in same-side inactive tabs
clearSelectionsExcept was clearing all tabs including same-side inactive
ones, causing users to lose file selections when switching between tabs
on the same side. Now only the opposite side's selections are cleared.

Also scoped tree selection clearing to only affect opposite-side pane
IDs, preventing mounted but hidden SFTP surfaces from losing state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:39:39 +08:00
bincxz
3bb98c9c27 fix: allow paste between different tabs on the same side
The paste check only compared sourceSide vs focusedSide, treating all
tabs on the same side as "same pane". Now it also compares connectionId
so copying from one tab and pasting to a different tab on the same side
works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:24:11 +08:00
bincxz
7f4dcce3cb fix: don't clear dialog action from inactive panes
Revert the stale action clearing in inactive panes (e9ad65f). When
multiple tabs exist on the same side, the inactive tab's effect could
fire before the active tab's, clearing the action and causing it to
be handled by the wrong pane or not at all.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:19:34 +08:00
bincxz
766451d9bb fix: handle empty selection in tree view container keyboard navigation
The tree view's own onKeyDown handler had the same issue as the global
keyboard shortcuts: pressing ArrowDown with no selection would skip the
first item. Apply the same fix (reset focus to -1 for empty selection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:01:58 +08:00
bincxz
6f5a2181b2 fix: suppress SFTP keyboard shortcuts when a dialog is open
Prevents SFTP shortcuts (Delete, Enter, etc.) from firing while
unrelated dialogs are open, which could cause unintended file
operations from outside the SFTP panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:55:01 +08:00
bincxz
297adbb818 fix: clamp anchor for Shift+Arrow from empty selection
When no files are selected, Shift+Arrow would use anchor=-1 causing
invalid slice ranges. Now anchor is set to 0 when Shift is held, so
range selection starts from the first item correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:47:47 +08:00
bincxz
13eeb2cf6d fix: ArrowDown from cleared selection now lands on first item
When selections are cleared (e.g. by switching panes), pressing
ArrowDown would skip the first item because the keyboard focus
defaulted to index 0 and then moved to 1. Now an empty selection
resets focus to -1 so the first arrow press selects item 0.
Applies to both list and tree views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:38:24 +08:00
bincxz
e9ad65fef6 fix: clear stale dialog actions when target pane is inactive
When a dialog action's targetSide matched but the pane was inactive,
the action was left in the store. If the pane later became active, it
would fire the stale action unexpectedly. Now inactive panes clear the
action to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:25:55 +08:00
bincxz
ddb6b5af1e perf: only re-render selected rows on focus change
The showSelectionHighlight check in SftpFileRow's areEqual was causing
all rows to re-render when switching focus between panes. Now only rows
that are actually selected re-render on highlight changes, avoiding
unnecessary work for large file lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:23:35 +08:00
bincxz
c1171d4c7b fix sftp shift selection upward expansion 2026-03-29 18:19:04 +08:00
bincxz
21daccf6ed fix: enforce cross-pane selection mutual exclusivity and improve delete dialog
- Add clearSelectionsExcept to clear all file/tree selections except the
  target pane, called on focus change, tab switch, tab add, and tab move
- Fix SftpFileRow areEqual to include showSelectionHighlight so highlight
  updates when focus changes between panes
- Improve delete confirmation dialog with host/path context and separate
  single vs multi-delete descriptions
- Fix hover style on selected rows to prevent flicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:05:54 +08:00
bincxz
2eed15b4b2 feat: show host label in SFTP operation dialogs
Display the connection's host label at the top of new folder, new file,
rename, overwrite, and delete confirmation dialogs so users can see
which machine the operation targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:50:37 +08:00
bincxz
de7fdfc4b4 fix: ensure SftpSidePanel panes remain active for keyboard shortcuts
SftpSidePanel doesn't sync with the global activeTabStore, so
useActiveTabId would return the main SftpView's tab id, causing
side panel panes to be treated as inactive. Add forceActive prop
to bypass the activeTabId check for contexts that manage pane
visibility themselves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:36:11 +08:00
bincxz
709ed12259 fix: prevent SFTP keyboard actions from repeating across all tabs (#569)
When multiple SFTP connections were open as tabs on the same side,
keyboard-triggered actions (delete, rename, new folder, new file) were
executed on every mounted tab instead of just the active one. This was
because all hidden SftpPaneView instances shared the same dialog action
handler and React batched their effects before clear() could prevent
duplicates.

- Add isActive parameter to useSftpDialogActionHandler so only the
  active tab responds to keyboard shortcut actions
- Compute real isActive state in SftpPaneView using useActiveTabId
  instead of hardcoding true
- Clear opposite side's file selection on pane focus change to prevent
  cross-pane selection leaking into actions

Closes #569

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:31:12 +08:00
bincxz
0826bbb435 style: use Netcatty logo in OAuth callback page
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Replace the generic terminal SVG icon with the actual Netcatty brand
logo (blue rounded-rect with terminal + cat tail motif).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:49:25 +08:00
bincxz
ec87eb593e fix: show spinner and connecting text during cloud sync connection
Replace yellow pulsing dot with a spinning Loader2 icon when cloud
provider is in connecting state. Also show "Connecting..." text
instead of "Not connected" during the connection attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:44:03 +08:00
bincxz
ecbd50dde4 fix: use accent color for active tab indicator instead of foreground
The top indicator line on active tabs (sessions, logview, vaults, SFTP)
was hardcoded to foreground color (white), making it always white
regardless of the system accent color setting. Changed all 4 tab
indicator lines to use --top-tabs-accent / --accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:31:20 +08:00
bincxz
4dd7640452 fix: allow auto encoding through same-host fast path
The encoding guard was rejecting "auto" which is the default encoding
for nearly all connections, making same-host optimization never trigger.

Frontend now allows "auto" through. Backend resolves "auto" to the
actual session encoding via resolveEncodingForRequest and only proceeds
with exec cp when the resolved encoding is UTF-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:25:36 +08:00
陈大猫
0b08521e63 perf: optimize same-host SFTP transfer with remote cp command (#564)
* perf: optimize same-host SFTP transfer with remote cp command

When both panels are connected to the same remote host, use SSH exec
`cp -a` instead of downloading to local temp then re-uploading. This
eliminates 2x bandwidth usage and reduces latency for same-host transfers.

Closes #561

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: optimize same-host directory transfer with single cp -ra command

For same-host directory transfers, use a single `cp -ra` command via SSH
exec instead of recursively walking the directory and copying files one
by one. This makes directory copies nearly instant on the remote server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use endpoint cache key for same-host detection and guard non-UTF-8 paths

Address two code review issues:

1. Compare per-connection cache keys (hostname+port+protocol+sudo+username)
   instead of just hostId for same-host detection. This prevents false
   positives when the same hostId has different session-time overrides.

2. Restrict exec-based cp paths to UTF-8 compatible encodings only.
   Non-UTF-8 encodings (e.g. gb18030) need encodePathForSession which
   shell exec cannot use — fall back to download+upload for those cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: directory cp semantics, cancellation, and auto encoding guard

1. Use `cp -ra source/. target/` instead of `cp -ra source target` to
   copy directory contents into target, preserving merge semantics when
   the target directory already exists (avoids extra nesting level).

2. Check cancellation state before and after sameHostCopyDirectory call
   so cancelled transfers don't finalize as completed.

3. Exclude 'auto' from exec-safe encodings since auto can resolve to
   non-UTF-8 (e.g. gb18030) at the session level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wire cancellation into same-host copy paths

1. Single file cp -a: check transfer.cancelled before and after
   execSshCommand so cancelled transfers don't proceed as success.

2. Directory cp -ra: accept transferId, register in activeTransfers
   so cancelTransfer can flag it, and check cancelled state at each
   async boundary. Cleanup via finally block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: abort remote cp process on transfer cancellation

Add execSshCommandCancellable() that wires the SSH exec stream into
transfer.abort, so cancelTransfer can close the stream and kill the
remote cp process immediately instead of waiting for it to finish.

Used in both single-file (cp -a) and directory (cp -ra) same-host paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: close exec stream immediately if cancelled before callback fires

Check transfer.cancelled at the start of the exec callback and close
the stream right away, preventing the remote cp from running when
cancellation happened between the exec() call and callback delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to download+upload when remote cp is unavailable

On non-POSIX remotes (e.g. Windows SSH servers) where cp is absent,
same-host optimization now gracefully falls back to the existing
download+upload transfer path instead of failing the transfer.

- Single file: try cp -a first, fall back to temp file on non-zero exit
- Directory: sameHostCopyDirectory returns { success: false } instead of
  throwing, frontend falls back to recursive transferDirectory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: cache cp unavailability to avoid repeated exec failures

Track sftpIds where remote cp failed in cpUnavailableSet so subsequent
file transfers in the same session skip the exec attempt and go directly
to download+upload, avoiding per-file exec round-trip overhead on
non-POSIX remotes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip transferFile for directories already handled by same-host copy

Add !task.isDirectory guard to the else branch so successful
sameHostCopyDirectory doesn't also trigger a redundant transferFile
call that would duplicate data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dereference symlinks in same-host copy to match SFTP behavior

Use cp -aL instead of cp -a so symlinks are dereferenced (copied as
file contents), matching the existing SFTP download+upload flow which
always transfers resolved file data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove -L flag from same-host cp to avoid recursing symlinked dirs

Revert cp -aL back to cp -a. The -L flag dereferences all symlinks
including symlinked directories, which can unexpectedly recurse into
large unrelated directory trees. Using cp -a preserves symlinks as-is,
which is safer and consistent with how the transfer UI treats symlink
directories as non-recursive entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: refine cp unavailability caching and remove dead import

1. Only cache sftpId in cpUnavailableSet on exit code 127 (command not
   found). Other failures (permission denied, disk full) are transient
   or path-specific and should not disable cp for the entire session.

2. Check cpUnavailableSet at the top of sameHostCopyDirectory to skip
   exec attempt on known non-POSIX remotes. Also cache 127 exits from
   directory copies.

3. Remove unused execSshCommand import from transferBridge (replaced by
   local execSshCommandCancellable) and revert its export from sftpBridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:21:58 +08:00
陈大猫
59e768c447 fix: prevent key file path from overflowing panel (#551) (#567)
* fix: prevent key file path from overflowing host details panel

Add min-w-0 to flex containers and flex items displaying key file
paths. Without this, flex items default to min-width: auto which
prevents truncate from working and causes long file paths (e.g.
from the file picker) to blow out the panel width.

Closes #551

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to AsidePanel to prevent content overflow

The root cause of key file paths overflowing the panel was the
AsidePanel container itself lacking overflow-hidden. Even though
inner elements had min-w-0 and truncate, the absolute-positioned
panel div allowed content to visually escape its bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to credentials Card and key path row

Ensure truncation works by adding overflow-hidden at multiple
levels: the Port & Credentials Card container and each key file
path flex row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use w-0 flex-1 to force key file path truncation

min-w-0 alone is insufficient in nested flex layouts. Setting w-0
with flex-1 forces the element to start at zero width and only grow
to fill available space, guaranteeing truncation works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:17:04 +08:00
陈大猫
6a37b8bbc6 fix: use system browser for OAuth flows (#563) (#565) 2026-03-29 12:43:21 +08:00
陈大猫
9397a781b5 refactor: unify directory download with upload transfer system (#560)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* refactor: unify directory download with upload transfer system

Directory downloads previously used a completely separate implementation
with custom queue management, progress tracking, and concurrency control
(~390 lines in useSftpViewFileOps.ts). This caused the download UI to
show only a single aggregate task without child file details, unlike
uploads which showed parent + child tasks.

Replace the custom download implementation with a new downloadToLocal()
method in useSftpTransfers that reuses the existing transferDirectory/
transferFile infrastructure. Downloads now:
- Show parent task with child file tasks (same as uploads)
- Use the configurable transfer concurrency setting
- Support cancellation through the same mechanism
- Share progress tracking and conflict detection code

Net reduction of ~260 lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove dead code from directory download refactor

Remove listSftp, mkdirLocal, and RemoteFile imports that were only
used by the old custom directory download implementation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle symlink directories in transfers and remove dead code

- Use isNavigableDirectory() instead of type === "directory" in
  transferDirectory so symlinks pointing to directories are
  recursed into correctly (fixes both upload and download paths)
- Remove unused deleteLocalFile prop from useSftpViewFileOps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use connection ID for download tasks and cancel child streams

- Use pane connection ID (not SFTP session ID) as sourceConnectionId
  so download tasks are properly associated with the host and visible
  in filtered transfer views
- Cancel all active child transfer streams at the backend when parent
  is cancelled, not just the parent ID — stops data transfer immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add symlink cycle detection and propagate child failures

- Add visitedPaths Set to transferDirectory to detect and skip
  symlink directory cycles that would cause infinite recursion
- Check for failed child tasks after transferDirectory completes
  and mark parent as failed instead of falsely reporting success

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use depth limit for symlink loops and handle EEXIST on mkdir

- Replace visited-paths cycle detection with a depth limit (64),
  which reliably catches symlink loops that generate new path strings
  each hop (e.g. /dir/link/link/link...)
- Handle EEXIST errors in mkdirLocal gracefully so re-downloading
  to an existing directory doesn't abort the entire transfer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: throw on depth limit exceeded and mark downloads non-retryable

- Depth limit now throws instead of silently returning, so exceeding
  it surfaces as a failed transfer rather than an incomplete success
- Set retryable: false on downloadToLocal tasks since retryTransfer
  cannot resolve the synthetic "local" connection ID

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: track symlink depth only and verify EEXIST target is directory

- Change depth guard to only count symlink directory hops, not total
  directory depth, so legitimate deep trees are not rejected
- After catching EEXIST on mkdirLocal, stat the path to verify it is
  actually a directory — throw if a regular file exists at that path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove dead props from callbacks and surface download failures

- Remove mkdirLocal and deleteLocalFile from useSftpViewPaneCallbacks
  interface and passthrough (fixes TS2353 build error)
- Show error toast when downloadToLocal returns "failed" status,
  not just when it throws

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: track child transfer IDs outside React state for reliable cancel

Child transfer IDs were only discoverable via transfersRef.current,
which lags behind setTransfers due to React batching. This caused
two race conditions:

1. Cancellation: child streams started between setTransfers and render
   were not cancelled at the backend, continuing to write data.
2. Failure detection: hasFailedChildren checked transfersRef which
   might not reflect recently-failed children, marking partial
   downloads as successful.

Fix: track active child IDs in activeChildIdsRef (a mutable Map
outside React state) for immediate visibility during cancellation.
Check child failure status inside setTransfers functional updater
where the latest state is guaranteed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve actual progress on partial failure and count symlink dirs

- Don't force transferredBytes to totalBytes when some children failed,
  so the progress bar accurately reflects the partial completion
- Use isNavigableDirectory in countDirectoryFiles and estimateDirectoryBytes
  so symlink directories are included in size/count estimates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: symlink count, progress on fast downloads, and child cancellation

1. countDirectoryFiles: use isNavigableDirectory so symlink dirs are
   recursed into, keeping totals consistent with transferDirectory
2. Final status: compute actual completedCount from children instead
   of relying on totalBytes which may be 0 if the background scan
   hasn't finished yet
3. Catch block: detect cancellation from error message (not just
   cancelledTasksRef) so child-initiated cancels don't show as errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add symlink depth guard to countDirectoryFiles and estimateDirectoryBytes

Both helper functions now track symlink depth and stop recursing
when MAX_SYMLINK_DEPTH is exceeded, consistent with transferDirectory.
Prevents infinite recursion on symlink directory cycles during the
background file count/size scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reliable final status and non-retryable child tasks

1. transferDirectory now returns the count of failed child transfers,
   tracked outside React state. downloadToLocal uses this count
   directly instead of reading from setTransfers updater (which may
   be deferred by React batching), ensuring the correct status is
   returned to the caller for toast messages.

2. Child tasks explicitly inherit retryable from the parent task.
   For downloadToLocal (retryable: false), this prevents showing
   retry actions on failed children whose "local" targetConnectionId
   cannot be resolved by retryTransfer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add ancestor path cycle detection for symlink directories

The depth-only guard allowed up to 32 pointless traversals before
stopping a symlink cycle (e.g. dir/link -> .). Add an ancestorPaths
Set that tracks the current recursion stack — if a directory's source
path is already in the set, it's an immediate cycle and is skipped
with zero wasted traversals. The depth limit remains as a hard backstop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: don't recurse into symlink directories during transfers

Revert to only recursing into real directories (type === "directory")
in transferDirectory, countDirectoryFiles, and estimateDirectoryBytes.
Symlink directories are now transferred as regular entries instead of
being followed, eliminating all symlink cycle risks without needing
complex cycle detection that can't reliably work with unresolved
remote paths.

Also clean up activeChildIdsRef in processTransfer (both success and
error paths) to prevent memory leaks from pane-to-pane directory
transfers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: filter "." entries and recurse into symlink dirs with depth guard

1. Filter both "." and ".." in all recursive functions — some SFTP
   servers include "." in readdir, causing infinite self-recursion.

2. Restore symlink directory recursion in transferDirectory with a
   symlinkDepth counter (max 32). Symlink dirs that exceed the limit
   are excluded from the dirs list (treated as files). This is needed
   because startStreamTransfer cannot transfer a directory as a file,
   so skipping symlink dirs caused child transfer failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add symlink depth guard to count/estimate helpers

countDirectoryFiles and estimateDirectoryBytes now track symlinkDepth
consistently with transferDirectory, preventing infinite recursion on
symlink cycles in the background file count/size estimation.

Also fixes:
- Remove fragile string-based cancellation detection in downloadToLocal
- Clean up cancelledTasksRef in downloadToLocal catch block
- Move MAX_SYMLINK_DEPTH before its first use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use path reconstruction instead of string replace for duplicate conflicts

resolveConflict's "duplicate" action used String.replace to swap the
filename in the target path, but this replaces the first occurrence
which can corrupt the path if the filename also appears in a parent
directory name. Use joinPath(getParentPath(...), newName) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip over-depth symlink directories instead of treating as files

When symlinkDepth exceeds MAX_SYMLINK_DEPTH, symlink directories
were falling through to regularFiles and being passed to transferFile,
which cannot transfer directories and would produce confusing errors.
Now they are skipped entirely with a warning log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: count skipped symlinks as errors and process subdirs concurrently

1. Symlink directories skipped at MAX_SYMLINK_DEPTH now increment
   totalErrors so the parent task is marked failed instead of
   silently reporting success with incomplete content.

2. Sibling subdirectories are now processed with Promise.all instead
   of sequential await, restoring cross-directory concurrency that
   the old download implementation had. Files within each directory
   still use the configurable worker pool concurrency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sequential subdirs to prevent SFTP overload and check dir errors in processTransfer

1. Revert subdirectory processing to sequential (for...of await) to
   prevent unbounded concurrent SFTP requests from nested Promise.all
   + worker pools across the directory tree. File-level concurrency
   within each directory is still governed by getTransferConcurrency().

2. processTransfer now captures transferDirectory's error count return
   value and marks the parent task as "failed" when child transfers
   fail, instead of unconditionally marking "completed".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove redundant completed state update for directory transfers

Directory success path no longer writes "completed" in both the
directory-specific block and the generic block. The directory-specific
block now only handles the failure case with early return; success
falls through to the generic completed block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: route partial directory failures through shared completion path

The early return for directory transfer failures skipped cache
invalidation, target pane refresh, and onTransferComplete callbacks
(needed by cut/paste to clear clipboard). Now partial failures flow
through the same cleanup path as successes — cache is cleared,
target is refreshed, and completionHandler is called with the
correct "failed" status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restrict symlink directory recursion to downloadToLocal only

Add followSymlinks parameter (default false) to transferDirectory,
countDirectoryFiles, and estimateDirectoryBytes. Only downloadToLocal
passes true — uploads and pane-to-pane copies retain their original
behavior of treating symlink directories as regular entries.

This prevents existing upload/copy flows from expanding symlinked
directory trees (which could duplicate content or trigger cycles),
while still allowing local downloads to recursively copy through
symlink directories with depth protection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable retry for partial dir failures and fix symlink file count

1. Mark partially failed directory transfers as retryable: false to
   prevent retry from replaying the entire directory without conflict
   checks, which would silently overwrite already-copied files.

2. In countDirectoryFiles and estimateDirectoryBytes, skip over-depth
   symlink directories entirely instead of counting them as files.
   This makes the totals consistent with transferDirectory which also
   skips these entries, preventing impossible progress like "10/11".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 01:27:46 +08:00
陈大猫
255a4730e7 feat: make SFTP folder transfer concurrency configurable (#558)
* feat: make SFTP folder transfer concurrency configurable

The number of files transferred in parallel during folder uploads/
downloads was hardcoded to 4. Add a setting (1-16, default 4) in
Settings > SFTP so users can tune it for their server and network.

The value is read from localStorage at transfer start time, so
changes take effect on the next folder transfer without restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync transfer concurrency setting across windows

Add notifySettingsChanged broadcast, IPC onSettingsChanged handler,
and storage event listener for the transfer concurrency setting so
changes propagate to all open windows immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move setSftpTransferConcurrency after notifySettingsChanged

The useCallback referenced notifySettingsChanged before it was
defined (const is not hoisted), causing a ReferenceError on mount.
Move the definition after notifySettingsChanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:04:48 +08:00
陈大猫
de0d1e1912 perf: use fallback viewport height for transfer child list virtualization (#559)
When the transfer child list crosses the virtualization threshold (80
items), viewportHeight may be 0 if the layout hasn't been measured yet.
Previously this caused all children to render on the first frame,
creating a lag spike when clicking "show details" on large transfers.

Use MAX_PANEL_HEIGHT (480px) as a fallback viewport, capping the
initial render to ~25 rows (17 visible + 8 overscan) instead of
potentially thousands.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:55:34 +08:00
陈大猫
dd50f95583 feat: add workspace focus indicator style setting (dim vs border) (#557)
* feat: add workspace focus indicator style setting (dim vs border)

Users can now choose between two focus indicator styles for split
terminal panes:
- Dim: reduces opacity of unfocused panes (current default)
- Border: shows a colored border on the focused pane (old style)

The setting is in Settings > Terminal > Workspace Focus Indicator.
Implementation uses a CSS data attribute on documentElement to
toggle between the two styles, avoiding prop threading.

Closes #556

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync workspace focus style across windows

Add cross-window notification handling for the workspace focus style
setting so changes in the Settings window take effect in the main
terminal window immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:31:15 +08:00
bincxz
e57376c461 fix: remove popd from FOLDER_ONLY and resolve score collision
- Remove popd from FOLDER_ONLY_COMMANDS since it does not accept
  path arguments (it pops from the directory stack)
- Change recent-history score from 700 to 720 to avoid collision
  with spec option suggestions (also 700), giving recent history
  a clear rank: path (750) > recent history (720) > options (700)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:53:58 +08:00
bincxz
3a5a558837 fix: clear kb selection state in sftpNavigateTo list view path
The list view branch of sftpNavigateTo was missing the
_kbSelectionState.delete() call that the tree view branch and
other navigation handlers already had.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:51:51 +08:00
bincxz
506ab33b11 fix: address review findings in keyboard shortcuts and autocomplete
Keyboard shortcuts:
- BASIC_NAV_KEYS fallback now only applies when hotkeyScheme is
  disabled, so user keybinding customizations are respected
- Clear _kbSelectionState on directory navigation (sftpOpen,
  sftpGoParent, sftpNavigateTo) to prevent stale anchor/focus
- Guard sftpOpen tree-view fallback to only fire in tree view mode
- Use treeActionSelection (filters "..") in sftpNavigateTo

Autocomplete PATH_COMMANDS:
- Remove subcommand-first tools (docker, kubectl, go, cargo, java,
  make, npx) that don't take paths as first arguments
- Add pushd (was in FOLDER_ONLY but missing from PATH_COMMANDS)
- Add tee, du, df, chroot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:50:56 +08:00
bincxz
198d9c365a tweak: increase recent history suggestions from 3 to 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:44:37 +08:00
bincxz
fbc17356e0 feat: expand PATH_COMMANDS for better autocomplete path detection
Add many commonly used commands that accept file/directory arguments:
modern alternatives (exa, eza, fd, bat, helix, micro), search tools
(grep, ag, awk, sed), compression (bzip2, xz, zstd, 7z), build tools
(gcc, make, cargo, go), runtimes (deno, bun, tsx, php), container
tools (docker, kubectl), and misc utilities (realpath, md5sum, etc.).

Also add popd to FOLDER_ONLY_COMMANDS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:43:46 +08:00
bincxz
a04a28049e fix: prioritize path suggestions over history for file commands
When typing arguments for file-related commands (cat, vim, cd, etc.),
files in the current directory should appear before history entries.
Lower the recent-history score from 900 to 700 so path suggestions
(score 750) rank higher. This makes "cat com<Tab>" show compose.yaml
before historical commands like "cat /other/path".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:42:46 +08:00
bincxz
65267b3c90 refactor: hoist BASIC_NAV_KEYS to module scope
Avoid creating a new object on every keydown event by moving the
constant lookup table outside the callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:32:13 +08:00
bincxz
2196733133 fix: Enter and Backspace were blocked by early return on null match
When basicNavAction was set, matched was intentionally null but the
existing `if (!matched) return` check exited before reaching the
action handler. This made Enter and Backspace non-functional in all
hotkey modes, not just disabled mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:24:55 +08:00
bincxz
67348b42b1 fix: ensure Enter and Backspace work when hotkeys are disabled
Enter (open) and Backspace (go parent) are essential navigation keys
that must work even when the user has disabled custom SFTP hotkeys.
Add a basic navigation fallback that fires before the disabled check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:23:44 +08:00
bincxz
e754b2bdc9 feat: add configurable Navigate To shortcut for SFTP
Add sftpNavigateTo keybinding (Ctrl+Enter / ⌘+Enter) to navigate
into a selected directory. Works in both tree view and list view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:22:07 +08:00
bincxz
87e49bc897 refactor: move Enter and Backspace SFTP shortcuts to configurable keybindings
Move the hardcoded Enter (open file/directory) and Backspace (go to
parent) handlers into the keybinding system so users can customize
them in Settings. Arrow key navigation remains hardcoded as it has
complex anchor/focus state tracking unsuitable for simple action mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:17:34 +08:00
bincxz
53212b8669 fix: stale anchor in Shift+Arrow after mouse click re-sync
When the keyboard selection state was re-synced (e.g. after a mouse
click changed the selection), the anchor variable still held the old
value from before re-sync. This caused Shift+Arrow to select from
position 0 instead of from the clicked item. Destructure anchor and
focus together so both are updated when re-sync occurs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:11:30 +08:00
bincxz
ce7549bb25 fix: correct Shift+Arrow multi-select in SFTP file list
Shift+Arrow selection was broken because the anchor position was
re-derived from the selected files Set on each keypress, causing
it to jump unpredictably. Track anchor and focus indices separately
per pane so Shift+Arrow correctly extends the range from the
original starting position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:06:47 +08:00
bincxz
b5ff5a468e feat: add Backspace shortcut to navigate to parent directory in SFTP
Pressing Backspace in the SFTP file list now navigates to the parent
directory, similar to file managers like Windows Explorer and Finder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:50:49 +08:00
陈大猫
b1f9ec43de fix: widen host edit panel and prevent content overflow (#555)
- Increase HostDetailsPanel width from 380px to 420px to give more
  room for inner content blocks
- Add max-w-full to AsidePanel/AsidePanelStack root so the panel
  never exceeds its parent container width
- Add min-w-0 to ScrollArea and inner content div in AsidePanelContent
  to allow flex children to shrink properly
- Use overflow-x-hidden instead of overflow-hidden to preserve
  vertical layout flexibility

Closes #551

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:45:48 +08:00
bincxz
eed2dfb811 fix: remove unnecessary onClearSelection dependency in useCallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:45:21 +08:00
bincxz
b7fa6c0405 fix: resolve lint errors from recent PRs
- Remove unnecessary eslint-disable directive in useAutoSync.ts
- Use localStorageAdapter.remove() instead of bare localStorage in
  useSftpFileAssociations.ts (no-restricted-globals)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:44:37 +08:00
陈大猫
c8d145f52e feat: add default file opener setting for SFTP (#554)
* feat: add default file opener setting for SFTP

Add a global default opener that is used as fallback when no
per-extension file association exists, eliminating the need to
select an editor for every new file type.

The default opener is stored as a special "*" key in the existing
file associations map, so it syncs and persists automatically.

Settings UI provides three options: always ask (current behavior),
built-in editor, or a chosen system application.

Closes #550

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use reserved key for default opener to avoid extension collision

Replace "*" with "__default__" as the default opener storage key to
prevent a theoretical collision with files named "foo.*" where
getFileExtension would return "*".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip built-in editor default for known binary files

When the global default opener is set to built-in editor, binary files
(zip, png, etc.) should not be opened as text. Fall back to the chooser
dialog for known binary formats instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: store default opener in separate localStorage key

Move the default opener out of the FileAssociationsMap into its own
storage key (STORAGE_KEY_SFTP_DEFAULT_OPENER) to completely eliminate
any possibility of key collision with file extensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:30:54 +08:00
陈大猫
aeacd913f5 feat: sync global SFTP bookmarks via cloud sync (#553)
* feat: sync global SFTP bookmarks via cloud sync

Global SFTP path bookmarks were stored only in localStorage and not
included in the cloud sync payload, so they could not be synced across
devices. Add them to the sync settings, with auto-sync detection via
a custom event and in-memory snapshot rehydration on import.

Local bookmarks remain device-specific by design.

Closes #548

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: deduplicate global SFTP bookmarks by path during merge

When the same path is bookmarked independently on two devices, each
generates a different random ID. The entity-array merge preserves both,
creating duplicates. Add path-based deduplication after settings merge,
following the same pattern used for known hosts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync global bookmarks across renderer windows via storage event

When cloud sync imports bookmarks in the Settings window, the main
window's in-memory snapshot stays stale. Listen for cross-window
storage events on the bookmark key to auto-rehydrate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:59:58 +08:00
陈大猫
67b78abfce fix: sort directory symlinks with directories in SFTP file list (#552)
Symlinks pointing to directories (DirLinks) were sorted with regular
files instead of being grouped with directories. Reuse the existing
isNavigableDirectory() helper so these entries sort alongside real
directories.

Closes #549

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:15:19 +08:00
penguinway
e3b882bdf9 feat(sftp): add tree view explorer for SFTP pane (#547)
* feat(sftp): add onListDirectory to SftpPaneCallbacks interface

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* feat(sftp): implement onListDirectory in left and right callbacks

* feat(sftp): add tree view i18n keys

* feat(sftp): add list/tree view mode toggle to toolbar

* feat(sftp): add viewMode state and tree view conditional rendering to SftpPaneView

* feat(sftp): implement SftpPaneTreeView with lazy loading and context menu

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(sftp): resolve lint errors in tree view implementation

Rename inner `t` and `ts` variables in onListDirectory callbacks to
`toSize`/`toTs`/`ms` to avoid shadowing the outer `t` translation param.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(sftp): resolve post-merge lint errors

- Remove duplicate sftp.context.copyPath i18n key (upstream added it too)
- Remove unused AlertCircle import from SftpPaneFileList (upstream removed usage)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* perf(sftp): optimize SftpPaneTreeView render pipeline

Split useMemo into two stages so selection changes no longer
rebuild the full node descriptor array. Extract stable
selection-aware callbacks (drag, copy, delete) via refs so
TreeNode React.memo can reliably bail out. Remove unused props
(onNavigateTo, draggedFiles), move NodeDescriptor type to
module scope, and fix selectedFiles undefined bug in context menu.

* feat(sftp): add path-aware rename and delete for tree view

Wire renameFileAtPath and deleteFilesAtPath through the full
callback stack so tree view context menu actions operate on
full paths instead of basenames. Update useSftpPaneDialogs to
accept entryPath in openRenameDialog and resolve parent dir
in handleDelete, keeping list view behaviour unchanged.

* fix: harden SFTP tree view actions and selection

* fix: support tree selection shortcuts and nested create targets

* fix: keep SFTP tree view sorting in sync

* Improve SFTP tree view interactions and refresh behavior

* Optimize SFTP tree refresh and pane state usage

* Reduce remaining SFTP tree performance overhead

* Fix nested SFTP drop target routing

* Restore keyboard access to parent tree entry

* Revert "Display approved AI commands in terminal sessions before their output. (#546)"

This reverts commit 6d19413025.

* Fix SFTP tree view review issues: accessibility, view persistence, and polish

- Add aria-pressed/aria-checked to view mode toggle buttons for accessibility
- Preserve tree expanded state across view mode switches (CSS hidden instead of unmount)
- Add cross-window localStorage sync for view mode preferences
- Add loading/reconnecting overlay UI for tree view
- Fix toggleExpand concurrent load guard and file list memo dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix review round 2: scroll jank, memo correctness, path handling, a11y

Critical:
- Fix rAF scroll throttle capturing stale scrollTop (use ref for latest value)
- Add sftpDefaultViewMode to memo comparator to react to settings changes
- Replace ad-hoc path splitting in handleDelete with getParentPath/getFileName
- Add fullPath to permissionsState prop type in SftpOverlays

Important:
- Remove treeSelectionState from handleNodeClick/handleTreeContainerKeyDown
  deps to prevent full tree re-render on every expand/collapse
- Add role="radiogroup" container and aria-label to view toggle buttons
- Wrap JSON.parse in try/catch for storage event handler
- Deduplicate getParentPath call in renameFileAtPath
- Parallelize reloadExpandedPaths with Promise.all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Clean up review round 3: dead code, logging, and minor optimizations

- Remove dead isParentNavigation field from tree selection store (always
  false since ".." entries are filtered before entering the store)
- Replace empty catch blocks in dialog handlers with logger.warn
- Extract duplicated initialViewMode expression in SftpPaneView
- Stabilize handleSetViewMode by using refs for callbacks instead of
  depending on the entire callbacks object
- Remove redundant FINISH_LOADING dispatch on error path in
  loadChildrenForPath (LOAD_ERROR already removes from loadingPaths)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add same-pane drag-move, move-to dialog, and fix breadcrumb/tree sync

Features:
- Same-pane drag-and-drop to move files between directories in tree view
- "Move to..." context menu with path input dialog and autocomplete
- "Move to parent directory" quick action in context menu
- "Navigate to" context menu item for directories
- Error state UI with retry button in tree view
- Breadcrumb path deferred display during loading

Fixes:
- Fix breadcrumb and tree content showing different paths during navigation
  by atomically syncing resolvedRootPath and rootEntries in a single effect
- Fix toolbar displayPath updating before files load (defer until !loading)
- Reconnection detection and session error reporting in tree directory listing

UI improvements:
- Column widths use minmax()+fr instead of percentages with min-width protection
- Column headers truncate with overflow protection
- buildSftpColumnTemplate utility shared between tree and list views
- Column resize limits per field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Reapply "Display approved AI commands in terminal sessions before their output. (#546)"

This reverts commit f739e81e8d7691eb33965f6c431623a257fd8b4b.

* fix: resolve remote-to-local drag transfer source pane

* fix: invalidate target cache after transfers

* fix: reload tree root after create mutations

* fix: use receive callback for tree drop targets

* fix: trigger pane refresh after transfer completion

* fix: handle transfer refresh tokens only once

* fix: show move-to-parent for direct children

* fix: refresh list view after move-to-parent changes

* fix: address review issues in transfer refresh and retry flows

* feat: improve list view keyboard and folder drops

* fix: strengthen list view keyboard selection feedback

* style: make list view selection more obvious

* fix: keep list selection visible during keyboard navigation

* fix: rerender list rows when selection changes

* fix: sync list selection highlight updates

* style: align list selection with tree view

* style: hide list selection highlight when pane is unfocused

* feat: clear list selection when clicking empty space

* refine transfer row layout and clear list selection on empty click

* perf: make transfer size discovery asynchronous

* perf: parallelize SFTP transfers and show per-file progress for directories

- Parallelize file transfers within directories (4 concurrent workers)
- Batch pre-create all directories before file uploads begin
- Run conflict check and size discovery concurrently
- Parallelize external drag-drop file uploads (4 concurrent workers)
- Show individual child file progress under parent directory task
- Parent directory task displays file count progress (e.g. "3/10 files")
- Child tasks auto-cleanup on parent completion or cancellation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine sftp transfer panel ux

* fix sftp sidebar and upload task flow

* polish sftp transfer interactions

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-28 18:02:21 +08:00
Eric Chan
6d19413025 Display approved AI commands in terminal sessions before their output. (#546) 2026-03-27 19:59:59 +08:00
75 changed files with 6846 additions and 1832 deletions

13
App.tsx
View File

@@ -193,6 +193,7 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -200,8 +201,18 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
workspaceFocusStyle,
} = settings;
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
document.documentElement.setAttribute('data-workspace-focus', 'border');
} else {
document.documentElement.removeAttribute('data-workspace-focus');
}
}, [workspaceFocusStyle]);
const {
hosts,
keys,
@@ -1345,6 +1356,7 @@ function App({ settings }: { settings: SettingsState }) {
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
@@ -1394,6 +1406,7 @@ function App({ settings }: { settings: SettingsState }) {
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}

View File

@@ -355,6 +355,13 @@ const en: Messages = {
'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 > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
@@ -631,8 +638,21 @@ const en: Messages = {
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
'sftp.context.navigateTo': 'Navigate to',
'sftp.context.moveTo': 'Move to...',
'sftp.context.moveToParent': 'Move to parent directory',
'sftp.moveTo.title': 'Move to directory',
'sftp.moveTo.placeholder': 'Enter target directory path',
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
@@ -653,6 +673,13 @@ const en: Messages = {
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
@@ -672,6 +699,9 @@ const en: Messages = {
'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.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
@@ -763,6 +793,15 @@ const en: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.defaultOpener': 'Default File Opener',
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
'settings.sftp.defaultOpener.ask': 'Always ask',
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
@@ -791,6 +830,13 @@ const en: Messages = {
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',

View File

@@ -446,8 +446,21 @@ const zhCN: Messages = {
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
'sftp.context.navigateTo': '跳转到这里',
'sftp.context.moveTo': '移动到...',
'sftp.context.moveToParent': '移动到上级目录',
'sftp.moveTo.title': '移动到目录',
'sftp.moveTo.placeholder': '输入目标目录路径',
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
'sftp.context.rename': '重命名',
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
@@ -468,6 +481,13 @@ const zhCN: Messages = {
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
@@ -487,6 +507,9 @@ const zhCN: Messages = {
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
'sftp.deleteConfirm.host': '主机',
'sftp.deleteConfirm.path': '路径',
'sftp.error.loadFailed': '加载目录失败',
'sftp.error.downloadFailed': '下载失败',
'sftp.error.uploadFailed': '上传失败',
@@ -1099,6 +1122,15 @@ const zhCN: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.defaultOpener': '默认文件打开方式',
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
'settings.sftp.defaultOpener.ask': '每次询问',
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
@@ -1127,6 +1159,13 @@ const zhCN: Messages = {
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
'settings.sftp.defaultViewMode.tree': '树形视图',
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',

View File

@@ -12,6 +12,7 @@ export interface SftpPane {
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
transferMutationToken: number;
}
// Multi-tab state for left and right sides
@@ -39,6 +40,7 @@ export const createEmptyPane = (
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
transferMutationToken: 0,
});
// File watch event types

View File

@@ -88,6 +88,8 @@ export const useSftpConnections = ({
if (!activeTabId) return;
const isReconnectAttempt = reconnectingRef.current[side];
// Notify caller of the tab ID synchronously, before any async work.
// This allows callers to map metadata (e.g. connection keys) to the tab
// immediately, avoiding race conditions with deferred effects.
@@ -466,7 +468,11 @@ export const useSftpConnections = ({
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));

View File

@@ -45,7 +45,8 @@ interface SftpExternalOperationsResult {
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
@@ -377,6 +378,7 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
};
addExternalUpload(scanningTask);
}
@@ -404,6 +406,8 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
};
addExternalUpload(transferTask);
}
@@ -505,7 +509,7 @@ export const useSftpExternalOperations = (
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
@@ -525,13 +529,14 @@ export const useSftpExternalOperations = (
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
@@ -540,7 +545,7 @@ export const useSftpExternalOperations = (
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: pane.connection.currentPath,
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
@@ -551,7 +556,14 @@ export const useSftpExternalOperations = (
controller
);
await refresh(side, { tabId: uploadPaneId });
// Invalidate cache for the upload target so returning to that path
// triggers a fresh listing.
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
@@ -561,6 +573,7 @@ export const useSftpExternalOperations = (
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
@@ -634,7 +647,9 @@ export const useSftpExternalOperations = (
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
await refresh(side, { tabId: uploadPaneId });
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);

View File

@@ -3,9 +3,12 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -25,6 +28,7 @@ interface UseSftpPaneActionsParams {
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
dirCacheTtlMs: number;
}
@@ -40,7 +44,9 @@ interface UseSftpPaneActionsResult {
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
@@ -49,6 +55,8 @@ interface UseSftpPaneActionsResult {
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
@@ -71,8 +79,39 @@ export const useSftpPaneActions = ({
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
}, []);
const isSamePath = useCallback((a: string, b: string): boolean => {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}, [normalizePathForCompare]);
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizePathForCompare(candidate);
const normalizedParent = normalizePathForCompare(parent);
if (normalizedCandidate === normalizedParent) return false;
if (/^[a-z]:\\$/.test(normalizedParent)) {
return normalizedCandidate.startsWith(normalizedParent);
}
if (normalizedParent === "/") {
return normalizedCandidate.startsWith("/");
}
const separator = normalizedParent.includes("\\") ? "\\" : "/";
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
}, [normalizePathForCompare]);
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
@@ -146,7 +185,7 @@ export const useSftpPaneActions = ({
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
...prev,
@@ -156,7 +195,7 @@ export const useSftpPaneActions = ({
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
@@ -200,7 +239,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
loading: true,
error: null,
}));
@@ -270,7 +309,7 @@ export const useSftpPaneActions = ({
connectionId,
path,
files,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
@@ -280,7 +319,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
@@ -340,6 +379,25 @@ export const useSftpPaneActions = ({
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (pane?.connection) {
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
if (!hasRemoteSession) {
if (options?.tabId) return;
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",
}));
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
@@ -362,7 +420,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
);
const navigateUp = useCallback(
@@ -409,6 +467,10 @@ export const useSftpPaneActions = ({
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
@@ -419,11 +481,15 @@ export const useSftpPaneActions = ({
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
@@ -433,11 +499,11 @@ export const useSftpPaneActions = ({
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
}, [updateActiveTab]);
const selectAll = useCallback(
@@ -467,12 +533,12 @@ export const useSftpPaneActions = ({
);
}, []);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const createDirectoryAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -485,7 +551,9 @@ export const useSftpPaneActions = ({
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -497,12 +565,21 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createDirectoryAtPath(side, pane.connection.currentPath, name);
},
[createDirectoryAtPath, getActivePane],
);
const fullPath = joinPath(pane.connection.currentPath, name);
const createFileAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -529,7 +606,9 @@ export const useSftpPaneActions = ({
throw new Error("No write method available");
}
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -541,6 +620,15 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createFileAtPath(side, pane.connection.currentPath, name);
},
[createFileAtPath, getActivePane],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
@@ -686,6 +774,139 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
// Rename using a full source path (for tree view where entryPath is already absolute).
// newName is still a basename; the new path is built as joinPath(parent, newName).
const renameFileAtPath = useCallback(
async (side: "left" | "right", oldPath: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const parentPath = getParentPath(oldPath);
const newPath = joinPath(parentPath, 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);
}
if (pane.connection.currentPath === parentPath) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const moveEntriesToPath = useCallback(
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
const pane = getActivePane(side);
if (!pane?.connection || sourcePaths.length === 0) return;
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
const filteredSources = uniqueSources
.sort((a, b) => a.length - b.length)
.filter((path, index, arr) =>
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
);
const movableSources = filteredSources.filter((sourcePath) => {
if (isSamePath(sourcePath, targetPath)) return false;
if (isDescendantPath(targetPath, sourcePath)) return false;
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
return !isSamePath(destinationPath, sourcePath);
});
if (movableSources.length === 0) return;
const sourceParentNames = new Map<string, string[]>();
for (const sourcePath of movableSources) {
const parentPath = getParentPath(sourcePath);
const names = sourceParentNames.get(parentPath) ?? [];
names.push(getFileName(sourcePath));
sourceParentNames.set(parentPath, names);
}
try {
if (pane.connection.isLocal) {
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
if (!renameLocalFile) {
throw new Error("Local rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameLocalFile(sourcePath, destinationPath);
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const renameSftp = netcattyBridge.get()?.renameSftp;
if (!renameSftp) {
throw new Error("SFTP rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
const currentPath = pane.connection.currentPath;
const sourceParents = Array.from(sourceParentNames.keys());
const currentPathAffected =
sourceParents.some((path) => isSamePath(path, currentPath)) ||
isSamePath(targetPath, currentPath);
if (currentPathAffected) {
await refresh(side);
} else {
updateActiveTab(side, (prev) => {
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
return prev;
}
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
return prev;
}
const removeSet = new Set(namesInCurrentPath);
const nextSelection = new Set(prev.selectedFiles);
for (const name of removeSet) {
nextSelection.delete(name);
}
return {
...prev,
files: prev.files.filter((file) => !removeSet.has(file.name)),
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
@@ -730,10 +951,14 @@ export const useSftpPaneActions = ({
setFilter,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
};
};

View File

@@ -14,6 +14,7 @@ interface SftpTabsState {
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;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
@@ -34,6 +35,8 @@ interface SftpTabsState {
getActiveTabId: (side: "left" | "right") => string | null;
}
const EMPTY_SELECTION = new Set<string>();
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
[updateTab],
);
const clearSelectionsExcept = useCallback(
(target: { side: "left" | "right"; tabId: string } | null) => {
const clearSideSelections = (
prev: SftpSideTabs,
side: "left" | "right",
): SftpSideTabs => {
let changed = false;
const tabs = prev.tabs.map((tab) => {
const shouldKeepSelection =
target?.side === side && target.tabId === tab.id;
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
return tab;
}
changed = true;
return { ...tab, selectedFiles: EMPTY_SELECTION };
});
return changed ? { ...prev, tabs } : prev;
};
setLeftTabs((prev) => clearSideSelections(prev, "left"));
setRightTabs((prev) => clearSideSelections(prev, "right"));
},
[],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -60,6 +60,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
@@ -288,7 +296,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -81,6 +81,7 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -372,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,

View File

@@ -22,6 +22,8 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -30,6 +32,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
@@ -65,6 +68,7 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -239,6 +243,14 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -343,6 +355,23 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
return stored === 'border' ? 'border' : 'dim';
});
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
setWorkspaceFocusStyleState(style);
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -433,6 +462,8 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
@@ -442,6 +473,10 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
@@ -585,9 +620,20 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -623,7 +669,7 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
@@ -632,7 +678,7 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
@@ -783,6 +829,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -804,6 +856,19 @@ export const useSettingsState = () => {
setImmersiveModeState(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
window.addEventListener('storage', handleStorageChange);
@@ -911,6 +976,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
}, [sftpDefaultViewMode, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -1145,6 +1217,10 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1173,6 +1249,8 @@ export const useSettingsState = () => {
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1180,8 +1258,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes, immersiveMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
]),
};
};

View File

@@ -3,10 +3,10 @@
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
@@ -17,10 +17,12 @@ export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// Shared state and subscribers for cross-component synchronization
// ---------------------------------------------------------------------------
// Per-extension associations store
// ---------------------------------------------------------------------------
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
@@ -39,7 +41,6 @@ function loadFromStorage(): FileAssociationsMap {
return {};
}
// Initialize from storage
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
@@ -47,7 +48,6 @@ function saveToStorage(associations: FileAssociationsMap) {
}
function updateAssociations(newAssociations: FileAssociationsMap) {
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
subscribers.forEach(callback => callback());
@@ -62,15 +62,54 @@ function getSnapshot() {
return snapshotRef;
}
// ---------------------------------------------------------------------------
// Default opener store (separate from per-extension associations)
// ---------------------------------------------------------------------------
const defaultOpenerSubscribers = new Set<() => void>();
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
};
function subscribeDefaultOpener(callback: () => void) {
defaultOpenerSubscribers.add(callback);
return () => defaultOpenerSubscribers.delete(callback);
}
function getDefaultOpenerSnapshot() {
return defaultOpenerSnapshot;
}
function updateDefaultOpener(entry: FileAssociationEntry | null) {
defaultOpenerSnapshot = { entry };
if (entry) {
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
} else {
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
}
defaultOpenerSubscribers.forEach(callback => callback());
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
updateDefaultOpener(
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
);
}
};
window.addEventListener('storage', handleStorage);
@@ -78,18 +117,46 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get the opener entry for a file based on its extension
* Get the opener entry for a file based on its extension.
* Falls back to the default opener when no per-extension association exists.
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
return associations[ext] || null;
}, [associations]);
if (associations[ext]) return associations[ext];
// Fall back to default opener, but skip built-in editor for binary files
const fallback = defaultOpenerState.entry;
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
return null;
}
return fallback;
}, [associations, defaultOpenerState]);
/**
* Get the default (fallback) opener, if set.
*/
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
return defaultOpenerState.entry;
}, [defaultOpenerState]);
/**
* Set the default opener used when no per-extension association exists.
*/
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
updateDefaultOpener({ openerType, systemApp });
}, []);
/**
* Remove the default opener.
*/
const removeDefaultOpener = useCallback(() => {
updateDefaultOpener(null);
}, []);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
@@ -109,7 +176,7 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get all associations as an array
* Get all per-extension associations as an array.
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
@@ -129,6 +196,9 @@ export function useSftpFileAssociations() {
return {
associations,
getOpenerForFile,
getDefaultOpener,
setDefaultOpener,
removeDefaultOpener,
setOpenerForExtension,
removeAssociation,
getAllAssociations,

View File

@@ -57,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
@@ -110,6 +111,30 @@ export const useSftpState = (
}
}, []);
const getPaneByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
return null;
}, [leftTabsRef, rightTabsRef]);
const getTabByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "left" as const, tabId: tab.id, pane: tab };
}
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "right" as const, tabId: tab.id, pane: tab };
}
}
return null;
}, [leftTabsRef, rightTabsRef]);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
@@ -183,10 +208,14 @@ export const useSftpState = (
selectAll,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
} = useSftpPaneActions({
hosts,
@@ -207,6 +236,7 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs: DIR_CACHE_TTL_MS,
});
@@ -244,6 +274,7 @@ export const useSftpState = (
conflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -254,8 +285,13 @@ export const useSftpState = (
resolveConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
getTabByConnectionId,
updateTab,
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -305,15 +341,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -324,6 +365,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -332,6 +374,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
methodsRef.current = {
getFilteredFiles,
@@ -352,15 +395,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -371,6 +419,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -379,6 +428,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
// Create stable method wrappers that call through methodsRef
@@ -402,6 +452,8 @@ export const useSftpState = (
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
methodsRef.current.clearSelectionsExcept(...args),
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
@@ -409,11 +461,17 @@ export const useSftpState = (
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
methodsRef.current.createDirectoryAtPath(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
methodsRef.current.createFileAtPath(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
@@ -425,6 +483,7 @@ export const useSftpState = (
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
@@ -433,6 +492,7 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref

View File

@@ -12,11 +12,13 @@ import type {
Identity,
KnownHost,
PortForwardingRule,
SftpBookmark,
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -37,6 +39,7 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../infrastructure/config/storageKeys';
@@ -161,6 +164,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
@@ -224,6 +231,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
}
@@ -298,6 +308,8 @@ export function applySyncPayload(
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
}

View File

@@ -102,11 +102,14 @@ interface StatusDotProps {
}
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-muted-foreground/50',
};
@@ -279,6 +282,7 @@ interface ProviderCardProps {
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
}
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
}) => {
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
{error}
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
<CloudOff size={14} />
</Button>
</>
) : isConnecting && onCancelConnect ? (
<Button
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1"
>
<X size={14} />
{t('common.cancel')}
</Button>
) : (
<Button
size="sm"
@@ -1088,6 +1105,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
@@ -1104,6 +1122,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>

View File

@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
// Size variants - all use rounded corners for consistency
const sizeClasses = {
sm: "h-6 w-6 rounded-md",
md: "h-11 w-11 rounded-xl",
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
lg: "h-14 w-14 rounded-xl",
};
const iconSizes = {
@@ -98,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
<div
className={cn(
containerClass,
"flex items-center justify-center border border-border/40 overflow-hidden",
"flex items-center justify-center overflow-hidden",
bg,
className,
)}

View File

@@ -625,6 +625,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<AsidePanel
open={true}
onClose={onCancel}
width="w-[420px]"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
@@ -738,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
@@ -983,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
@@ -1178,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}

View File

@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
interface SelectHostPanelProps {
hosts: Host[];
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}, [currentPath]);
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
)}
{groupsWithCounts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<LayoutGrid size={18} />
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: group.count })}
</div>
</div>
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Hosts Section */}
{filteredHosts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIds.includes(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-muted border border-border"
? "bg-muted"
: "hover:bg-muted/70",
)}
onClick={() => onSelect(host)}
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-10 w-10"
className="h-8 w-8 rounded-md"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.username}@{host.hostname}:{host.port || 22}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">
{host.label}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">
{connectionStr}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected && (
<Check size={16} className="text-primary" />
<Check size={14} className="text-primary shrink-0" />
)}
</div>
);
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
/>
)}
</div>
</TooltipProvider>
);
};

View File

@@ -63,6 +63,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
);
};

View File

@@ -10,7 +10,7 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
@@ -31,12 +31,17 @@ import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
@@ -55,6 +60,8 @@ interface SftpSidePanelProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
@@ -65,6 +72,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
keys,
identities,
updateHosts,
sftpDefaultViewMode,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
@@ -76,6 +84,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
@@ -109,6 +119,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
listSftp,
mkdirLocal,
deleteLocalFile,
listLocalDir,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -119,6 +130,17 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: isVisible && hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
@@ -130,10 +152,60 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, []);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible, syncFocusedSelection]);
useEffect(() => {
if (!isVisible) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
@@ -168,6 +240,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const {
@@ -432,6 +505,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Filter transfers to those relevant to the active connection's host,
// so workspace focus switches don't show transfers from other hosts.
const filtered = sftp.transfers.filter((t) => {
if (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
if (connection.isLocal) {
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
}
@@ -504,9 +578,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
rightCallbacks={rightCallbacks}
>
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
@@ -546,8 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
@@ -558,6 +638,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
@@ -608,6 +689,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&

View File

@@ -40,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
// Wrapper component that subscribes to activeTabId for CSS visibility
// This isolates the activeTabId subscription - only this component re-renders on tab switch
@@ -50,6 +52,7 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
@@ -65,6 +68,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keys,
identities,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
@@ -77,6 +81,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const { t } = useI18n();
const isActive = useIsSftpActive();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
@@ -109,6 +114,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
listSftp,
mkdirLocal,
deleteLocalFile,
listLocalDir,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -129,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive,
});
@@ -136,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const focusedSide = useSftpFocusedSide();
// Handle pane focus when clicking on a pane container
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
// Clear the opposite side's selection so file operations only affect the focused pane
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
const prevSide = sftpFocusStore.getFocusedSide();
sftpFocusStore.setFocusedSide(side);
if (prevSide !== side) {
if (targetTabId) {
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
} else {
// Focus side changed — clear other panes but keep the newly focused pane intact.
keepOnlyActivePaneSelections(sftpRef.current, side);
}
}
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
@@ -205,10 +222,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, 5),
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
[sftp.transfers],
);
@@ -251,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
handlePaneFocus("left", tabId);
}, [handleAddTabLeft, handlePaneFocus]);
const handleAddTabRightWithFocus = useCallback(() => {
const tabId = handleAddTabRight();
handlePaneFocus("right", tabId);
}, [handleAddTabRight, handlePaneFocus]);
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
handleSelectTabLeft(tabId);
handlePaneFocus("left", tabId);
}, [handlePaneFocus, handleSelectTabLeft]);
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
handleSelectTabRight(tabId);
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
return (
<SftpContextProvider
hosts={hosts}
@@ -291,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={leftTabsInfo}
side="left"
onSelectTab={handleSelectTabLeft}
onSelectTab={handleSelectTabLeftWithFocus}
onCloseTab={handleCloseTabLeft}
onAddTab={handleAddTabLeft}
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
/>
@@ -309,6 +347,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
@@ -348,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={rightTabsInfo}
side="right"
onSelectTab={handleSelectTabRight}
onSelectTab={handleSelectTabRightWithFocus}
onCloseTab={handleCloseTabRight}
onAddTab={handleAddTabRight}
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
/>
@@ -366,6 +407,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
@@ -427,6 +471,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -375,6 +375,7 @@ interface TerminalLayerProps {
onToggleBroadcast?: (workspaceId: string) => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: 'list' | 'tree';
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
@@ -425,6 +426,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
@@ -1974,6 +1976,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={isVisibleSftpPanel ? sftpActiveHost : null}
initialLocation={
isVisibleSftpPanel
@@ -1989,6 +1992,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoSync={isVisibleSftpPanel ? sftpAutoSync : false}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
@@ -2293,6 +2298,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP

View File

@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>

View File

@@ -28,10 +28,12 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
const associations = getAllAssociations();
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
@@ -39,6 +41,22 @@ export default function SettingsFileAssociationsTab() {
}
}, [removeAssociation, t]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return;
const result = await bridge.selectApplication();
if (result) {
setDefaultOpener('system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingDefaultApp(false);
}
}, [setDefaultOpener]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
@@ -130,6 +148,76 @@ export default function SettingsFileAssociationsTab() {
</div>
</div>
{/* Default view mode section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDefaultViewMode('list')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'list'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'list'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'list' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.list')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.listDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDefaultViewMode('tree')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'tree'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'tree'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'tree' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.tree')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.treeDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
@@ -290,6 +378,117 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Transfer concurrency section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.transferConcurrency.desc')}
</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={16}
step={1}
value={sftpTransferConcurrency}
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
className="flex-1 accent-primary"
/>
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
</div>
</div>
{/* Default opener section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultOpener')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => removeDefaultOpener()}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
!defaultOpener
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
!defaultOpener ? "border-primary" : "border-muted-foreground/30"
)}>
{!defaultOpener && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultOpener.ask')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.askDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setDefaultOpener('builtin-editor')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'builtin-editor'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'builtin-editor' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'builtin-editor' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('sftp.opener.builtInEditor')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.builtInDesc')}
</p>
</div>
</div>
</button>
<button
onClick={handleSelectDefaultSystemApp}
disabled={isSelectingDefaultApp}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'system-app'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'system-app' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'system-app' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
? defaultOpener.systemApp.name
: t('settings.sftp.defaultOpener.systemApp')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.systemAppDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -84,6 +84,8 @@ export default function SettingsTerminalTab(props: {
value: TerminalSettings[K],
) => void;
availableFonts: TerminalFont[];
workspaceFocusStyle: 'dim' | 'border';
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
}) {
const {
terminalThemeId,
@@ -95,6 +97,8 @@ export default function SettingsTerminalTab(props: {
terminalSettings,
updateTerminalSetting,
availableFonts,
workspaceFocusStyle,
setWorkspaceFocusStyle,
} = props;
const { t } = useI18n();
@@ -866,6 +870,23 @@ export default function SettingsTerminalTab(props: {
</SettingRow>
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
<div className="space-y-1">
<SettingRow
label={t("settings.terminal.workspaceFocus.style")}
description={t("settings.terminal.workspaceFocus.style.desc")}
>
<Select
value={workspaceFocusStyle}
onChange={(v) => setWorkspaceFocusStyle(v as 'dim' | 'border')}
options={[
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
]}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow

View File

@@ -9,37 +9,53 @@
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
export interface SftpTransferSource {
name: string;
isDirectory: boolean;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
}
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onRefreshTab: (tabId: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onCreateDirectoryAtPath: (path: string, name: string) => Promise<void>;
onCreateFile: (name: string) => Promise<void>;
onCreateFileAtPath: (path: string, name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onDeleteFilesAtPath: (connectionId: string, path: string, fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onEditPermissions?: (file: SftpFileEntry) => void;
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
// File operations
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
}
export interface SftpDragCallbacks {
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
onDragStart: (files: SftpTransferSource[], side: "left" | "right") => void;
onDragEnd: () => void;
}
@@ -91,16 +107,18 @@ export interface SftpContextValue {
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
// Drag state (shared between panes)
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
export interface SftpDragContextValue {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
@@ -116,13 +134,19 @@ export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
// Hook to get drag-related values
// Hook to get drag-related values (reads from separate SftpDragContext)
export const useSftpDrag = () => {
const context = useSftpContext();
return {
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
};
const context = useContext(SftpDragContext);
if (!context) {
throw new Error("useSftpDrag must be used within SftpContextProvider");
}
return useMemo(
() => ({
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
}),
[context.draggedFiles, context.dragCallbacks],
);
};
// Hook to get hosts
@@ -140,7 +164,7 @@ export const useSftpUpdateHosts = () => {
interface SftpContextProviderProps {
hosts: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
@@ -156,19 +180,29 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
rightCallbacks,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
// Note: The callbacks objects should be stable (created with useMemo in parent)
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
const value = useMemo<SftpContextValue>(
() => ({
hosts,
updateHosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
}),
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
[hosts, updateHosts, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
// Memoize drag context separately so only drag consumers re-render on drag state changes
const dragValue = useMemo<SftpDragContextValue>(
() => ({
draggedFiles,
dragCallbacks,
}),
[draggedFiles, dragCallbacks],
);
return (
<SftpContext.Provider value={value}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpContext.Provider>
);
};

View File

@@ -6,12 +6,13 @@ import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } from 'react';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import { ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
interface SftpFileRowProps {
entry: SftpFileEntry;
index: number;
isSelected: boolean;
showSelectionHighlight: boolean;
isDragOver: boolean;
columnWidths: ColumnWidths;
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
@@ -27,6 +28,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
entry,
index,
isSelected,
showSelectionHighlight,
isDragOver,
columnWidths,
onSelect,
@@ -58,10 +60,13 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
const handleDrop = useCallback((e: React.DragEvent) => {
onDrop(entry, e);
}, [entry, onDrop]);
const isSelectionVisible = isSelected && showSelectionHighlight;
return (
<div
data-sftp-row="true"
data-entry-name={entry.name}
data-selected={isSelected ? "true" : "false"}
draggable={!isParentDir}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
@@ -71,33 +76,53 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onClick={handleSelect}
onDoubleClick={handleOpen}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
"px-4 py-2 items-center cursor-pointer text-sm",
isSelectionVisible
? "bg-accent text-accent-foreground hover:bg-accent"
: "hover:bg-accent/50",
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
)}
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
isSelectionVisible
? "bg-accent-foreground/10 text-accent-foreground"
: isNavDir
? "bg-primary/10 text-primary"
: "bg-secondary/60 text-muted-foreground"
)}>
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
{/* Show link indicator for symlinks */}
{entry.type === 'symlink' && (
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
<Link
size={8}
className={cn(
"absolute -bottom-0.5 -right-0.5",
isSelectionVisible ? "text-accent-foreground/80" : "text-muted-foreground",
)}
aria-hidden="true"
/>
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
<span
className={cn(
"truncate",
entry.type === 'symlink' && "italic pr-1",
isSelectionVisible && "font-medium",
)}
title={entry.name}
>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">{modifiedLabel}</span>
<span className="text-xs text-muted-foreground truncate text-right">
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isNavDir ? '--' : sizeLabel}
</span>
<span className="text-xs text-muted-foreground truncate capitalize text-right">
<span className={cn("text-xs truncate capitalize text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
</span>
</div>
@@ -107,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.index !== next.index) return false;
if (prev.isSelected !== next.isSelected) return false;
// Only re-render for showSelectionHighlight changes when the row is actually selected
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.columnWidths.name !== next.columnWidths.name) return false;
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;

View File

@@ -24,8 +24,8 @@ interface SftpOverlaysProps {
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;
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
@@ -43,7 +43,7 @@ interface SftpOverlaysProps {
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
hosts,
sftp,
visibleTransfers,
@@ -101,7 +101,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
/>
{showTransferQueue && (
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} allTransfers={sftp.transfers} />
)}
<SftpConflictDialog
@@ -114,17 +114,11 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(file, permissions) => {
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,
permissionsState.fullPath,
permissions,
);
}
@@ -160,4 +154,4 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
/>
</>
);
};
});

View File

@@ -11,11 +11,14 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./index";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
hostLabel?: string;
currentPath?: string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
onDisconnect: () => void;
}
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
label ? (
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
) : null;
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
hostLabel,
currentPath,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
setHostSearch,
onConnect,
onDisconnect,
}) => (
}) => {
const isSingleDeleteTarget = deleteTargets.length === 1;
const deletePath = (() => {
if (isSingleDeleteTarget) {
return deleteTargets[0];
}
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) return uniquePaths[0];
if (uniquePaths.length > 1) return "Multiple locations";
return currentPath;
})();
const showDeleteList = deleteTargets.length > 1;
const deleteListItems = (() => {
if (!showDeleteList) return [];
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) {
return deleteTargets.map((target) => getFileName(target) || target);
}
return deleteTargets;
})();
return (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription>
{t("sftp.deleteConfirm.desc")}
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
</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 className="space-y-3">
{hostLabel || deletePath ? (
<div className="text-xs text-muted-foreground space-y-1.5">
{hostLabel ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
<span className="break-all">{hostLabel}</span>
</div>
) : null}
{deletePath ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
<span className="break-all">{deletePath}</span>
</div>
) : null}
</div>
))}
) : null}
{showDeleteList ? (
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteListItems.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}
/>
</>
);
);
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from "react";
import { ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -9,10 +9,12 @@ import {
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import { joinPath } from "../../application/state/sftp/utils";
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
import { isNavigableDirectory } from "./index";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
@@ -21,6 +23,7 @@ interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
side: "left" | "right";
isPaneFocused: boolean;
columnWidths: ColumnWidths;
sortField: SortField;
sortOrder: SortOrder;
@@ -32,8 +35,10 @@ interface SftpPaneFileListProps {
totalHeight: number;
sortedDisplayFiles: SftpFileEntry[];
isDragOverPane: boolean;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onRefresh: () => void;
onNavigateTo: (path: string) => void;
onClearSelection: () => void;
setShowNewFolderDialog: (open: boolean) => void;
setShowNewFileDialog: (open: boolean) => void;
getNextUntitledName: (existingNames: string[]) => string;
@@ -48,7 +53,8 @@ interface SftpPaneFileListProps {
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
@@ -99,10 +105,11 @@ const SftpErrorWithLogs: React.FC<{
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
t,
pane,
side,
isPaneFocused,
columnWidths,
sortField,
sortOrder,
@@ -116,6 +123,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
isDragOverPane,
draggedFiles,
onRefresh,
onNavigateTo,
onClearSelection,
setShowNewFolderDialog,
setShowNewFileDialog,
getNextUntitledName,
@@ -130,6 +139,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onMoveEntriesToPath,
onOpenFileWith,
onEditFile,
onDownloadFile,
@@ -147,6 +157,39 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
return map;
}, [sortedDisplayFiles]);
// Push sorted file names into the list order store for keyboard navigation
useEffect(() => {
const names = sortedDisplayFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
sftpListOrderStore.setItems(pane.id, names);
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
useEffect(() => {
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
const container = fileListRef.current;
if (!container) return;
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
.find((element) => element.dataset.entryName === selectedName);
row?.scrollIntoView({ block: "nearest" });
}, [fileListRef, pane.selectedFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('[data-sftp-row="true"]')) return;
if (pane.selectedFiles.size === 0) return;
onClearSelection();
}, [onClearSelection, pane.selectedFiles.size]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
@@ -155,6 +198,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
entry={entry}
index={index}
isSelected={pane.selectedFiles.has(entry.name)}
showSelectionHighlight={isPaneFocused}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
onSelect={handleRowSelect}
@@ -180,6 +224,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
</>
)}
</ContextMenuItem>
{isNavigableDirectory(entry) && (
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
@@ -202,8 +251,9 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
@@ -211,6 +261,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
onCopyToOtherPane(fileData);
@@ -228,7 +280,27 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{t("sftp.context.copyPath")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
{(() => {
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
const targetParent = getParentPath(sourceParent);
if (sourceParent === targetParent) return null;
return (
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
const sourcePaths = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
void onMoveEntriesToPath(sourcePaths, targetParent);
}}
>
<ArrowUp size={14} className="mr-2" />{" "}
{t("sftp.context.moveToParent")}
</ContextMenuItem>
);
})()}
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
</ContextMenuItem>
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
@@ -240,9 +312,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<ContextMenuItem
className="text-destructive"
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
openDeleteConfirm(files);
}}
>
@@ -264,7 +337,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
),
[
columnWidths,
dragOverEntry,
filesByName,
handleEntryDragOver,
handleEntryDrop,
@@ -272,11 +344,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
handleRowDragLeave,
handleRowOpen,
handleRowSelect,
dragOverEntry,
isPaneFocused,
onCopyToOtherPane,
onMoveEntriesToPath,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onNavigateTo,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -306,7 +382,13 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{renderRow(entry, index)}
</React.Fragment>
)),
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
[
renderRow,
rowHeight,
shouldVirtualize,
sortedDisplayFiles,
visibleRows,
],
);
return (
@@ -316,16 +398,16 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
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}%`,
gridTemplateColumns: buildSftpColumnTemplate(columnWidths),
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
@@ -335,12 +417,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
@@ -350,30 +432,30 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.size")}</span>
<span className="truncate whitespace-nowrap">{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"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.kind")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
</div>
</div>
@@ -386,6 +468,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onClick={handleBackgroundClick}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
@@ -457,7 +540,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<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,
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
})}
{pane.selectedFiles.size > 0 &&
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
@@ -497,4 +580,4 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
)}
</>
);
};
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
@@ -53,6 +53,8 @@ interface SftpPaneToolbarProps {
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
viewMode: 'list' | 'tree';
onSetViewMode: (mode: 'list' | 'tree') => void;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
@@ -60,7 +62,7 @@ interface SftpPaneToolbarProps {
// always gets at least ~200px of space.
const COLLAPSE_WIDTH = 400;
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
t,
pane,
onNavigateTo,
@@ -101,9 +103,22 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
viewMode,
onSetViewMode,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
useEffect(() => {
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
prevDisplayConnectionIdRef.current = pane.connection?.id;
// Sync immediately on connection change; otherwise defer until loading completes
if (connectionChanged || !pane.loading) {
setDisplayPath(pane.connection?.currentPath ?? "");
}
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
// Observe the overall toolbar width to decide whether to collapse action buttons
useEffect(() => {
@@ -157,6 +172,36 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'list' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'list'}
aria-label={t('sftp.viewMode.list')}
onClick={() => onSetViewMode('list')}
>
<List size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.list')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'tree' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'tree'}
aria-label={t('sftp.viewMode.tree')}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.tree')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -279,6 +324,32 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
// Overflow dropdown menu items (same collapsible actions as menu items)
const overflowMenuItems = (
<div className="flex flex-col min-w-[140px]">
<div role="radiogroup" aria-label={t('sftp.viewMode.label')}>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'list' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'list'}
onClick={() => onSetViewMode('list')}
>
<List size={14} className="shrink-0" />
{t('sftp.viewMode.list')}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'tree' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'tree'}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} className="shrink-0" />
{t('sftp.viewMode.tree')}
</button>
</div>
{isRemote && (
<Popover>
<PopoverTrigger asChild>
@@ -410,7 +481,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
path={displayPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
@@ -600,4 +671,4 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
</TooltipProvider>
);
};
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
import { SftpPaneTreeView } from "./SftpPaneTreeView";
import {
useActiveTabId,
useSftpDrag,
@@ -15,6 +16,7 @@ import {
useSftpUpdateHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
@@ -26,6 +28,15 @@ import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
interface TreeReloadRequest {
token: number;
paths?: string[];
full?: boolean;
}
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -56,31 +67,66 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
dialogActionScopeId: string;
isPaneFocused: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
forceActive?: boolean;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
dialogActionScopeId,
isPaneFocused,
sftpDefaultViewMode,
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
forceActive,
}) => {
const isActive = true;
const activeTabId = useActiveTabId(side);
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const { t } = useI18n();
const hostId = pane.connection?.hostId;
const { hostViewMode, setHostViewMode: saveHostViewMode } = useSftpHostViewMode(hostId);
const [, startTransition] = useTransition();
const [showFilterBar, setShowFilterBar] = useState(false);
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
// Lazy-mount: only render the tree component once tree mode has been activated
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
useEffect(() => {
if (viewMode === 'tree' && !treeEverMounted) setTreeEverMounted(true);
}, [viewMode, treeEverMounted]);
const filterInputRef = useRef<HTMLInputElement>(null);
const requestTreeReload = useCallback((paths?: string[], full = false) => {
setTreeReloadRequest((prev) => ({
token: prev.token + 1,
paths,
full,
}));
}, []);
const requestNestedTreeReload = useCallback((paths?: string[]) => {
const targets = Array.from(new Set((paths ?? []).filter(Boolean)));
if (targets.length > 0) {
requestTreeReload(targets);
}
}, [requestTreeReload]);
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
@@ -141,11 +187,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
[hostBookmarks, globalBookmarks],
);
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
const { sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles: pane.showHiddenFiles,
enableListView: viewMode === 'list',
sortField,
sortOrder,
});
@@ -166,7 +213,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handlePathSubmit,
} = useSftpPanePath({
connection: pane.connection,
filteredFiles,
files: pane.files,
showHiddenFiles: pane.showHiddenFiles,
onNavigateTo: callbacks.onNavigateTo,
});
const {
@@ -204,6 +252,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
@@ -211,11 +261,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
t,
pane,
onCreateDirectory: callbacks.onCreateDirectory,
onCreateDirectoryAtPath: callbacks.onCreateDirectoryAtPath,
onCreateFile: callbacks.onCreateFile,
onRenameFile: callbacks.onRenameFile,
onDeleteFiles: callbacks.onDeleteFiles,
onCreateFileAtPath: callbacks.onCreateFileAtPath,
onRenameFileAtPath: callbacks.onRenameFileAtPath,
onDeleteFilesAtPath: callbacks.onDeleteFilesAtPath,
onClearSelection: callbacks.onClearSelection,
onMutateSuccess: (paths?: string[]) => requestNestedTreeReload(paths),
});
const handleUploadExternalFiles = useCallback(async (dataTransfer: DataTransfer, targetPath?: string) => {
await callbacks.onUploadExternalFiles?.(dataTransfer, targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
const {
dragOverEntry,
isDragOverPane,
@@ -236,7 +300,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
draggedFiles,
onDragStart,
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
onUploadExternalFiles: callbacks.onUploadExternalFiles,
onMoveEntriesToPath: callbacks.onMoveEntriesToPath,
onUploadExternalFiles: handleUploadExternalFiles,
onOpenEntry: callbacks.onOpenEntry,
onRangeSelect: callbacks.onRangeSelect,
onToggleSelection: callbacks.onToggleSelection,
@@ -250,14 +315,26 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
visibleRows,
} = useSftpPaneVirtualList({
isActive,
enabled: viewMode === 'list',
sortedDisplayFiles,
});
const toFullPath = useCallback(
(target: string) => {
const currentPath = pane.connection?.currentPath;
if (!currentPath || target.includes("/") || target.includes("\\")) {
return target;
}
return joinPath(currentPath, target);
},
[pane.connection?.currentPath],
);
// Handle keyboard shortcut dialog actions
const dialogActionHandlers = useMemo(
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onRename: (fileName: string) => openRenameDialog(toFullPath(fileName)),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames.map(toFullPath)),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
@@ -274,6 +351,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
openDeleteConfirm,
openRenameDialog,
pane.files,
toFullPath,
setFileNameError,
setNewFileName,
setNewFolderName,
@@ -282,12 +360,51 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
],
);
useSftpDialogActionHandler(side, dialogActionHandlers);
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
};
const handleRefresh = useCallback(() => {
callbacks.onRefresh();
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, requestTreeReload, viewMode]);
const onSetFilterRef = useRef(callbacks.onSetFilter);
onSetFilterRef.current = callbacks.onSetFilter;
const onClearSelectionRef = useRef(callbacks.onClearSelection);
onClearSelectionRef.current = callbacks.onClearSelection;
const handleSetViewMode = useCallback((mode: 'list' | 'tree') => {
setViewMode(mode);
saveHostViewMode(mode);
if (mode === 'tree') {
setShowFilterBar(false);
onSetFilterRef.current('');
onClearSelectionRef.current();
}
}, [saveHostViewMode]);
useEffect(() => {
if (viewMode === 'list') {
sftpTreeSelectionStore.clearPane(pane.id);
return;
}
sftpListOrderStore.clearPane(pane.id);
}, [pane.id, viewMode]);
// When connecting to a host, restore its saved view mode preference
const prevHostIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (hostId && hostId !== prevHostIdRef.current) {
setViewMode(hostViewMode ?? sftpDefaultViewMode);
}
prevHostIdRef.current = hostId;
}, [hostId, hostViewMode, sftpDefaultViewMode]);
useEffect(() => {
logger.debug("SftpPaneView active state", {
side,
@@ -296,6 +413,17 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
});
}, [isActive, pane.id, side]);
const lastHandledTransferMutationTokenRef = useRef(0);
useEffect(() => {
if (!pane.connection || pane.transferMutationToken === 0) return;
if (pane.transferMutationToken === lastHandledTransferMutationTokenRef.current) return;
lastHandledTransferMutationTokenRef.current = pane.transferMutationToken;
callbacks.onRefreshTab(pane.id);
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, pane.connection, pane.id, pane.transferMutationToken, requestTreeReload, viewMode]);
if (!pane.connection) {
return (
<SftpPaneEmptyState
@@ -329,7 +457,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
onRefresh={callbacks.onRefresh}
onRefresh={handleRefresh}
showFilterBar={showFilterBar}
setShowFilterBar={setShowFilterBar}
filterInputRef={filterInputRef}
@@ -364,12 +492,51 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
/>
{treeEverMounted && (
<div className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<SftpPaneTreeView
pane={pane}
side={side}
onPrepareSelection={callbacks.onPrepareSelection}
onLoadChildren={callbacks.onListDirectory}
onMoveEntriesToPath={handleMoveEntriesToPath}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onRefresh={handleRefresh}
onOpenEntry={callbacks.onOpenEntry}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
openNewFileDialog={openNewFileDialogAtPath}
onUploadExternalFiles={handleUploadExternalFiles}
columnWidths={columnWidths}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
sortField={sortField}
sortOrder={sortOrder}
reloadRequest={treeReloadRequest}
/>
</div>
)}
<div className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
isPaneFocused={isPaneFocused}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
@@ -382,7 +549,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={callbacks.onRefresh}
onRefresh={handleRefresh}
onNavigateTo={callbacks.onNavigateTo}
onClearSelection={callbacks.onClearSelection}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
@@ -397,6 +566,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
@@ -406,9 +576,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
</div>
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
currentPath={pane.connection?.currentPath}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
@@ -457,8 +630,11 @@ const sftpPaneViewAreEqual = (
): boolean => {
if (prev.pane !== next.pane) return false;
if (prev.side !== next.side) return false;
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
if (prev.isPaneFocused !== next.isPaneFocused) return false;
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
return true;
};

View File

@@ -214,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
[onCloseTab],
);
const handleSelectTabClick = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onSelectTab(tabId);
},
[onSelectTab],
);
const handleAddTabClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddTab();
},
[onAddTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
@@ -302,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
<div
key={tab.id}
data-tab-id={tab.id}
onClick={() => onSelectTab(tab.id)}
onClick={(e) => handleSelectTabClick(e, tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
@@ -379,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={onAddTab}
onClick={handleAddTabClick}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -4,237 +4,375 @@
import {
ArrowDown,
ArrowRight,
CheckCircle2,
ChevronDown,
ChevronUp,
File,
FolderUp,
GripVertical,
Loader2,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { formatSpeed, formatTransferBytes } from './utils';
interface SftpTransferItemProps {
task: TransferTask;
isChild?: boolean;
childNameColumnWidth?: number;
onResizeNameColumn?: (event: React.MouseEvent<HTMLDivElement>) => void;
onCancel: () => void;
onRetry: () => void;
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
canToggleChildren?: boolean;
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
task,
isChild = false,
childNameColumnWidth = 260,
onResizeNameColumn,
onCancel,
onRetry,
onDismiss,
canRevealTarget = false,
onRevealTarget,
canToggleChildren = false,
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
}) => {
const { t } = useI18n();
const hasKnownTotal = task.totalBytes > 0;
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Show indeterminate state when transferring but no real progress received yet
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
// Calculate remaining time from backend-reported sliding-window speed
const remainingBytes = task.totalBytes - task.transferredBytes;
const progressMode = task.progressMode ?? 'bytes';
const isDirParent = task.isDirectory && !task.parentTaskId && progressMode === 'files';
const hasKnownTotal = task.totalBytes > 0 || (!isDirParent && !!task.sourceLastModified);
const progress = hasKnownTotal
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
: 0;
const isIndeterminate = task.status === 'transferring' && !hasKnownTotal;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const remainingTime = hasKnownTotal && effectiveSpeed > 0
? Math.ceil(remainingBytes / effectiveSpeed)
: 0;
const remainingFormatted = remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: '';
// Format bytes transferred / total
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && task.totalBytes > 0
? formatTransferBytes(task.totalBytes)
const bytesDisplay = isDirParent
? ''
: task.status === 'transferring' && hasKnownTotal
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && hasKnownTotal
? formatTransferBytes(task.totalBytes)
: '';
const fileCountDisplay = isDirParent && task.status === 'transferring'
? (task.totalBytes > 0
? t('sftp.transfers.filesProgress', { current: task.transferredBytes, total: task.totalBytes })
: t('sftp.transfers.filesCount', { count: task.transferredBytes }))
: isDirParent && task.status === 'completed' && task.totalBytes > 0
? t('sftp.transfers.filesCount', { count: task.totalBytes })
: '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
const details = (
<>
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
</div>
const progressOverlayText = task.status === 'pending'
? t('sftp.task.waiting')
: isIndeterminate
? t('sftp.transfer.preparing')
: isDirParent
? (fileCountDisplay
? `${fileCountDisplay}${hasKnownTotal ? `${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...')
: bytesDisplay
? `${bytesDisplay}${hasKnownTotal ? `${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...';
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
const progressBarWidth = task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
: `${progress}%`;
const statusIcon = task.status === 'transferring'
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pending'
? (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />)
: task.status === 'completed'
? <CheckCircle2 size={12} className="text-green-500" />
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
const childProgressBar = (
<div className="relative h-full overflow-hidden border border-border/60 bg-secondary/70">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/35 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: task.status === 'completed'
? "bg-emerald-500/80"
: task.status === 'failed'
? "bg-destructive/70"
: task.status === 'cancelled'
? "bg-muted-foreground/45"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: progressBarWidth,
transition: 'width 150ms ease-out',
}}
>
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-2">
<span className="truncate whitespace-nowrap text-[10px] font-medium text-foreground">
{progressOverlayText}
</span>
</div>
</div>
);
const progressSummaryText = task.status === 'transferring' || task.status === 'pending'
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
: '';
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
const hasFooterContent = showTransferSizeCalculation || showFailedError;
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
</div>
);
if (isChild) {
return (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
style={{
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
}}
>
<div className="flex h-full items-center justify-center text-muted-foreground">
{task.isDirectory ? <FolderUp size={12} /> : <File size={12} />}
</div>
<div className="flex min-w-0 items-center pr-2">
<TruncatedTextWithTooltip
text={task.fileName}
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<div
className={cn(
"text-[9px] mt-0.5 truncate",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
title={targetDirectoryPath}
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
onMouseDown={onResizeNameColumn}
title="Resize file name column"
>
{targetDirectoryPath}
<GripVertical size={10} />
</div>
<div className="min-w-0">
{childProgressBar}
</div>
<div className="flex h-full items-center justify-center">
{actionButtons}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? '100%'
: `${progress}%`,
transition: 'width 150ms ease-out'
}}
>
{/* Animated shine effect */}
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending'
? 'waiting...'
: isIndeterminate
? t('sftp.transfer.preparing')
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
</span>
</div>
)}
{task.status === 'transferring' && bytesDisplay && (
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
{bytesDisplay}
</div>
)}
{task.status === 'transferring' && !hasKnownTotal && (
<div className="text-[9px] text-muted-foreground mt-0.5">
{t('sftp.transfers.calculatingTotal')}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
</div>
)}
{task.status === 'failed' && task.error && (
<span className="text-[10px] text-destructive">{task.error}</span>
)}
</div>
</>
);
}
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
className="text-[12px] font-medium leading-5"
/>
<ArrowRight size={11} className="shrink-0 text-muted-foreground/70" />
<TruncatedTextWithTooltip
text={targetDirectoryPath}
className={cn(
"min-w-0 text-[11px]",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
{canToggleChildren && (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggleChildren}
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
>
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
)}
</div>
);
return (
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
title="Open transfer destination"
>
{details}
</button>
) : (
details
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}
</div>
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
>
{titleBlock}
</button>
) : (
<div className="min-w-0 flex-1">
{titleBlock}
</div>
)}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
</span>
)}
{actionButtons}
</div>
{showBelowParentProgress && (
<div className="mt-2 ml-7">
<div className="h-1.5 overflow-hidden bg-secondary/80">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary",
)}
style={{
width: progressBarWidth,
transition: 'width 150ms ease-out',
}}
>
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
{hasFooterContent && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{showTransferSizeCalculation && (
<span className="text-muted-foreground">{t('sftp.transfers.calculatingTotal')}</span>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
{showFailedError && (
<span className="text-destructive">{task.error}</span>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
</div>
</div>
)}
</div>
);
};
// Custom comparison function to reduce unnecessary re-renders
// Only re-render if meaningful values change
const arePropsEqual = (
prevProps: SftpTransferItemProps,
nextProps: 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;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false;
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
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.1) return false;
// Re-render on any speed change (backend already smooths via sliding window)
if (next.speed !== prev.speed) return false;
}
// For pending status, don't re-render unless status changes
if (next.status === 'pending') {
return true;
}

View File

@@ -1,8 +1,14 @@
import React from "react";
import { Button } from "../ui/button";
import { GripHorizontal } from "lucide-react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useStoredNumber } from "../../application/state/useStoredNumber";
import type { useSftpState } from "../../application/state/useSftpState";
import {
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
} from "../../infrastructure/config/storageKeys";
import type { TransferTask } from "../../types";
import { Button } from "../ui/button";
import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>;
@@ -10,25 +16,327 @@ type SftpState = ReturnType<typeof useSftpState>;
interface SftpTransferQueueProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
allTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
}
const MIN_PANEL_HEIGHT = 112;
const MAX_PANEL_HEIGHT = 480;
const HEADER_HEIGHT = 42;
const MIN_CHILD_NAME_WIDTH = 160;
const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
interface TransferChildListProps {
childTasks: TransferTask[];
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollTop: number;
viewportHeight: number;
onCancel: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
scrollTop,
viewportHeight,
onCancel,
onRetry,
onDismiss,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
useLayoutEffect(() => {
const container = containerRef.current;
const scrollContainer = scrollContainerRef.current;
if (!container || !scrollContainer) return;
const nextTop =
container.getBoundingClientRect().top -
scrollContainer.getBoundingClientRect().top +
scrollTop;
if (Math.abs(nextTop - contentTop) > 1) {
setContentTop(nextTop);
}
}, [childTasks.length, contentTop, scrollContainerRef, scrollTop, viewportHeight]);
const needsVirtualization = childTasks.length > CHILD_VIRTUALIZE_THRESHOLD;
// Use a fallback viewport height when not yet measured to avoid rendering
// all children on the first frame. This caps the initial render to ~15 rows
// instead of potentially thousands.
const effectiveViewportHeight = viewportHeight > 0 ? viewportHeight : MAX_PANEL_HEIGHT;
const shouldVirtualize = needsVirtualization;
const { startIndex, visibleTasks } = useMemo(() => {
if (!shouldVirtualize) {
return {
startIndex: 0,
visibleTasks: childTasks,
};
}
const relativeTop = Math.max(0, scrollTop - contentTop);
const relativeBottom = Math.max(0, scrollTop + effectiveViewportHeight - contentTop);
const start = Math.max(0, Math.floor(relativeTop / CHILD_ROW_HEIGHT) - CHILD_OVERSCAN);
const end = Math.min(
childTasks.length - 1,
Math.ceil(relativeBottom / CHILD_ROW_HEIGHT) + CHILD_OVERSCAN,
);
return {
startIndex: start,
visibleTasks: childTasks.slice(start, end + 1),
};
}, [childTasks, contentTop, effectiveViewportHeight, scrollTop, shouldVirtualize]);
return (
<div
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
<div
className={shouldVirtualize ? "relative" : undefined}
style={shouldVirtualize ? { height: childTasks.length * CHILD_ROW_HEIGHT } : undefined}
>
{visibleTasks.map((child, visibleIndex) => {
const index = shouldVirtualize ? startIndex + visibleIndex : visibleIndex;
return (
<div
key={child.id}
className={shouldVirtualize ? "absolute left-0 right-0" : undefined}
style={shouldVirtualize ? { top: index * CHILD_ROW_HEIGHT } : undefined}
>
<SftpTransferItem
task={child}
isChild
childNameColumnWidth={childNameWidth}
onResizeNameColumn={onResizeNameColumn}
onCancel={() => onCancel(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
/>
</div>
);
})}
</div>
</div>
);
};
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
sftp,
visibleTransfers,
allTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
}) => {
const { t } = useI18n();
const [expandedParents, setExpandedParents] = useState<Record<string, boolean>>({});
const [panelHeight, setPanelHeight, persistPanelHeight] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
220,
{ min: MIN_PANEL_HEIGHT, max: MAX_PANEL_HEIGHT },
);
const [childNameWidth, setChildNameWidth, persistChildNameWidth] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
260,
{ min: MIN_CHILD_NAME_WIDTH, max: MAX_CHILD_NAME_WIDTH },
);
const panelHeightRef = useRef(panelHeight);
const childNameWidthRef = useRef(childNameWidth);
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
const childColumnDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
if (sftp.transfers.length === 0) {
panelHeightRef.current = panelHeight;
childNameWidthRef.current = childNameWidth;
const childrenByParent = useMemo(() => {
const map = new Map<string, TransferTask[]>();
for (const task of allTransfers) {
if (task.parentTaskId && task.status !== "cancelled") {
const children = map.get(task.parentTaskId) || [];
children.push(task);
map.set(task.parentTaskId, children);
}
}
for (const [parentId, children] of map) {
map.set(
parentId,
[...children].sort((a, b) => b.startTime - a.startTime),
);
}
return map;
}, [allTransfers]);
const topLevelTransfers = useMemo(
() => visibleTransfers.filter((task) => !task.parentTaskId),
[visibleTransfers],
);
const clampPanelHeight = useCallback((height: number) => {
if (typeof window === "undefined") {
return Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, height));
}
const viewportMax = Math.floor(window.innerHeight * 0.6);
return Math.max(MIN_PANEL_HEIGHT, Math.min(Math.min(MAX_PANEL_HEIGHT, viewportMax), height));
}, []);
useEffect(() => {
setExpandedParents((prev) => {
const next: Record<string, boolean> = {};
let changed = false;
for (const task of topLevelTransfers) {
const hasChildren = (childrenByParent.get(task.id)?.length ?? 0) > 0;
if (!hasChildren) continue;
next[task.id] = prev[task.id] ?? true;
if (next[task.id] !== prev[task.id]) {
changed = true;
}
}
if (!changed && Object.keys(prev).length === Object.keys(next).length) {
return prev;
}
return next;
});
}, [childrenByParent, topLevelTransfers]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const updateViewport = () => setViewportHeight(scrollContainer.clientHeight);
updateViewport();
const resizeObserver = new ResizeObserver(updateViewport);
resizeObserver.observe(scrollContainer);
return () => {
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (dragStateRef.current) {
const deltaY = dragStateRef.current.startY - event.clientY;
setPanelHeight(clampPanelHeight(dragStateRef.current.startHeight + deltaY));
}
if (childColumnDragRef.current) {
const deltaX = event.clientX - childColumnDragRef.current.startX;
const nextWidth = Math.max(
MIN_CHILD_NAME_WIDTH,
Math.min(MAX_CHILD_NAME_WIDTH, childColumnDragRef.current.startWidth + deltaX),
);
setChildNameWidth(nextWidth);
}
};
const handleMouseUp = () => {
const hadPanelDrag = !!dragStateRef.current;
const hadChildColumnDrag = !!childColumnDragRef.current;
dragStateRef.current = null;
childColumnDragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (hadPanelDrag) {
persistPanelHeight(panelHeightRef.current);
}
if (hadChildColumnDrag) {
persistChildNameWidth(childNameWidthRef.current);
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [clampPanelHeight, panelHeight, persistChildNameWidth, persistPanelHeight, setChildNameWidth, setPanelHeight]);
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
dragStateRef.current = {
startY: event.clientY,
startHeight: panelHeight,
};
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
}, [panelHeight]);
const handleChildColumnResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
childColumnDragRef.current = {
startX: event.clientX,
startWidth: childNameWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [childNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
[taskId]: !(prev[taskId] ?? true),
}));
}, []);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const nextTop = event.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
}, []);
if (topLevelTransfers.length === 0) {
return null;
}
return (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
<div
className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0"
style={{ height: clampPanelHeight(panelHeight) }}
>
<div
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
onMouseDown={handleResizeStart}
title={t("sftp.transfers.dragToResize")}
>
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
@@ -37,8 +345,9 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
</span>
)}
</span>
{sftp.transfers.some(
(tr) => tr.status === "completed" || tr.status === "cancelled",
(transfer) => transfer.status === "completed" || transfer.status === "cancelled",
) && (
<Button
variant="ghost"
@@ -50,29 +359,59 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
<div
ref={scrollContainerRef}
className="overflow-auto"
style={{ height: `calc(100% - ${HEADER_HEIGHT}px)` }}
onScroll={handleScroll}
>
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
return (
<React.Fragment key={task.id}>
<SftpTransferItem
task={task}
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
: undefined
}
/>
))}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
}
: undefined
}
/>
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}
onCancel={(taskId) => sftp.cancelTransfer(taskId)}
onRetry={(taskId) => sftp.retryTransfer(taskId)}
onDismiss={(taskId) => sftp.dismissTransfer(taskId)}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,37 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
tabId: string;
}
export const keepOnlyPaneSelections = (
sftp: SftpStateApi,
target: SftpSelectionTarget | null,
) => {
sftp.clearSelectionsExcept(target);
const paneIds = [
...sftp.leftTabs.tabs.map((tab) => tab.id),
...sftp.rightTabs.tabs.map((tab) => tab.id),
];
for (const paneId of paneIds) {
if (target?.tabId === paneId) continue;
sftpTreeSelectionStore.clearSelection(paneId);
}
};
export const keepOnlyActivePaneSelections = (
sftp: SftpStateApi,
side: "left" | "right",
): SftpSelectionTarget | null => {
const tabId = sftp.getActiveTabId(side);
if (!tabId) {
keepOnlyPaneSelections(sftp, null);
return null;
}
const target = { side, tabId } as const;
keepOnlyPaneSelections(sftp, target);
return target;
};

View File

@@ -18,10 +18,26 @@ function getSnapshot() {
return snapshot;
}
/** Re-read bookmarks from localStorage (e.g. after cloud sync import). */
export function rehydrateGlobalBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const l of listeners) l();
}
// Rehydrate when another window updates the same localStorage key
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalBookmarks();
}
});
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
window.dispatchEvent(new CustomEvent('sftp-bookmarks-changed'));
}
interface UseGlobalSftpBookmarksParams {

View File

@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
}
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side) return;
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, handlers]);
}, [action, side, scopeId, handlers, isActive]);
};

View File

@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}

View File

@@ -8,11 +8,16 @@
import { useCallback, useEffect } from "react";
import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { getParentPath, joinPath } from "../../../application/state/sftp/utils";
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import type { SftpFileEntry } from "../../../types";
import { toast } from "../../ui/toast";
// SFTP action names that we handle
@@ -25,12 +30,70 @@ const SFTP_ACTIONS = new Set([
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
"sftpOpen",
"sftpGoParent",
"sftpNavigateTo",
]);
// ── Tree Enter key action store ──────────────────────────────────────
// Allows the keyboard shortcut hook to signal tree views to handle Enter.
type TreeEnterListener = () => void;
interface TreeEnterAction {
paneId: string;
entryPath: string;
isDirectory: boolean;
timestamp: number;
}
let _treeEnterAction: TreeEnterAction | null = null;
const _treeEnterListeners = new Set<TreeEnterListener>();
const notifyTreeEnterListeners = () => _treeEnterListeners.forEach((l) => l());
export const sftpTreeEnterStore = {
trigger: (paneId: string, entryPath: string, isDirectory: boolean) => {
_treeEnterAction = { paneId, entryPath, isDirectory, timestamp: Date.now() };
notifyTreeEnterListeners();
},
get: () => _treeEnterAction,
clear: () => {
_treeEnterAction = null;
notifyTreeEnterListeners();
},
subscribe: (listener: TreeEnterListener) => {
_treeEnterListeners.add(listener);
return () => { _treeEnterListeners.delete(listener); };
},
getSnapshot: () => _treeEnterAction,
};
// ── Keyboard selection anchor/focus tracking ────────────────────────
// Tracks the anchor (where Shift-selection started) and focus (cursor)
// indices per pane so Shift+Arrow extends correctly.
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
export const sftpKeyboardSelectionStore = {
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
set: (paneId: string, anchor: number, focus: number) => {
_kbSelectionState.set(paneId, { anchor, focus });
},
clear: (paneId: string) => {
_kbSelectionState.delete(paneId);
},
};
// Basic navigation keys that work even when custom hotkeys are disabled.
const BASIC_NAV_KEYS: Record<string, string> = {
'Enter': 'sftpOpen',
'Backspace': 'sftpGoParent',
};
interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
dialogActionScopeId: string;
isActive: boolean;
}
@@ -56,12 +119,14 @@ export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
async (e: KeyboardEvent) => {
// Skip if shortcuts are disabled or SFTP is not active
if (hotkeyScheme === "disabled" || !isActive) return;
// Basic SFTP keyboard navigation should work whenever the SFTP tab is active,
// even if the user has disabled global/custom hotkeys.
if (!isActive) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
@@ -74,12 +139,126 @@ export const useSftpKeyboardShortcuts = ({
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
if (document.querySelector('[role="dialog"][data-state="open"]')) {
return;
}
const { action } = matched;
if (!SFTP_ACTIONS.has(action)) return;
// ── Arrow Up/Down: move selection ────────────────────────────────
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const delta = e.key === 'ArrowDown' ? 1 : -1;
// List view: navigate sorted display files.
// Prefer the list store when it exists so stale tree selection state
// cannot swallow keyboard navigation after switching views.
const listItems = sftpListOrderStore.getItems(pane.id);
if (listItems.length > 0) {
e.preventDefault();
e.stopPropagation();
// Resolve current focus position from tracked state, falling back
// to the actual selection when out of sync (e.g. after mouse click).
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
const currentSelected = Array.from(pane.selectedFiles) as string[];
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else if (!currentSelected.includes(listItems[focusIdx])) {
// Tracked focus doesn't match actual selection, re-sync
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
if (focusIdx < 0) focusIdx = 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
// Shift+Arrow: extend range from anchor to new focus
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
// Tree view: navigate visible items
const treeState = sftpTreeSelectionStore.getPaneState(pane.id);
if (treeState.visibleItems.length > 0) {
e.preventDefault();
e.stopPropagation();
const items = treeState.visibleItems;
const currentSelected = [...treeState.selectedPaths];
// Use tracked state, re-sync if needed
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map(item => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
return;
}
// Basic navigation actions (Enter, Backspace) must work even when
// custom hotkeys are disabled — they are essential SFTP navigation.
// When hotkeys are enabled, defer to matchSftpAction so user
// customizations are respected.
const basicNavAction = hotkeyScheme === "disabled" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey
? BASIC_NAV_KEYS[e.key]
: undefined;
if (hotkeyScheme === "disabled" && !basicNavAction) return;
const isMac = hotkeyScheme === "mac";
const matched = basicNavAction ? null : matchSftpAction(e, keyBindings, isMac);
if (!matched && !basicNavAction) return;
const action = basicNavAction ?? matched?.action;
if (!action || !SFTP_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
@@ -94,49 +273,100 @@ export const useSftpKeyboardShortcuts = ({
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const treeSelectionState = sftpTreeSelectionStore.getPaneState(pane.id);
const treeSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
const treeActionSelection = treeSelection.filter((entry) => entry.name !== '..');
switch (action) {
case "sftpCopy": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be copied with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.copy(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
break;
}
// Copy selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
}
break;
}
case "sftpCut": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be cut with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.cut(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
break;
}
// Cut selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
}
break;
}
@@ -146,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
if (!clipboard || clipboard.files.length === 0) return;
// Use startTransfer to paste files from source to current pane
// The transfer direction is determined by clipboard sourceSide and current focusedSide
if (clipboard.sourceSide !== focusedSide) {
// Allow paste when source and target are different connections, even on the same side
const isSameConnection = clipboard.sourceSide === focusedSide
&& clipboard.sourceConnectionId === pane.connection.id;
if (!isSameConnection) {
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
@@ -234,7 +466,17 @@ export const useSftpKeyboardShortcuts = ({
}
case "sftpSelectAll": {
if (treeSelectionState.visibleItems.length > 0) {
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftpTreeSelectionStore.selectAllVisible(pane.id);
break;
}
// Select all files in the current pane
// TODO: Reference already-computed filtered files from useSftpPaneFiles
// instead of re-implementing the hidden file + filter logic here.
// This requires either lifting the computed files into pane state or
// passing them via a shared store, which needs a larger refactor.
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
if (term) {
@@ -245,23 +487,38 @@ export const useSftpKeyboardShortcuts = ({
const allFileNames = visibleFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftp.rangeSelect(focusedSide, allFileNames);
break;
}
case "sftpRename": {
if (treeActionSelection.length === 1) {
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
break;
}
// Trigger rename for the first selected file
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length !== 1) return;
sftpDialogActionStore.trigger("rename", selectedFiles);
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
break;
}
case "sftpDelete": {
if (treeActionSelection.length > 0) {
sftpDialogActionStore.trigger(
"delete",
dialogActionScopeId,
treeActionSelection.map((entry) => entry.path),
);
break;
}
// Delete selected files
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
sftpDialogActionStore.trigger("delete", selectedFiles);
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
break;
}
@@ -273,12 +530,70 @@ export const useSftpKeyboardShortcuts = ({
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder");
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
break;
}
case "sftpOpen": {
// Prefer list selection when the list store is active
const listItems = sftpListOrderStore.getItems(pane.id);
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (listItems.length > 0 && selectedFiles.length === 1) {
const fileName = selectedFiles[0];
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === fileName);
if (entry) {
if (isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
} else {
sftp.openEntry(focusedSide, entry);
}
}
break;
}
// Only fall through to tree view if list store is empty (tree view mode)
if (listItems.length > 0) break;
const treeOpenSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
if (treeOpenSelection.length === 1) {
const item = treeOpenSelection[0];
if (item.isDirectory) _kbSelectionState.delete(pane.id);
sftpTreeEnterStore.trigger(pane.id, item.path, item.isDirectory);
}
break;
}
case "sftpGoParent": {
const parentPath = getParentPath(pane.connection.currentPath);
if (parentPath !== pane.connection.currentPath) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, parentPath);
}
break;
}
case "sftpNavigateTo": {
// Navigate to the selected directory (useful in tree view)
// Filter out ".." entry for consistency with other handlers
if (treeActionSelection.length === 1 && treeActionSelection[0].isDirectory) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, treeActionSelection[0].path);
break;
}
// In list view, navigate to selected directory
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 1) {
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === selectedFiles[0]);
if (entry && isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
}
}
break;
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef]
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
);
useEffect(() => {

View File

@@ -0,0 +1,20 @@
/**
* Lightweight store that tracks the sorted display file names per SFTP pane.
* Used by keyboard shortcuts to navigate with ArrowUp/ArrowDown in list view.
*/
const paneItems = new Map<string, string[]>();
export const sftpListOrderStore = {
/** Update the ordered list of file names for a pane (call from SftpPaneFileList). */
setItems: (paneId: string, names: string[]) => {
paneItems.set(paneId, names);
},
/** Get the ordered list of file names (excluding "..") for arrow key navigation. */
getItems: (paneId: string): string[] => paneItems.get(paneId) ?? [],
clearPane: (paneId: string) => {
paneItems.delete(paneId);
},
};

View File

@@ -1,15 +1,46 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
import { logger } from "../../../lib/logger";
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",
]);
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onRenameFile: SftpPaneCallbacks["onRenameFile"];
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
onMutateSuccess?: (paths?: string[]) => void;
}
interface UseSftpPaneDialogsResult {
@@ -47,6 +78,8 @@ interface UseSftpPaneDialogsResult {
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openNewFolderDialogAtPath: (path: string) => void;
openNewFileDialogAtPath: (path: string) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
@@ -56,17 +89,21 @@ export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateDirectoryAtPath,
onCreateFile,
onRenameFile,
onDeleteFiles,
onCreateFileAtPath,
onRenameFileAtPath,
onDeleteFilesAtPath,
onClearSelection,
onMutateSuccess,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
@@ -80,34 +117,24 @@ export const useSftpPaneDialogs = ({
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Refs for values accessed inside useCallback to avoid stale closures
const newFolderNameRef = useRef(newFolderName);
newFolderNameRef.current = newFolderName;
const newFileNameRef = useRef(newFileName);
newFileNameRef.current = newFileName;
const createTargetPathRef = useRef(createTargetPath);
createTargetPathRef.current = createTargetPath;
const renameTargetRef = useRef(renameTarget);
renameTargetRef.current = renameTarget;
const renameNameRef = useRef(renameName);
renameNameRef.current = renameName;
const deleteTargetsRef = useRef(deleteTargets);
deleteTargetsRef.current = deleteTargets;
const paneRef = useRef(pane);
paneRef.current = pane;
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;
@@ -145,22 +172,29 @@ export const useSftpPaneDialogs = ({
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = async () => {
if (!newFolderName.trim() || isCreating) return;
const handleCreateFolder = useCallback(async () => {
if (!newFolderNameRef.current.trim() || isCreating) return;
setIsCreating(true);
try {
await onCreateDirectory(newFolderName.trim());
setShowNewFolderDialog(false);
if (createTargetPathRef.current) {
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
} else {
await onCreateDirectory(newFolderNameRef.current.trim());
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFolderDialogState(false);
setCreateTargetPath(null);
setNewFolderName("");
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to create folder", err);
} finally {
setIsCreating(false);
}
};
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
const handleCreateFile = async (forceOverwrite = false) => {
const trimmedName = newFileName.trim();
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
const trimmedName = newFileNameRef.current.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
@@ -169,8 +203,9 @@ export const useSftpPaneDialogs = ({
return;
}
if (!forceOverwrite) {
const existingFile = pane.files.find(
const currentPane = paneRef.current;
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
const existingFile = currentPane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
@@ -183,59 +218,112 @@ export const useSftpPaneDialogs = ({
setIsCreatingFile(true);
try {
await onCreateFile(trimmedName);
setShowNewFileDialog(false);
if (createTargetPathRef.current) {
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
} else {
await onCreateFile(trimmedName);
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFileDialogState(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setCreateTargetPath(null);
setNewFileName("");
setFileNameError(null);
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to create file", err);
} finally {
setIsCreatingFile(false);
}
};
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
const handleConfirmOverwrite = async () => {
const handleConfirmOverwrite = useCallback(async () => {
await handleCreateFile(true);
};
}, [handleCreateFile]);
const handleRename = async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
const handleRename = useCallback(async () => {
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
setIsRenaming(true);
try {
await onRenameFile(renameTarget, renameName.trim());
// renameTarget is always a full path; use the path-aware variant
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to rename file", err);
} finally {
setIsRenaming(false);
}
};
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
const handleDelete = async () => {
if (deleteTargets.length === 0 || isDeleting) return;
const handleDelete = useCallback(async () => {
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
await onDeleteFiles(deleteTargets);
// deleteTargets are full paths; group by parent dir and use path-aware variant
const byDir = new Map<string, string[]>();
for (const fullPath of deleteTargetsRef.current) {
const dir = getParentPath(fullPath);
const name = getFileName(fullPath);
const list = byDir.get(dir) ?? [];
list.push(name);
byDir.set(dir, list);
}
const connectionId = paneRef.current.connection?.id;
if (!connectionId) {
throw new Error("Pane connection is no longer available");
}
for (const [dir, names] of byDir) {
await onDeleteFilesAtPath(connectionId, dir, names);
}
onMutateSuccess?.(Array.from(byDir.keys()));
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to delete files", err);
} finally {
setIsDeleting(false);
}
};
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
const openRenameDialog = useCallback((name: string) => {
setRenameTarget(name);
setRenameName(name);
// entryPath is the full path; renameName is initialized to the basename
const openRenameDialog = useCallback((entryPath: string) => {
setRenameTarget(entryPath);
setRenameName(getFileName(entryPath) || entryPath);
setShowRenameDialog(true);
}, []);
const setShowNewFolderDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFolderDialogState(open);
}, []);
const setShowNewFileDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFileDialogState(open);
}, []);
const openNewFolderDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFolderName("");
setShowNewFolderDialogState(true);
}, []);
const openNewFileDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFileName("");
setFileNameError(null);
setShowNewFileDialogState(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
@@ -244,9 +332,9 @@ export const useSftpPaneDialogs = ({
return {
showHostPicker,
hostSearch,
showNewFolderDialog,
showNewFolderDialog: showNewFolderDialogState,
newFolderName,
showNewFileDialog,
showNewFileDialog: showNewFileDialogState,
newFileName,
fileNameError,
showOverwriteConfirm,
@@ -276,6 +364,8 @@ export const useSftpPaneDialogs = ({
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,

View File

@@ -1,15 +1,20 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../index";
import { joinPath } from "../../../application/state/sftp/utils";
interface UseSftpPaneDragAndSelectParams {
side: "left" | "right";
pane: { selectedFiles: Set<string> };
pane: {
selectedFiles: Set<string>;
connection?: { currentPath: string; id: string } | null;
};
sortedDisplayFiles: SftpFileEntry[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onDragStart: SftpDragCallbacks["onDragStart"];
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
onMoveEntriesToPath: SftpPaneCallbacks["onMoveEntriesToPath"];
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
@@ -38,6 +43,7 @@ export const useSftpPaneDragAndSelect = ({
draggedFiles,
onDragStart,
onReceiveFromOtherPane,
onMoveEntriesToPath,
onUploadExternalFiles,
onOpenEntry,
onRangeSelect,
@@ -49,17 +55,38 @@ export const useSftpPaneDragAndSelect = ({
const lastSelectedIndexRef = useRef<number | null>(null);
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const sortedFilesRef = useRef(sortedDisplayFiles);
sortedFilesRef.current = sortedDisplayFiles;
const draggedFilesRef = useRef(draggedFiles);
draggedFilesRef.current = draggedFiles;
const onReceiveRef = useRef(onReceiveFromOtherPane);
onReceiveRef.current = onReceiveFromOtherPane;
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
const onUploadRef = useRef(onUploadExternalFiles);
onUploadRef.current = onUploadExternalFiles;
useEffect(() => {
selectedFilesRef.current = pane.selectedFiles;
}, [pane.selectedFiles]);
if (pane.selectedFiles.size === 0) {
lastSelectedIndexRef.current = null;
}
}, [pane.selectedFiles.size]);
useEffect(() => {
sortedFilesRef.current = sortedDisplayFiles;
}, [sortedDisplayFiles]);
const getSamePaneDragPaths = useCallback((): string[] | null => {
const dragged = draggedFilesRef.current;
if (!dragged || dragged.length === 0) return null;
if (dragged[0]?.side !== side) return null;
const handlePaneDragOver = (e: React.DragEvent) => {
const currentConnectionId = pane.connection?.id;
const paths = dragged
.filter((file) => file.sourceConnectionId === currentConnectionId && file.sourcePath)
.map((file) => joinPath(file.sourcePath!, file.name));
return paths.length > 0 ? paths : null;
}, [pane.connection?.id, side]);
const handlePaneDragOver = useCallback((e: React.DragEvent) => {
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles) {
@@ -69,38 +96,36 @@ export const useSftpPaneDragAndSelect = ({
return;
}
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (!draggedFilesRef.current || draggedFilesRef.current[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
};
}, [side]);
const handlePaneDragLeave = (e: React.DragEvent) => {
const handlePaneDragLeave = useCallback((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) => {
const handlePaneDrop = useCallback(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 })),
);
if (draggedFilesRef.current && draggedFilesRef.current.length > 0) {
if (draggedFilesRef.current[0]?.side !== side) {
onReceiveRef.current(draggedFilesRef.current);
}
return;
}
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
if (e.dataTransfer.items.length > 0 && onUploadRef.current) {
await onUploadRef.current(e.dataTransfer);
}
};
}, [side]);
const handleFileDragStart = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
@@ -115,48 +140,105 @@ export const useSftpPaneDragAndSelect = ({
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
}))
: [
{
name: entry.name,
isDirectory: isNavigableDirectory(entry),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
},
];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
},
[onDragStart, side],
[onDragStart, pane.connection?.currentPath, pane.connection?.id, side],
);
const handleEntryDragOver = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setDragOverEntry(entry.name);
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
return;
}
// Handle external file drag (from OS file explorer)
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragOverEntry(entry.name);
}
},
[draggedFiles, side],
[getSamePaneDragPaths, side],
);
const handleEntryDrop = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
async (entry: SftpFileEntry, e: React.DragEvent) => {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
if (targetPath) {
await onMoveEntriesToPathRef.current(samePaneDragPaths, targetPath);
}
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
onReceiveRef.current(
draggedFilesRef.current.map((file) => ({ ...file, targetPath })),
);
}
return;
}
// Handle external file drop on a directory entry
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
if (onUploadRef.current && pane.connection?.currentPath) {
const targetPath = joinPath(pane.connection.currentPath, entry.name);
void onUploadRef.current(e.dataTransfer, targetPath);
}
}
},
[draggedFiles, onReceiveFromOtherPane, side],
[getSamePaneDragPaths, side, pane.connection?.currentPath],
);
const handleRowSelect = useCallback(
@@ -165,7 +247,7 @@ export const useSftpPaneDragAndSelect = ({
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const selectedFileNames = sortedDisplayFiles
const selectedFileNames = sortedFilesRef.current
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
@@ -175,7 +257,7 @@ export const useSftpPaneDragAndSelect = ({
lastSelectedIndexRef.current = index;
}
},
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
[onRangeSelect, onToggleSelection],
);
const handleRowOpen = useCallback(

View File

@@ -2,13 +2,14 @@ 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";
import { filterHiddenFiles, sortSftpEntries } from "../index";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
enableListView: boolean;
sortField: SortField;
sortOrder: SortOrder;
}
@@ -24,76 +25,62 @@ export const useSftpPaneFiles = ({
filter,
connection,
showHiddenFiles,
enableListView,
sortField,
sortOrder,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
// Extract ".." once and process the remaining files through filter -> sort
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
const filteredFiles = useMemo(() => {
if (!enableListView) return [] as SftpFileEntry[];
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]);
}, [enableListView, files, filter, showHiddenFiles]);
const { displayFiles, sortedDisplayFiles } = useMemo(() => {
if (!connection || !enableListView) {
return { displayFiles: [] as SftpFileEntry[], sortedDisplayFiles: [] as SftpFileEntry[] };
}
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;
// Split ".." from other files in a single pass
let parentEntry: SftpFileEntry | undefined;
const otherFiles: SftpFileEntry[] = [];
for (const f of filteredFiles) {
if (f.name === "..") {
parentEntry = f;
} else {
otherFiles.push(f);
}
}
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;
});
// For non-root paths, always ensure a ".." entry exists
if (!isRootPath && !parentEntry) {
parentEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
}
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const display = parentEntry ? [parentEntry, ...otherFiles] : otherFiles;
const sorted = otherFiles.length
? sortSftpEntries(otherFiles, sortField, sortOrder)
: otherFiles;
const sortedDisplay = parentEntry ? [parentEntry, ...sorted] : sorted;
return { displayFiles: display, sortedDisplayFiles: sortedDisplay };
}, [connection, enableListView, filteredFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -1,11 +1,12 @@
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";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
filteredFiles: SftpFileEntry[];
files: SftpFileEntry[];
showHiddenFiles: boolean;
onNavigateTo: (path: string) => void;
}
@@ -28,7 +29,8 @@ interface UseSftpPanePathResult {
export const useSftpPanePath = ({
connection,
filteredFiles,
files,
showHiddenFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
@@ -43,7 +45,7 @@ export const useSftpPanePath = ({
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filteredFiles.filter(
const folders = filterHiddenFiles(files, showHiddenFiles).filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
@@ -70,7 +72,7 @@ export const useSftpPanePath = ({
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
}, [connection, editingPathValue, files, isEditingPath, showHiddenFiles]);
const handlePathDoubleClick = () => {
if (!connection) return;

View File

@@ -13,10 +13,10 @@ 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,
name: 56,
modified: 28,
size: 7,
type: 9,
});
const resizingRef = useRef<{
@@ -41,9 +41,16 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
if (!resizingRef.current) return;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
name: { min: 36, max: 78 },
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
10,
Math.min(60, startWidth + diff / 5),
min,
Math.min(max, startWidth + diff / 8),
);
setColumnWidths((prev) => ({
...prev,

View File

@@ -3,6 +3,7 @@ import type { SftpFileEntry } from "../../../types";
interface UseSftpPaneVirtualListParams {
isActive: boolean;
enabled?: boolean;
sortedDisplayFiles: SftpFileEntry[];
}
@@ -17,6 +18,7 @@ interface UseSftpPaneVirtualListResult {
export const useSftpPaneVirtualList = ({
isActive,
enabled = true,
sortedDisplayFiles,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
@@ -27,7 +29,7 @@ export const useSftpPaneVirtualList = ({
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive) return;
if (!container || !isActive || !enabled) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
@@ -37,11 +39,11 @@ export const useSftpPaneVirtualList = ({
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [isActive, sortedDisplayFiles.length]);
}, [enabled, isActive, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || sortedDisplayFiles.length === 0) return;
if (!container || !isActive || !enabled || sortedDisplayFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-row="true"]',
@@ -53,7 +55,7 @@ export const useSftpPaneVirtualList = ({
}
});
return () => window.cancelAnimationFrame(raf);
}, [isActive, rowHeight, sortedDisplayFiles.length]);
}, [enabled, isActive, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
@@ -65,7 +67,7 @@ export const useSftpPaneVirtualList = ({
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!isActive) return;
if (!isActive || !enabled) return;
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
@@ -73,12 +75,12 @@ export const useSftpPaneVirtualList = ({
setScrollTop(nextTop);
});
},
[isActive],
[enabled, isActive],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = isActive && viewportHeight > 0 && rowHeight > 0;
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
@@ -111,7 +113,7 @@ export const useSftpPaneVirtualList = ({
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,

View File

@@ -0,0 +1,153 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
@@ -21,9 +21,6 @@ interface UseSftpViewFileOpsParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
@@ -47,9 +44,9 @@ interface UseSftpViewFileOpsParams {
}
interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
@@ -89,20 +86,20 @@ interface UseSftpViewFileOpsResult {
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;
onEditPermissionsLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditPermissionsRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenEntryLeft: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenEntryRight: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
}
export const useSftpViewFileOps = ({
@@ -112,9 +109,6 @@ export const useSftpViewFileOps = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
@@ -123,6 +117,7 @@ export const useSftpViewFileOps = ({
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
@@ -145,27 +140,49 @@ export const useSftpViewFileOps = ({
fullPath: string;
} | null>(null);
// Refs for frequently-changing state used inside stable callbacks
const fileOpenerTargetRef = useRef(fileOpenerTarget);
fileOpenerTargetRef.current = fileOpenerTarget;
const textEditorTargetRef = useRef(textEditorTarget);
textEditorTargetRef.current = textEditorTarget;
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
[],
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "left",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
[],
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "right",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
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 resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, fullPath);
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
setTextEditorContent(content);
setShowTextEditor(true);
@@ -180,22 +197,22 @@ export const useSftpViewFileOps = ({
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
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 resolvedFullPath = 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);
handleEditFileForSide(side, file, resolvedFullPath);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
fullPath,
resolvedFullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
@@ -207,7 +224,7 @@ export const useSftpViewFileOps = ({
}
}
setFileOpenerTarget({ file, side, fullPath });
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
@@ -215,23 +232,24 @@ export const useSftpViewFileOps = ({
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
const target = fileOpenerTargetRef.current;
if (!target) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.file.name);
const ext = getFileExtension(target.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
handleEditFileForSide(target.side, target.file, target.fullPath);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
fileOpenerTarget.side,
fileOpenerTarget.fullPath,
fileOpenerTarget.file.name,
target.side,
target.fullPath,
target.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
@@ -242,7 +260,7 @@ export const useSftpViewFileOps = ({
setFileOpenerTarget(null);
},
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
@@ -255,7 +273,8 @@ export const useSftpViewFileOps = ({
const handleSaveTextFile = useCallback(
async (content: string) => {
if (!textEditorTarget) return;
const target = textEditorTargetRef.current;
if (!target) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
@@ -263,64 +282,64 @@ export const useSftpViewFileOps = ({
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = textEditorTarget.side === "left"
const currentPane = target.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,
target.side,
target.fullPath,
content,
);
},
[textEditorTarget, sftpRef],
[sftpRef],
);
const onEditFileLeft = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
[handleOpenFileForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry) => {
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
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 });
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer) => {
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
// Check if upload was cancelled
if (results.some((r) => r.cancelled)) {
@@ -359,21 +378,21 @@ export const useSftpViewFileOps = ({
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
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 resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
@@ -384,7 +403,7 @@ export const useSftpViewFileOps = ({
return;
}
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
@@ -412,7 +431,7 @@ export const useSftpViewFileOps = ({
}
if (isDirectory) {
if (!listSftp || !mkdirLocal || !selectDirectory) {
if (!selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
@@ -422,402 +441,30 @@ export const useSftpViewFileOps = ({
const targetPath = joinFsPath(selectedDirectory, file.name);
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let completedBytes = 0;
const MAX_SYMLINK_DEPTH = 32;
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
const activeChildTransferIds = new Set<string>();
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
const activeFileSizes = new Map<string, number>();
const visitedPaths = new Set<string>();
const directoryTaskQueue: Array<{
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}> = [];
const fileTaskQueue: Array<{
type: "file";
remotePath: string;
localPath: string;
size: number;
}> = [];
let pendingDirectoryTasks = 0;
let discoveredTotalBytes = 0;
let estimatedTotalBytes = 0;
let activeQueueTasks = 0;
const isTaskCancelled = () =>
sftpRef.current.transfers.some(
(task) => task.id === transferId && task.status === "cancelled",
);
const updateAggregateProgress = () => {
let activeTransferredBytes = 0;
let activeSpeed = 0;
for (const progress of activeFileProgress.values()) {
activeTransferredBytes += progress.transferred;
activeSpeed += progress.speed;
}
sftpRef.current.updateExternalUpload(transferId, {
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
transferredBytes: completedBytes + activeTransferredBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
speed: activeSpeed,
});
};
const cancelActiveChildTransfers = async () => {
await Promise.all(
Array.from(activeChildTransferIds).map((childTransferId) =>
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
),
);
};
const maybeFinalizeDiscovery = () => {
if (pendingDirectoryTasks === 0) {
estimatedTotalBytes = discoveredTotalBytes;
updateAggregateProgress();
}
};
const getDynamicConcurrencyLimit = () => {
let largeFiles = 0;
let mediumFiles = 0;
for (const size of activeFileSizes.values()) {
if (size >= 32 * 1024 * 1024) largeFiles += 1;
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
}
if (largeFiles > 0) return 2;
if (mediumFiles >= 2) return 4;
if (mediumFiles === 1) return 5;
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
};
const enqueueDirectoryTask = (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
directoryTaskQueue.push(task);
};
const enqueueFileTask = (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
if (insertIndex === -1) {
fileTaskQueue.push(task);
} else {
fileTaskQueue.splice(insertIndex, 0, task);
}
};
const dequeueTask = () => {
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
return directoryTaskQueue.shift() ?? null;
}
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
return null;
};
const processFileTask = async (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferIds.add(childTransferId);
activeFileSizes.set(childTransferId, task.size);
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
updateAggregateProgress();
try {
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: task.remotePath,
targetPath: task.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
totalBytes: task.size,
sourceEncoding: pane.filenameEncoding,
},
(transferred, _total, speed) => {
if (isTaskCancelled()) {
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
return;
}
activeFileProgress.set(childTransferId, {
transferred,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
});
updateAggregateProgress();
},
() => {
completedBytes += task.size;
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
resolve();
},
(error) => {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(error));
},
)
.then((result) => {
if (result === undefined) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error("Stream transfer unavailable"));
} else if (result.error) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(result.error));
}
})
.catch(reject);
});
} finally {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
}
};
const processDirectoryTask = async (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
if (visitedPaths.has(task.remotePath)) {
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
return;
}
visitedPaths.add(task.remotePath);
if (isTaskCancelled()) {
throw new Error("Transfer cancelled");
}
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
for (const entry of entries) {
if (entry.name === ".." || entry.name === ".") continue;
if (isTaskCancelled()) {
await cancelActiveChildTransfers();
throw new Error("Transfer cancelled");
}
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
const localEntryPath = joinFsPath(task.localPath, entry.name);
const isRealDir = entry.type === "directory";
const isSymlinkDir =
entry.type === "symlink" && entry.linkTarget === "directory";
if (isRealDir || isSymlinkDir) {
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error(
"Maximum symlink directory depth exceeded (possible symlink cycle)",
);
}
try {
await mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
}
pendingDirectoryTasks += 1;
enqueueDirectoryTask({
type: "directory",
remotePath: remoteEntryPath,
localPath: localEntryPath,
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
});
continue;
}
const entrySize =
typeof entry.size === "string"
? parseInt(String(entry.size), 10) || 0
: entry.size || 0;
discoveredTotalBytes += entrySize;
enqueueFileTask({
type: "file",
remotePath: remoteEntryPath,
localPath: localEntryPath,
size: entrySize,
});
}
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
};
const runQueue = async () =>
new Promise<void>((resolve, reject) => {
let settled = false;
const pump = () => {
if (settled) return;
if (isTaskCancelled()) {
settled = true;
void cancelActiveChildTransfers().finally(() =>
reject(new Error("Transfer cancelled")),
);
return;
}
while (
activeQueueTasks < getDynamicConcurrencyLimit()
) {
const nextTask = dequeueTask();
if (!nextTask) break;
activeQueueTasks += 1;
Promise.resolve(
nextTask.type === "directory"
? processDirectoryTask(nextTask)
: processFileTask(nextTask),
)
.then(() => {
activeQueueTasks -= 1;
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
return;
}
pump();
})
.catch((error) => {
if (settled) return;
settled = true;
void cancelActiveChildTransfers().finally(() => reject(error));
});
}
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
}
};
pump();
});
sftpRef.current.addExternalUpload({
id: transferId,
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
sourcePath: fullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
retryable: false,
});
try {
try {
await mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (isEEXIST && deleteLocalFile) {
await deleteLocalFile(targetPath);
await mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
pendingDirectoryTasks = 1;
enqueueDirectoryTask({
type: "directory",
remotePath: fullPath,
localPath: targetPath,
symlinkDepth: 0,
});
await runQueue();
sftpRef.current.updateExternalUpload(transferId, {
status: "completed",
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
transferredBytes: completedBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
speed: 0,
endTime: Date.now(),
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceEncoding: pane.filenameEncoding,
isDirectory: true,
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
const isCancelled =
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errorMessage,
speed: 0,
endTime: Date.now(),
});
if (!isCancelled) {
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -832,7 +479,7 @@ export const useSftpViewFileOps = ({
sftpRef.current.addExternalUpload({
id: transferId,
fileName: file.name,
sourcePath: fullPath,
sourcePath: resolvedFullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: 'local',
@@ -851,7 +498,7 @@ export const useSftpViewFileOps = ({
const result = await startStreamTransfer(
{
transferId,
sourcePath: fullPath,
sourcePath: resolvedFullPath,
targetPath,
sourceType: 'sftp',
targetType: 'local',
@@ -925,9 +572,6 @@ export const useSftpViewFileOps = ({
[
sftpRef,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
@@ -936,17 +580,18 @@ export const useSftpViewFileOps = ({
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
[handleDownloadFileForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
@@ -955,20 +600,28 @@ export const useSftpViewFileOps = ({
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "left", "right");
sftpRef.current.startTransfer(fileData, "left", "right", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileLeft(entry);
onOpenFileLeft(entry, fullPath);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry) => {
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
@@ -977,13 +630,20 @@ export const useSftpViewFileOps = ({
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "right", "left");
sftpRef.current.startTransfer(fileData, "right", "left", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileRight(entry);
onOpenFileRight(entry, fullPath);
}
},
[sftpRef, onOpenFileRight, behaviorRef],

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks } from "../SftpContext";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -9,17 +10,21 @@ interface UseSftpViewPaneActionsParams {
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onRefreshTabLeft: (tabId: string) => void;
onRefreshTabRight: (tabId: string) => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
@@ -32,28 +37,38 @@ interface UseSftpViewPaneActionsResult {
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateDirectoryAtPathLeft: (path: string, name: string) => void;
onCreateDirectoryAtPathRight: (path: string, name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onCreateFileAtPathLeft: (path: string, name: string) => void;
onCreateFileAtPathRight: (path: string, name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onDeleteFilesAtPathLeft: (connectionId: string, path: string, names: string[]) => void;
onDeleteFilesAtPathRight: (connectionId: string, path: string, 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;
onRenameFileAtPathLeft: (oldPath: string, newName: string) => void;
onRenameFileAtPathRight: (oldPath: string, newName: string) => void;
onMoveEntriesToPathLeft: (sourcePaths: string[], targetPath: string) => void;
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
}
export const useSftpViewPaneActions = ({
sftpRef,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const [draggedFiles, setDraggedFiles] = useState<
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
(SftpTransferSource & { side: "left" | "right" })[] | null
>(null);
const handleDragStart = useCallback(
(
files: { name: string; isDirectory: boolean }[],
files: SftpTransferSource[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
@@ -65,25 +80,43 @@ export const useSftpViewPaneActions = ({
setDraggedFiles(null);
}, []);
const onCopyToOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
const startGroupedTransfer = useCallback(
(files: SftpTransferSource[], sourceSide: "left" | "right", targetSide: "left" | "right") => {
const groups = new Map<string, SftpTransferSource[]>();
for (const file of files) {
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath ?? ""}`;
const group = groups.get(key) ?? [];
group.push(file);
groups.set(key, group);
}
for (const group of groups.values()) {
const [{ sourceConnectionId, sourcePath, targetPath }] = group;
void sftpRef.current.startTransfer(group, sourceSide, targetSide, {
sourceConnectionId,
sourcePath,
targetPath,
});
}
},
[sftpRef],
);
const onCopyToOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onCopyToOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onConnectLeft = useCallback(
@@ -96,6 +129,12 @@ export const useSftpViewPaneActions = ({
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
const onPrepareSelectionLeft = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "left");
}, [sftpRef]);
const onPrepareSelectionRight = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "right");
}, [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
@@ -108,6 +147,8 @@ export const useSftpViewPaneActions = ({
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 onRefreshTabLeft = useCallback((tabId: string) => sftpRef.current.refresh("left", { tabId }), [sftpRef]);
const onRefreshTabRight = useCallback((tabId: string) => sftpRef.current.refresh("right", { tabId }), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
@@ -119,20 +160,32 @@ export const useSftpViewPaneActions = ({
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionLeft();
sftpRef.current.toggleSelection("left", name, multi);
},
[onPrepareSelectionLeft, sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionRight();
sftpRef.current.toggleSelection("right", name, multi);
},
[onPrepareSelectionRight, sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionLeft();
sftpRef.current.rangeSelect("left", fileNames);
},
[onPrepareSelectionLeft, sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionRight();
sftpRef.current.rangeSelect("right", fileNames);
},
[onPrepareSelectionRight, sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
@@ -152,6 +205,14 @@ export const useSftpViewPaneActions = ({
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateDirectoryAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("left", path, name),
[sftpRef],
);
const onCreateDirectoryAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("right", path, name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
@@ -160,6 +221,14 @@ export const useSftpViewPaneActions = ({
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onCreateFileAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("left", path, name),
[sftpRef],
);
const onCreateFileAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("right", path, name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
@@ -168,6 +237,16 @@ export const useSftpViewPaneActions = ({
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onDeleteFilesAtPathLeft = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("left", connectionId, path, names),
[sftpRef],
);
const onDeleteFilesAtPathRight = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("right", connectionId, path, names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
@@ -176,6 +255,22 @@ export const useSftpViewPaneActions = ({
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const onRenameFileAtPathLeft = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("left", oldPath, newName),
[sftpRef],
);
const onRenameFileAtPathRight = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("right", oldPath, newName),
[sftpRef],
);
const onMoveEntriesToPathLeft = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("left", sourcePaths, targetPath),
[sftpRef],
);
const onMoveEntriesToPathRight = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("right", sourcePaths, targetPath),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
@@ -192,12 +287,16 @@ export const useSftpViewPaneActions = ({
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onRefreshTabLeft,
onRefreshTabRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
@@ -210,12 +309,22 @@ export const useSftpViewPaneActions = ({
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateDirectoryAtPathLeft,
onCreateDirectoryAtPathRight,
onCreateFileLeft,
onCreateFileRight,
onCreateFileAtPathLeft,
onCreateFileAtPathRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onDeleteFilesAtPathLeft,
onDeleteFilesAtPathRight,
onRenameFileLeft,
onRenameFileRight,
onRenameFileAtPathLeft,
onRenameFileAtPathRight,
onMoveEntriesToPathLeft,
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,

View File

@@ -1,11 +1,15 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { formatFileSize, formatDate } from '../../../application/state/sftp/utils';
import { isSessionError } from "../../../application/state/sftp/errors";
import { filterHiddenFiles } from "../utils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -21,8 +25,6 @@ interface UseSftpViewPaneCallbacksParams {
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
@@ -43,6 +45,7 @@ interface UseSftpViewPaneCallbacksParams {
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
}
export const useSftpViewPaneCallbacks = ({
@@ -53,12 +56,11 @@ export const useSftpViewPaneCallbacks = ({
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
listLocalFiles,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
@@ -68,23 +70,81 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
});
const listLocalFilesRef = useRef(listLocalFiles);
const listSftpRef = useRef(listSftp);
const getSftpIdForConnectionRef = useRef(getSftpIdForConnection);
useEffect(() => {
listLocalFilesRef.current = listLocalFiles;
listSftpRef.current = listSftp;
getSftpIdForConnectionRef.current = getSftpIdForConnection;
}, [listLocalFiles, listSftp, getSftpIdForConnection]);
const makeListDirectory = (side: "left" | "right", getPane: () => SftpPane) =>
async (path: string) => {
const pane = getPane();
if (!pane.connection) return [];
const toSize = (raw: string) => parseInt(raw) || 0;
const toTs = (raw: string) => new Date(raw).getTime();
const normalizeEntries = (rawFiles: RemoteFile[]) =>
filterHiddenFiles(
rawFiles.map(f => {
const s = toSize(f.size);
const ms = toTs(f.lastModified);
return {
name: f.name,
type: f.type as 'file' | 'directory' | 'symlink',
size: s,
sizeFormatted: formatFileSize(s),
lastModified: ms,
lastModifiedFormatted: formatDate(ms),
permissions: f.permissions,
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
hidden: f.hidden,
};
}),
pane.showHiddenFiles,
);
if (pane.connection.isLocal) {
return normalizeEntries(await listLocalFilesRef.current(path));
}
const sftpId = getSftpIdForConnectionRef.current?.(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
sftpRef.current.reportSessionError(side, error);
throw error;
}
let rawFiles: RemoteFile[] | undefined;
try {
rawFiles = await listSftpRef.current?.(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpRef.current.reportSessionError(side, err as Error);
}
throw err;
}
if (!rawFiles) return [];
return normalizeEntries(rawFiles);
};
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onRefreshTab: paneActions.onRefreshTabLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
@@ -92,9 +152,14 @@ export const useSftpViewPaneCallbacks = ({
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathLeft,
onCreateFile: paneActions.onCreateFileLeft,
onCreateFileAtPath: paneActions.onCreateFileAtPathLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathLeft,
onRenameFile: paneActions.onRenameFileLeft,
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
@@ -103,6 +168,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
}),
[],
);
@@ -111,9 +177,11 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onRefreshTab: paneActions.onRefreshTabRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
@@ -121,9 +189,14 @@ export const useSftpViewPaneCallbacks = ({
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathRight,
onCreateFile: paneActions.onCreateFileRight,
onCreateFileAtPath: paneActions.onCreateFileAtPathRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathRight,
onRenameFile: paneActions.onRenameFileRight,
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
@@ -132,6 +205,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
}),
[],
);

View File

@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => void;
handleAddTabRight: () => void;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleSelectTabLeft: (tabId: string) => void;
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
const [hostSearchRight, setHostSearchRight] = useState("");
const handleAddTabLeft = useCallback(() => {
sftpRef.current.addTab("left");
const tabId = sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
return tabId;
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
sftpRef.current.addTab("right");
const tabId = sftpRef.current.addTab("right");
setShowHostPickerRight(true);
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {

View File

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes, formatDate,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, type ColumnWidths, type SortField,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, sortSftpEntries, type ColumnWidths, type SortField,
type SortOrder
} from './utils';

View File

@@ -22,8 +22,160 @@ import {
Terminal,
} from 'lucide-react';
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { SftpFileEntry } from '../../types';
// Pre-built icon maps for O(1) lookup in getFileIcon
type IconDef = [LucideIcon, string?];
const EXTENSION_ICON_MAP = new Map<string, IconDef>([
// Documents
['doc', [FileText, "text-blue-500"]],
['docx', [FileText, "text-blue-500"]],
['rtf', [FileText, "text-blue-500"]],
['odt', [FileText, "text-blue-500"]],
['xls', [FileSpreadsheet, "text-green-500"]],
['xlsx', [FileSpreadsheet, "text-green-500"]],
['csv', [FileSpreadsheet, "text-green-500"]],
['ods', [FileSpreadsheet, "text-green-500"]],
['ppt', [FileType, "text-orange-500"]],
['pptx', [FileType, "text-orange-500"]],
['odp', [FileType, "text-orange-500"]],
['pdf', [FileText, "text-red-500"]],
// Code/Scripts
['js', [FileCode, "text-yellow-500"]],
['jsx', [FileCode, "text-yellow-500"]],
['ts', [FileCode, "text-yellow-500"]],
['tsx', [FileCode, "text-yellow-500"]],
['mjs', [FileCode, "text-yellow-500"]],
['cjs', [FileCode, "text-yellow-500"]],
['py', [FileCode, "text-blue-400"]],
['pyc', [FileCode, "text-blue-400"]],
['pyw', [FileCode, "text-blue-400"]],
['sh', [Terminal, "text-green-400"]],
['bash', [Terminal, "text-green-400"]],
['zsh', [Terminal, "text-green-400"]],
['fish', [Terminal, "text-green-400"]],
['bat', [Terminal, "text-green-400"]],
['cmd', [Terminal, "text-green-400"]],
['ps1', [Terminal, "text-green-400"]],
['c', [FileCode, "text-blue-600"]],
['cpp', [FileCode, "text-blue-600"]],
['h', [FileCode, "text-blue-600"]],
['hpp', [FileCode, "text-blue-600"]],
['cc', [FileCode, "text-blue-600"]],
['cxx', [FileCode, "text-blue-600"]],
['java', [FileCode, "text-orange-600"]],
['class', [FileCode, "text-orange-600"]],
['jar', [FileCode, "text-orange-600"]],
['go', [FileCode, "text-cyan-500"]],
['rs', [FileCode, "text-orange-400"]],
['rb', [FileCode, "text-red-400"]],
['php', [FileCode, "text-purple-500"]],
['html', [Globe, "text-orange-500"]],
['htm', [Globe, "text-orange-500"]],
['xhtml', [Globe, "text-orange-500"]],
['css', [FileCode, "text-blue-500"]],
['scss', [FileCode, "text-blue-500"]],
['sass', [FileCode, "text-blue-500"]],
['less', [FileCode, "text-blue-500"]],
['vue', [FileCode, "text-green-500"]],
['svelte', [FileCode, "text-green-500"]],
// Config/Data
['json', [FileCode, "text-yellow-600"]],
['json5', [FileCode, "text-yellow-600"]],
['xml', [FileCode, "text-orange-400"]],
['xsl', [FileCode, "text-orange-400"]],
['xslt', [FileCode, "text-orange-400"]],
['yml', [Settings, "text-pink-400"]],
['yaml', [Settings, "text-pink-400"]],
['toml', [Settings, "text-gray-400"]],
['ini', [Settings, "text-gray-400"]],
['conf', [Settings, "text-gray-400"]],
['cfg', [Settings, "text-gray-400"]],
['config', [Settings, "text-gray-400"]],
['env', [Lock, "text-yellow-500"]],
['sql', [Database, "text-blue-400"]],
['sqlite', [Database, "text-blue-400"]],
['db', [Database, "text-blue-400"]],
// Images
['jpg', [FileImage, "text-purple-400"]],
['jpeg', [FileImage, "text-purple-400"]],
['png', [FileImage, "text-purple-400"]],
['gif', [FileImage, "text-purple-400"]],
['bmp', [FileImage, "text-purple-400"]],
['webp', [FileImage, "text-purple-400"]],
['svg', [FileImage, "text-purple-400"]],
['ico', [FileImage, "text-purple-400"]],
['tiff', [FileImage, "text-purple-400"]],
['tif', [FileImage, "text-purple-400"]],
['heic', [FileImage, "text-purple-400"]],
['heif', [FileImage, "text-purple-400"]],
['avif', [FileImage, "text-purple-400"]],
// Videos
['mp4', [FileVideo, "text-pink-500"]],
['mkv', [FileVideo, "text-pink-500"]],
['avi', [FileVideo, "text-pink-500"]],
['mov', [FileVideo, "text-pink-500"]],
['wmv', [FileVideo, "text-pink-500"]],
['flv', [FileVideo, "text-pink-500"]],
['webm', [FileVideo, "text-pink-500"]],
['m4v', [FileVideo, "text-pink-500"]],
['3gp', [FileVideo, "text-pink-500"]],
['mpeg', [FileVideo, "text-pink-500"]],
['mpg', [FileVideo, "text-pink-500"]],
// Audio
['mp3', [FileAudio, "text-green-400"]],
['wav', [FileAudio, "text-green-400"]],
['flac', [FileAudio, "text-green-400"]],
['aac', [FileAudio, "text-green-400"]],
['ogg', [FileAudio, "text-green-400"]],
['m4a', [FileAudio, "text-green-400"]],
['wma', [FileAudio, "text-green-400"]],
['opus', [FileAudio, "text-green-400"]],
['aiff', [FileAudio, "text-green-400"]],
// Archives
['zip', [FileArchive, "text-amber-500"]],
['rar', [FileArchive, "text-amber-500"]],
['7z', [FileArchive, "text-amber-500"]],
['tar', [FileArchive, "text-amber-500"]],
['gz', [FileArchive, "text-amber-500"]],
['bz2', [FileArchive, "text-amber-500"]],
['xz', [FileArchive, "text-amber-500"]],
['tgz', [FileArchive, "text-amber-500"]],
['tbz2', [FileArchive, "text-amber-500"]],
['lz', [FileArchive, "text-amber-500"]],
['lzma', [FileArchive, "text-amber-500"]],
['cab', [FileArchive, "text-amber-500"]],
['iso', [FileArchive, "text-amber-500"]],
['dmg', [FileArchive, "text-amber-500"]],
// Executables
['exe', [File, "text-red-400"]],
['msi', [File, "text-red-400"]],
['app', [File, "text-red-400"]],
['deb', [File, "text-red-400"]],
['rpm', [File, "text-red-400"]],
['apk', [File, "text-red-400"]],
['ipa', [File, "text-red-400"]],
['dll', [File, "text-gray-500"]],
['so', [File, "text-gray-500"]],
['dylib', [File, "text-gray-500"]],
// Keys/Certs
['pem', [Key, "text-yellow-400"]],
['crt', [Key, "text-yellow-400"]],
['cer', [Key, "text-yellow-400"]],
['key', [Key, "text-yellow-400"]],
['pub', [Key, "text-yellow-400"]],
['ppk', [Key, "text-yellow-400"]],
// Text/Markdown
['md', [FileText, "text-gray-400"]],
['markdown', [FileText, "text-gray-400"]],
['mdx', [FileText, "text-gray-400"]],
['txt', [FileText, "text-muted-foreground"]],
['log', [FileText, "text-muted-foreground"]],
['text', [FileText, "text-muted-foreground"]],
]);
/**
* Format bytes with appropriate unit (B, KB, MB, GB)
*/
@@ -70,7 +222,8 @@ export const formatSpeed = (bytesPerSecond: number): string => {
};
/**
* Comprehensive file icon helper - returns JSX element based on file type
* Comprehensive file icon helper - returns JSX element based on file type.
* Uses pre-built Map for O(1) extension lookup.
*/
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
@@ -80,89 +233,13 @@ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() ?? '' : '';
// Documents
if (['doc', 'docx', 'rtf', 'odt'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-blue-500" });
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext))
return React.createElement(FileSpreadsheet, { size: 14, className: "text-green-500" });
if (['ppt', 'pptx', 'odp'].includes(ext))
return React.createElement(FileType, { size: 14, className: "text-orange-500" });
if (['pdf'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-red-500" });
// Code/Scripts
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-yellow-500" });
if (['py', 'pyc', 'pyw'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-400" });
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1'].includes(ext))
return React.createElement(Terminal, { size: 14, className: "text-green-400" });
if (['c', 'cpp', 'h', 'hpp', 'cc', 'cxx'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-600" });
if (['java', 'class', 'jar'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-600" });
if (['go'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-cyan-500" });
if (['rs'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-400" });
if (['rb'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-red-400" });
if (['php'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-purple-500" });
if (['html', 'htm', 'xhtml'].includes(ext))
return React.createElement(Globe, { size: 14, className: "text-orange-500" });
if (['css', 'scss', 'sass', 'less'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-500" });
if (['vue', 'svelte'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-green-500" });
// Config/Data
if (['json', 'json5'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-yellow-600" });
if (['xml', 'xsl', 'xslt'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-400" });
if (['yml', 'yaml'].includes(ext))
return React.createElement(Settings, { size: 14, className: "text-pink-400" });
if (['toml', 'ini', 'conf', 'cfg', 'config'].includes(ext))
return React.createElement(Settings, { size: 14, className: "text-gray-400" });
if (['env'].includes(ext))
return React.createElement(Lock, { size: 14, className: "text-yellow-500" });
if (['sql', 'sqlite', 'db'].includes(ext))
return React.createElement(Database, { size: 14, className: "text-blue-400" });
// Images
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif', 'heic', 'heif', 'avif'].includes(ext))
return React.createElement(FileImage, { size: 14, className: "text-purple-400" });
// Videos
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg'].includes(ext))
return React.createElement(FileVideo, { size: 14, className: "text-pink-500" });
// Audio
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'aiff'].includes(ext))
return React.createElement(FileAudio, { size: 14, className: "text-green-400" });
// Archives
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'lz', 'lzma', 'cab', 'iso', 'dmg'].includes(ext))
return React.createElement(FileArchive, { size: 14, className: "text-amber-500" });
// Executables
if (['exe', 'msi', 'app', 'deb', 'rpm', 'apk', 'ipa'].includes(ext))
return React.createElement(File, { size: 14, className: "text-red-400" });
if (['dll', 'so', 'dylib'].includes(ext))
return React.createElement(File, { size: 14, className: "text-gray-500" });
// Keys/Certs
if (['pem', 'crt', 'cer', 'key', 'pub', 'ppk'].includes(ext))
return React.createElement(Key, { size: 14, className: "text-yellow-400" });
// Text/Markdown
if (['md', 'markdown', 'mdx'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-gray-400" });
if (['txt', 'log', 'text'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-muted-foreground" });
const iconDef = EXTENSION_ICON_MAP.get(ext);
if (iconDef) {
const [Icon, className] = iconDef;
return React.createElement(Icon, { size: 14, ...(className ? { className } : {}) });
}
// Default
return React.createElement(FileCode, { size: 14 });
@@ -180,6 +257,59 @@ export interface ColumnWidths {
type: number;
}
export const buildSftpColumnTemplate = (columnWidths: ColumnWidths): string => {
return [
`minmax(140px, ${columnWidths.name}fr)`,
`minmax(0, ${columnWidths.modified}fr)`,
`minmax(52px, ${columnWidths.size}fr)`,
`minmax(64px, ${columnWidths.type}fr)`,
].join(' ');
};
export const sortSftpEntries = (
entries: SftpFileEntry[],
sortField: SortField,
sortOrder: SortOrder,
): SftpFileEntry[] => {
if (!entries.length) return entries;
const sorted = [...entries].sort((a, b) => {
const aIsDir = isNavigableDirectory(a);
const bIsDir = isNavigableDirectory(b);
if (sortField !== 'type') {
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) 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 = aIsDir
? 'folder'
: a.name.split('.').pop()?.toLowerCase() || '';
const extB = bIsDir
? 'folder'
: b.name.split('.').pop()?.toLowerCase() || '';
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return sorted;
};
/**
* Check if an entry is navigable like a directory
* This includes regular directories and symlinks that point to directories

View File

@@ -191,12 +191,15 @@ export async function getCompletions(
}
if (preferPathSuggestions && ctx.commandName) {
// When path completion is active (file-related commands like cat, vim, cd),
// recent history is still useful but should rank below actual path matches
// from the current directory.
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId,
limit: 3,
limit: 5,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
@@ -205,7 +208,7 @@ export async function getCompletions(
text: entry.command,
displayText: entry.command,
source: "history",
score: 900 - index,
score: 720 - index,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);

View File

@@ -44,15 +44,36 @@ const CACHE_TTL_MS = 5000;
const MAX_CACHE_SIZE = 30;
const MAX_FILTERED_CACHE_SIZE = 60;
/** Commands that commonly accept file/directory path arguments */
/** Commands that commonly accept file/directory path arguments.
* Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded —
* their path arguments are better handled via Fig specs. */
const PATH_COMMANDS = new Set([
"cd", "ls", "ll", "la", "dir", "cat", "less", "more", "head", "tail",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl",
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "chmod", "chown", "chgrp",
"stat", "file", "source", ".", "bat", "rg", "find", "tree",
"tar", "zip", "unzip", "gzip", "gunzip",
"scp", "rsync", "diff",
"python", "python3", "node", "ruby", "perl", "bash", "sh", "zsh",
// Navigation & listing
"cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd",
// Viewing & editing
"cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit",
// File operations
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred",
// Permissions & metadata
"chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr",
// Search & filter
"find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate",
"wc", "sort", "uniq", "cut", "awk", "sed",
// Archive & compression
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd",
"7z", "rar", "unrar",
// Transfer & sync
"scp", "rsync", "diff", "cmp", "patch",
// Scripting & execution
"source", ".", "bash", "sh", "zsh", "fish",
"python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++",
"deno", "bun", "tsx", "ts-node",
// Disk & filesystem
"du", "df", "chroot",
// Misc
"realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump",
"xdg-open", "open", "start",
]);
/** Commands that only accept directories (not files) */

View File

@@ -101,8 +101,8 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
className,
}) => {
return (
<ScrollArea className={cn("flex-1", className)}>
<div className="p-4 space-y-4 overflow-hidden">
<ScrollArea className={cn("flex-1 min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-x-hidden">
{children}
</div>
</ScrollArea>
@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>

View File

@@ -373,6 +373,9 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' },
{ id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' },
{ id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' },
{ id: 'sftp-open', action: 'sftpOpen', label: 'Open File / Enter Directory', mac: 'Enter', pc: 'Enter', category: 'sftp' },
{ id: 'sftp-go-parent', action: 'sftpGoParent', label: 'Go to Parent Directory', mac: '⌫', pc: 'Backspace', category: 'sftp' },
{ id: 'sftp-navigate-to', action: 'sftpNavigateTo', label: 'Navigate to Selected Directory', mac: '⌘ + Enter', pc: 'Ctrl + Enter', category: 'sftp' },
];
// Terminal appearance settings
@@ -712,6 +715,7 @@ export interface TransferTask {
startTime: number;
endTime?: number;
isDirectory: boolean;
progressMode?: 'bytes' | 'files';
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat

View File

@@ -198,6 +198,7 @@ export interface SyncPayload {
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Immersive mode
immersiveMode?: boolean;
};

View File

@@ -411,6 +411,16 @@ export function mergeSyncPayloads(
// Merge settings
const settings = mergeSettings(b.settings, local.settings, remote.settings);
// Deduplicate global SFTP bookmarks by path (IDs are random per device)
if (settings?.sftpGlobalBookmarks && settings.sftpGlobalBookmarks.length > 0) {
const seenPaths = new Set<string>();
settings.sftpGlobalBookmarks = settings.sftpGlobalBookmarks.filter((bm) => {
if (seenPaths.has(bm.path)) return false;
seenPaths.add(bm.path);
return true;
});
}
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,

View File

@@ -69,28 +69,24 @@ function escapeCmdForNestedShell(text) {
function buildWrappedCommand(command, shellKind, marker) {
switch (shellKind) {
case "powershell": {
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
// the PTY delivers it in small chunks (the marker must appear early).
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
const psEscaped = escapePowerShellSingleQuoted(command);
return (
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
`$${marker}=0; $${marker}_cmd='${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
);
}
case "cmd": {
const cmdEscaped = escapeCmdForNestedShell(command);
return (
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
);
}
case "fish":
// set __NCMCP_... at the start ensures early marker presence in echo.
return (
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
// Clear the current terminal row before the user-visible echo.
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
@@ -98,9 +94,9 @@ function buildWrappedCommand(command, shellKind, marker) {
case "posix":
default: {
// Single-line compound command with early marker & visible command echo.
// Single-line compound command with early marker.
//
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
// Key design decisions:
//
@@ -111,26 +107,44 @@ function buildWrappedCommand(command, shellKind, marker) {
// long echo line might not contain the marker and would leak
// through to the terminal as garbage.
//
// 2) printf clears the current row and outputs "> command\n"
// (no marker) → visible to user without prompt residue.
//
// 3) The user command is executed via eval on a quoted string. This
// 2) The user command is executed via eval on a quoted string. This
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
// 3) Single-line { ... } is parsed fully before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const escaped = escapePosixSingleQuoted(command);
return (
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
`${marker}=0; ${marker}_cmd='${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
);
}
}
}
function findEndMarker(outputText, marker) {
const endPattern = marker + "_E:";
let searchFrom = 0;
while (searchFrom < outputText.length) {
const endIdx = outputText.indexOf(endPattern, searchFrom);
if (endIdx === -1) return null;
// Accept if at start of output, or preceded by \n or \r (line boundary)
if (endIdx === 0 || outputText[endIdx - 1] === "\n" || outputText[endIdx - 1] === "\r") {
const afterEnd = outputText.slice(endIdx + endPattern.length);
const codeMatch = afterEnd.match(/^(\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
if (exitCode !== null) {
return { endIdx, exitCode };
}
}
searchFrom = endIdx + 1;
}
return null;
}
/**
* Execute command through a terminal PTY stream.
* The user sees the command typed and output in their terminal.
@@ -145,6 +159,8 @@ function buildWrappedCommand(command, shellKind, marker) {
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
* @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
*/
function execViaPty(ptyStream, command, options) {
const {
@@ -155,6 +171,8 @@ function execViaPty(ptyStream, command, options) {
chatSessionId,
abortSignal,
expectedPrompt,
typedInput = false,
echoCommand,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
@@ -168,6 +186,7 @@ function execViaPty(ptyStream, command, options) {
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let preStartOutput = "";
let timeoutId = null;
let promptFallbackTimer = null;
let finished = false;
@@ -185,6 +204,7 @@ function execViaPty(ptyStream, command, options) {
const text = data.toString();
if (!foundStart) {
preStartOutput += text;
const combined = pendingStart + text;
pendingStart = "";
const startMarker = marker + "_S";
@@ -211,8 +231,26 @@ function execViaPty(ptyStream, command, options) {
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
}
if (foundStart) {
preStartOutput = "";
schedulePromptFallback();
checkEnd();
return;
}
// Fallback: if strict start-marker detection missed (e.g. due shell
// control sequence prefixes), still complete as soon as we observe a
// valid end marker with exit code.
const fallbackEnd = findEndMarker(preStartOutput, marker);
if (fallbackEnd) {
let stdout = preStartOutput.slice(0, fallbackEnd.endIdx);
const lastStartIdx = stdout.lastIndexOf(startMarker);
if (lastStartIdx !== -1) {
const nlAfterStart = stdout.indexOf("\n", lastStartIdx);
if (nlAfterStart !== -1) {
stdout = stdout.slice(nlAfterStart + 1);
}
}
finish(stdout, fallbackEnd.exitCode);
}
return;
}
@@ -244,24 +282,10 @@ function execViaPty(ptyStream, command, options) {
function checkEnd() {
// Look for the end marker at a line boundary (actual printf output),
// not inside the echo of the printf command argument.
const endPattern = marker + "_E:";
let searchFrom = 0;
while (searchFrom < output.length) {
const endIdx = output.indexOf(endPattern, searchFrom);
if (endIdx === -1) return;
// Accept if at start of output, or preceded by \n or \r (line boundary)
if (endIdx === 0 || output[endIdx - 1] === '\n' || output[endIdx - 1] === '\r') {
const afterEnd = output.slice(endIdx + endPattern.length);
const codeMatch = afterEnd.match(/^(\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
const stdout = output.slice(0, endIdx);
finish(stdout, exitCode);
return;
}
searchFrom = endIdx + 1;
}
const found = findEndMarker(output, marker);
if (!found) return;
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
}
function finish(stdout, exitCode, error) {
@@ -350,7 +374,15 @@ function execViaPty(ptyStream, command, options) {
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
// Markers are filtered from terminal display by preload.cjs.
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
}

View File

@@ -229,7 +229,7 @@ function init(deps) {
sessions = deps.sessions;
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
mcpServerBridge.init({ sessions, sftpClients });
mcpServerBridge.init({ sessions, sftpClients, electronModule });
// Wire up main window getter for MCP approval IPC
mcpServerBridge.setMainWindowGetter(() => {
@@ -939,6 +939,15 @@ function registerHandlers(ipcMain) {
shellKind: session.shellKind,
chatSessionId,
expectedPrompt: session.lastIdlePrompt || "",
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", {
sessionId,
data: `${rawCommand}\r\n`,
syntheticEcho: true,
});
},
});
}

View File

@@ -13,11 +13,13 @@ const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { execViaPty, execViaChannel, execViaRawPty } = require("./ai/ptyExec.cjs");
const { safeSend } = require("./ipcUtils.cjs");
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
let tcpServer = null;
let tcpPort = null;
let authToken = null; // Random token generated when TCP server starts
let electronModule = null;
// Track which sockets have completed authentication
const authenticatedSockets = new WeakSet();
@@ -161,11 +163,22 @@ function cancelPtyExecsForSession(chatSessionId) {
function init(deps) {
sessions = deps.sessions;
electronModule = deps.electronModule || null;
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
}
function echoCommandToSession(session, sessionId, command) {
if (!electronModule || !session?.webContentsId || !command) return;
const contents = electronModule.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", {
sessionId,
data: `${command}\r\n`,
syntheticEcho: true,
});
}
function setCommandBlocklist(list) {
commandBlocklist = list || [];
// Recompile cached regexes when blocklist changes
@@ -581,6 +594,8 @@ function handleExec(params) {
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
expectedPrompt: session.lastIdlePrompt || "",
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
});
}

View File

@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
.logo {
width: 36px;
height: 36px;
color: hsl(var(--accent));
}
.brand {
font-size: 16px;
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
<body>
<div class="shell">
<div class="header">
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
stroke="currentColor" stroke-width="2" />
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
</svg>
<div>
<div class="brand">Netcatty</div>
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync.",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
autoClose: true,
})
);

View File

@@ -1858,4 +1858,5 @@ module.exports = {
renameSftp,
statSftp,
chmodSftp,
resolveEncodingForRequest,
};

View File

@@ -6,7 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
@@ -50,6 +50,9 @@ let sftpClients = null;
// Active transfers storage
const activeTransfers = new Map();
const isolatedDownloadChannelPools = new WeakMap();
// Cache sftpIds where remote cp is known to be unavailable, so we skip
// repeated failed exec attempts for each file in a multi-file transfer.
const cpUnavailableSet = new Set();
/**
* Initialize the transfer bridge with dependencies
@@ -58,6 +61,46 @@ function init(deps) {
sftpClients = deps.sftpClients;
}
/**
* Execute an SSH command with cancellation support.
* Registers an abort hook on the transfer object that closes the exec stream,
* which sends SIGHUP to the remote process.
*/
function execSshCommandCancellable(sshClient, command, transfer) {
return new Promise((resolve, reject) => {
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
sshClient.exec(command, (err, stream) => {
if (err) return reject(err);
// If cancelled between exec() call and callback, kill immediately
if (transfer.cancelled) {
try { stream.close(); } catch { }
return reject(new Error('Transfer cancelled'));
}
let stdout = '';
let stderr = '';
// Wire abort: closing the stream kills the remote process
const prevAbort = transfer.abort;
transfer.abort = () => {
try { stream.close(); } catch { }
if (typeof prevAbort === 'function') prevAbort();
};
stream.on('close', (code) => {
transfer.abort = prevAbort; // restore
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
resolve({ stdout, stderr, code });
});
stream.on('data', (data) => { stdout += data.toString(); });
stream.stderr.on('data', (data) => { stderr += data.toString(); });
});
});
}
async function openIsolatedSftpChannel(client) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") return null;
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
totalBytes,
sourceEncoding,
targetEncoding,
sameHost,
} = payload;
const sender = event.sender;
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
// Try same-host optimization first: remote cp via SSH exec.
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
let sameHostDone = false;
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
if (sameHost
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
&& !cpUnavailableSet.has(sourceSftpId)) {
const srcClient = sftpClients.get(sourceSftpId);
const sshClient = srcClient?.client;
if (sshClient && typeof sshClient.exec === 'function') {
try {
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(sourceSftpId);
}
// Other non-zero exits (permission denied, disk full, etc.)
// fall through to download+upload without caching
} catch (cpErr) {
// If cancelled, re-throw; otherwise fall back to download+upload
if (transfer.cancelled) throw cpErr;
}
}
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
if (!sameHostDone) {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
try { await fs.promises.unlink(tempPath); } catch { }
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
try { await fs.promises.unlink(tempPath); } catch { }
}
} else {
throw new Error("Invalid transfer configuration");
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
return { success: true };
}
/**
* Same-host directory copy: uses a single `cp -ra` command on the remote server
* instead of recursively transferring files one by one.
*/
async function sameHostCopyDirectory(event, payload) {
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
// Register in activeTransfers so cancelTransfer can flag it
const transfer = { cancelled: false };
if (transferId) {
activeTransfers.set(transferId, transfer);
}
try {
if (cpUnavailableSet.has(sftpId)) return { success: false };
const client = sftpClients.get(sftpId);
if (!client) return { success: false };
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
// Ensure target directory itself exists (not just its parent),
// so cp copies contents into it rather than creating a nested subdirectory.
const targetDir = targetPath.replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
try {
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 127) {
cpUnavailableSet.add(sftpId);
return { success: false };
}
if (result.code !== 0) {
return { success: false };
}
} catch (cpErr) {
if (transfer.cancelled) throw cpErr;
return { success: false };
}
return { success: true };
} finally {
if (transferId) {
activeTransfers.delete(transferId);
}
}
}
/**
* Register IPC handlers for transfer operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:transfer:start", startTransfer);
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
}
module.exports = {
@@ -762,4 +906,5 @@ module.exports = {
registerHandlers,
startTransfer,
cancelTransfer,
sameHostCopyDirectory,
};

View File

@@ -112,6 +112,10 @@ function _deliverToListeners(sessionId, data) {
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
if (payload?.syntheticEcho) {
_deliverToListeners(payload.sessionId, payload.data);
return;
}
const data = filterMcpChunk(payload.sessionId, payload.data);
if (data) {
set.forEach((cb) => {
@@ -737,6 +741,9 @@ const api = {
cleanupTransferListeners(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
},
// Compressed folder upload
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
const { compressionId } = options;

2
global.d.ts vendored
View File

@@ -346,6 +346,7 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
// Compressed folder upload
startCompressedUpload?(
@@ -383,6 +384,7 @@ declare global {
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
sameHost?: boolean;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,

View File

@@ -335,10 +335,26 @@ body {
overflow: visible;
}
/* Dim terminal text in unfocused workspace panes */
/* Dim terminal text in unfocused workspace panes (default) */
.workspace-pane:not(:focus-within) .xterm-screen {
opacity: 0.65;
}
/* Border-style focus indicator (opt-in via data attribute) */
[data-workspace-focus="border"] .workspace-pane:not(:focus-within) .xterm-screen {
opacity: 1;
}
[data-workspace-focus="border"] .workspace-pane::after {
content: "";
position: absolute;
inset: 0;
border: 2px solid transparent;
pointer-events: none;
transition: border-color 120ms ease;
z-index: 40;
}
[data-workspace-focus="border"] .workspace-pane:focus-within::after {
border-color: hsl(var(--primary));
}
/* ── Streamdown code block overrides ── */
[data-streamdown="code-block"] {

View File

@@ -42,6 +42,7 @@ export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1'
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
export const STORAGE_KEY_SFTP_DEFAULT_OPENER = 'netcatty_sftp_default_opener_v1';
// SFTP Local Bookmarks
export const STORAGE_KEY_SFTP_LOCAL_BOOKMARKS = 'netcatty_sftp_local_bookmarks_v1';
@@ -55,6 +56,10 @@ export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
export const STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR = 'netcatty_sftp_auto_open_sidebar_v1';
export const STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE = 'netcatty_sftp_default_view_mode_v1';
export const STORAGE_KEY_SFTP_HOST_VIEW_MODES = 'netcatty_sftp_host_view_modes_v1';
export const STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT = 'netcatty_sftp_transfer_panel_height_v1';
export const STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH = 'netcatty_sftp_transfer_child_name_width_v1';
// Editor Settings
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
@@ -94,6 +99,12 @@ export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
// SFTP Transfer Concurrency
export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_concurrency_v1';
// Workspace Focus Indicator Style
export const STORAGE_KEY_WORKSPACE_FOCUS_STYLE = 'netcatty_workspace_focus_style_v1';
// Immersive Mode
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';

View File

@@ -574,16 +574,13 @@ export async function extractDropEntries(
// Build a map of file/folder name to path from the original files in DataTransfer.files
const filePathMap = new Map<string, string>();
const filesWithPath = dataTransfer.files;
console.log('[extractDropEntries] DataTransfer.files count:', filesWithPath.length);
for (let i = 0; i < filesWithPath.length; i++) {
const f = filesWithPath[i];
const path = getPathForFile(f);
console.log('[extractDropEntries] File:', { name: f.name, path, size: f.size });
if (path) {
filePathMap.set(f.name, path);
}
}
console.log('[extractDropEntries] filePathMap:', Object.fromEntries(filePathMap));
// Check if webkitGetAsEntry is supported (for folder access)
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
@@ -611,13 +608,11 @@ export async function extractDropEntries(
const directPath = getPathForFile(result.file);
if (directPath) {
(result.file as File & { path?: string }).path = directPath;
console.log('[extractDropEntries] Direct path for:', { relativePath: result.relativePath, path: directPath });
} else {
// Fallback: try to reconstruct from root folder path
const pathParts = result.relativePath.split('/');
const rootName = pathParts[0];
const rootPath = filePathMap.get(rootName);
console.log('[extractDropEntries] Fallback matching:', { relativePath: result.relativePath, rootName, rootPath });
if (rootPath) {
if (pathParts.length === 1) {

View File

@@ -7,6 +7,7 @@
*/
import { extractDropEntries, DropEntry, getPathForFile } from "./sftpFileUtils";
import { logger } from "./logger";
// ============================================================================
// Types
@@ -26,6 +27,8 @@ export interface UploadTaskInfo {
/** Display name for bundled tasks (e.g., "folder (5 files)") */
displayName: string;
isDirectory: boolean;
progressMode?: 'bytes' | 'files';
parentTaskId?: string;
totalBytes: number;
transferredBytes: number;
speed: number;
@@ -323,17 +326,25 @@ export async function uploadFromDataTransfer(
const scanningTaskId = crypto.randomUUID();
callbacks?.onScanningStart?.(scanningTaskId);
const scanT0 = performance.now();
let entries: DropEntry[];
try {
entries = await extractDropEntries(dataTransfer);
} finally {
} catch (error) {
callbacks?.onScanningEnd?.(scanningTaskId);
throw error;
}
logger.debug(`[SFTP:perf] extractDropEntries — ${entries.length} entries — ${(performance.now() - scanT0).toFixed(0)}ms`);
if (entries.length === 0) {
callbacks?.onScanningEnd?.(scanningTaskId);
return [];
}
if (!entries.some((entry) => !entry.isDirectory && entry.file)) {
callbacks?.onScanningEnd?.(scanningTaskId);
}
// Check if this is a folder upload and compressed upload is enabled
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
@@ -509,8 +520,42 @@ async function uploadEntries(
const rootFolders = detectRootFolders(entries);
const sortedEntries = sortEntries(entries);
// Pre-create all needed directories in batch before file transfers
const uploadT0 = performance.now();
logger.debug(`[SFTP:perf] uploadEntries START — ${sortedEntries.length} entries, ${sortedEntries.filter(e => !e.isDirectory).length} files`);
const allDirPaths = new Set<string>();
for (const entry of sortedEntries) {
if (entry.isDirectory) {
allDirPaths.add(joinPath(targetPath, entry.relativePath));
} else {
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
let parentPath = targetPath;
for (let i = 0; i < pathParts.length - 1; i++) {
parentPath = joinPath(parentPath, pathParts[i]);
allDirPaths.add(parentPath);
}
}
}
}
// Create directories in sorted order (parents before children) with limited concurrency
const sortedDirPaths = Array.from(allDirPaths).sort();
// Group by depth and create each depth level in parallel
const dirsByDepth = new Map<number, string[]>();
for (const dirPath of sortedDirPaths) {
const depth = dirPath.split('/').length;
const group = dirsByDepth.get(depth) || [];
group.push(dirPath);
dirsByDepth.set(depth, group);
}
const sortedDepths = Array.from(dirsByDepth.keys()).sort((a, b) => a - b);
for (const depth of sortedDepths) {
const dirs = dirsByDepth.get(depth)!;
await Promise.all(dirs.map(d => ensureDirectory(d)));
}
logger.debug(`[SFTP:perf] batch mkdir done — ${allDirPaths.size} dirs — ${(performance.now() - uploadT0).toFixed(0)}ms`);
let wasCancelled = false;
const yieldToMain = () => new Promise<void>(resolve => setTimeout(resolve, 0));
// Track bundled task progress
const bundleProgress = new Map<string, {
@@ -518,9 +563,11 @@ async function uploadEntries(
transferredBytes: number;
fileCount: number;
completedCount: number;
failedCount: number;
currentSpeed: number;
completedFilesBytes: number;
}>();
const pendingTaskIds = new Set<string>();
// Create bundled tasks for each root folder
const bundleTaskIds = new Map<string, string>(); // rootName -> bundleTaskId
@@ -548,24 +595,27 @@ async function uploadEntries(
transferredBytes: 0,
fileCount,
completedCount: 0,
failedCount: 0,
currentSpeed: 0,
completedFilesBytes: 0,
});
// Notify task created
if (callbacks?.onTaskCreated) {
const displayName = fileCount === 1 ? rootName : `${rootName} (${fileCount} files)`;
const displayName = rootName;
callbacks.onTaskCreated({
id: bundleTaskId,
fileName: rootName,
displayName,
isDirectory: true,
totalBytes,
progressMode: 'files',
totalBytes: fileCount,
transferredBytes: 0,
speed: 0,
fileCount,
completedCount: 0,
});
pendingTaskIds.add(bundleTaskId);
}
}
@@ -579,323 +629,303 @@ async function uploadEntries(
return null;
};
try {
for (const entry of sortedEntries) {
await yieldToMain();
// Upload a single file entry — returns result and handles progress
const uploadSingleFile = async (
entry: DropEntry,
entryTargetPath: string,
standaloneTransferId: string,
fileTotalBytes: number,
): Promise<{ cancelled?: boolean; error?: string }> => {
const localFilePath = (entry.file as File & { path?: string }).path;
if (controller?.isCancelled()) {
wasCancelled = true;
// Mark all created tasks as cancelled before breaking
for (const [, bundleTaskId] of bundleTaskIds) {
const progress = bundleProgress.get(bundleTaskId);
if (progress && progress.completedCount < progress.fileCount) {
callbacks?.onTaskCancelled?.(bundleTaskId);
}
}
break;
}
// Progress callback factory for both stream and memory paths
const makeOnProgress = () => {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const entryTargetPath = joinPath(targetPath, entry.relativePath);
const bundleTaskId = getBundleTaskId(entry);
let standaloneTransferId = "";
let fileTotalBytes = 0;
return (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
try {
if (entry.isDirectory) {
await ensureDirectory(entryTargetPath);
} else if (entry.file) {
fileTotalBytes = entry.file.size;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (!update || controller?.isCancelled() || !callbacks?.onTaskProgress) return;
// For standalone files (not in a folder), create individual task
if (!bundleTaskId) {
standaloneTransferId = crypto.randomUUID();
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: standaloneTransferId,
fileName: entry.relativePath,
displayName: entry.relativePath,
isDirectory: false,
totalBytes: fileTotalBytes,
transferredBytes: 0,
speed: 0,
fileCount: 1,
completedCount: 0,
if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
};
// Ensure parent directories exist
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
let parentPath = targetPath;
for (let i = 0; i < pathParts.length - 1; i++) {
parentPath = joinPath(parentPath, pathParts[i]);
await ensureDirectory(parentPath);
}
}
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
const onProgress = makeOnProgress();
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
// Check if file has a local path (Electron provides file.path for dropped files)
const localFilePath = (entry.file as File & { path?: string }).path;
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
// For bundled tasks, only update the current file's progress
// Don't add to completedFilesBytes until the file is fully completed
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
return { cancelled: true };
}
if (streamResult?.error) {
return { error: streamResult.error };
}
} else {
const arrayBuffer = await entry.file!.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) throw new Error("writeLocalFile not available");
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
const onProgress = makeOnProgress();
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
let result;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
() => {},
() => {}
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
if (result?.cancelled) {
return { cancelled: true };
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
const arrayBuffer = await entry.file.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't show 100% until all files are completed
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: displayPercent,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
() => {
// File upload completed successfully
},
(error) => {
// File upload failed - error is handled by the caller
void error;
}
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
return { error: "Upload failed and no fallback method available" };
}
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
return { error: "No SFTP write method available" };
}
}
}
return {};
};
// Filter to only file entries (directories are pre-created above)
const fileEntries = sortedEntries.filter(e => !e.isDirectory && e.file);
// Create standalone task entries upfront so they're visible immediately.
// Bundled child tasks are created lazily when upload actually starts, so
// large folder uploads don't flood React state before work begins.
const standaloneTaskIds = new Map<string, string>(); // relativePath -> taskId
for (const entry of fileEntries) {
const bundleTaskId = getBundleTaskId(entry);
if (!bundleTaskId) {
const taskId = crypto.randomUUID();
standaloneTaskIds.set(entry.relativePath, taskId);
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: taskId,
fileName: entry.relativePath,
displayName: entry.relativePath,
isDirectory: false,
progressMode: 'bytes',
totalBytes: entry.file!.size,
transferredBytes: 0,
speed: 0,
fileCount: 1,
completedCount: 0,
});
pendingTaskIds.add(taskId);
}
}
}
const createBundledChildTask = (entry: DropEntry, bundleTaskId: string): string => {
const taskId = crypto.randomUUID();
if (callbacks?.onTaskCreated) {
callbacks.onTaskCreated({
id: taskId,
fileName: entry.relativePath,
displayName: entry.relativePath,
isDirectory: false,
progressMode: 'bytes',
parentTaskId: bundleTaskId,
totalBytes: entry.file!.size,
transferredBytes: 0,
speed: 0,
fileCount: 1,
completedCount: 0,
});
pendingTaskIds.add(taskId);
}
return taskId;
};
const settleTask = (
taskId: string,
settle: (taskId: string) => void,
) => {
if (!taskId) return;
if (!pendingTaskIds.delete(taskId)) return;
settle(taskId);
};
const UPLOAD_CONCURRENCY = 4;
try {
let entryIndex = 0;
const worker = async () => {
while (entryIndex < fileEntries.length) {
if (controller?.isCancelled() || wasCancelled) break;
const idx = entryIndex++;
const entry = fileEntries[idx];
const entryTargetPath = joinPath(targetPath, entry.relativePath);
const bundleTaskId = getBundleTaskId(entry);
const bundledChildTaskId = bundleTaskId ? createBundledChildTask(entry, bundleTaskId) : "";
const standaloneTransferId = standaloneTaskIds.get(entry.relativePath) || "";
const fileTotalBytes = entry.file!.size;
try {
const uploadResult = await uploadSingleFile(
entry,
entryTargetPath,
bundledChildTaskId || standaloneTransferId,
fileTotalBytes,
);
if (uploadResult.cancelled) {
wasCancelled = true;
settleTask(bundledChildTaskId, (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(!bundleTaskId ? standaloneTransferId : "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
break;
}
if (uploadResult.error) {
throw new Error(uploadResult.error);
}
// File processing completed (both stream transfer and fallback paths)
controller?.clearCurrentTransfer();
results.push({ fileName: entry.relativePath, success: true });
// Update progress tracking
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (bundledChildTaskId) {
settleTask(bundledChildTaskId, (taskId) => callbacks?.onTaskCompleted?.(taskId, fileTotalBytes));
}
if (progress) {
progress.completedCount++;
progress.completedFilesBytes += fileTotalBytes;
// Set transferredBytes to completedFilesBytes to avoid double counting
progress.transferredBytes = progress.completedFilesBytes;
progress.transferredBytes = progress.completedCount;
if (progress.completedCount >= progress.fileCount) {
// All files completed - set final progress to 100% and mark as completed
callbacks?.onTaskProgress?.(bundleTaskId, {
transferred: progress.totalBytes,
total: progress.totalBytes,
transferred: progress.fileCount,
total: progress.fileCount,
speed: 0,
percent: 100,
});
// Call completion callback synchronously
callbacks?.onTaskCompleted?.(bundleTaskId, progress.totalBytes);
} else if (callbacks?.onTaskProgress) {
const percent = progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0;
// Ensure progress doesn't exceed 99.9% until all files are completed
const displayPercent = Math.min(percent, 99.9);
callbacks.onTaskProgress(bundleTaskId, {
transferred: progress.completedFilesBytes,
total: progress.totalBytes,
settleTask(bundleTaskId, (taskId) => callbacks?.onTaskCompleted?.(taskId, progress.fileCount));
} else {
callbacks?.onTaskProgress?.(bundleTaskId, {
transferred: progress.completedCount,
total: progress.fileCount,
speed: 0,
percent: displayPercent,
percent: progress.fileCount > 0 ? (progress.completedCount / progress.fileCount) * 100 : 0,
});
}
}
} else if (standaloneTransferId) {
callbacks?.onTaskCompleted?.(standaloneTransferId, fileTotalBytes);
settleTask(standaloneTransferId, (taskId) => callbacks?.onTaskCompleted?.(taskId, fileTotalBytes));
}
}
} catch (error) {
controller?.clearCurrentTransfer();
// Check if this was a cancellation
if (controller?.isCancelled()) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
} catch (error) {
if (controller?.isCancelled()) {
wasCancelled = true;
settleTask(bundledChildTaskId, (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(!bundleTaskId ? standaloneTransferId : "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
break;
}
break;
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({ fileName: entry.relativePath, success: false, error: errorMessage });
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
progress.failedCount++;
}
}
settleTask(bundledChildTaskId, (taskId) => callbacks?.onTaskFailed?.(taskId, errorMessage));
settleTask(!bundleTaskId ? standaloneTransferId : "", (taskId) => callbacks?.onTaskFailed?.(taskId, errorMessage));
}
}
};
const errorMessage = error instanceof Error ? error.message : String(error);
const workers = Array.from(
{ length: Math.min(UPLOAD_CONCURRENCY, fileEntries.length || 1) },
() => worker(),
);
await Promise.all(workers);
if (!entry.isDirectory) {
results.push({
fileName: entry.relativePath,
success: false,
error: errorMessage,
if (!wasCancelled) {
for (const [bundleTaskId, progress] of bundleProgress) {
if (progress.failedCount > 0) {
settleTask(bundleTaskId, (taskId) => {
callbacks?.onTaskFailed?.(
taskId,
progress.failedCount === progress.fileCount
? `All ${progress.fileCount} files failed`
: `${progress.failedCount} of ${progress.fileCount} files failed`,
);
});
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskFailed?.(taskId, errorMessage);
}
}
}
}
// Any error stops the entire upload - fail fast approach
// Note: We don't set wasCancelled here because this is an error, not a cancellation
break;
// Mark any remaining incomplete tasks as cancelled if upload was cancelled
if (wasCancelled) {
for (const pendingTaskId of Array.from(pendingTaskIds)) {
settleTask(pendingTaskId, (taskId) => callbacks?.onTaskCancelled?.(taskId));
}
}
} finally {
@@ -1092,6 +1122,7 @@ async function uploadFoldersCompressed(
fileName: folderName,
displayName: `${folderName} (compressed)`,
isDirectory: true,
progressMode: 'bytes',
totalBytes,
transferredBytes: 0,
speed: 0,