8.8 KiB
Remote -> Many: Multi-File Source Selection (Files Only)
Date: 2026-03-23 Status: Draft
Context
Transfers page currently supports two modes:
- Local -> Many: user selects local files (supports multiple via
<input type="file" multiple>), then uploads to many target connections. - Remote -> Many: user provides a single remote
sourcePathon a source connection, then transfers that one file to many target connections.
Current remote transfer backend only supports single-file transfer. If sourcePath is a directory, backend throws an error ("only single file transfer is supported").
Goal
Enable Remote -> Many to select and transfer multiple source files in one run.
Scope constraints:
- Files only (no directories).
- Keep existing backend APIs unchanged.
- Preserve current concurrency semantics and progress UI style.
Non-Goals
- Directory transfer (recursive) or directory selection.
- Server-side batch API (single request for multiple source paths).
- Change Local -> Many behavior.
Current Implementation (Relevant)
-
UI:
frontend/src/views/TransfersView.vue- Remote -> Many uses
remoteSourceConnectionId+remoteSourcePath+remoteTargetDirOrPath. - Uses
SftpFilePickerModalto pick a single remote path.
- Remote -> Many uses
-
Store:
frontend/src/stores/transfers.tsstartRemoteToMany({ sourceConnectionId, sourcePath, targetConnectionIds, targetDirOrPath, concurrency })- For each target connection creates a backend task via
createRemoteTransferTask(...)and waits via SSE (subscribeRemoteTransferProgress).
-
Backend:
backend/src/main/java/com/sshmanager/controller/SftpController.java/sftp/transfer-remote/taskscreates a single transfer task.
-
Backend service:
backend/src/main/java/com/sshmanager/service/SftpService.javatransferRemote(...)validatessourcePathis not a directory.
Proposed Approach (Recommended)
Front-end only changes:
- Remote -> Many supports selecting multiple
sourcePathvalues. - For each
(sourcePath, targetConnectionId)pair, create one backend transfer task using existing/sftp/transfer-remote/tasks. - Reuse current concurrency limiter (
runWithConcurrency) across all items.
This is consistent with how Local -> Many expands files x targets into a queue of transfer items.
UX / UI Changes
TransfersView Remote -> Many
-
Replace single path field with a multi-selection model:
- From:
remoteSourcePath: string - To:
remoteSourcePaths: string[]
- From:
-
Display:
- A compact list of selected files (basename + full path tooltip) with remove buttons.
- "Clear" action to empty the list.
- Primary picker button opens the remote picker.
-
Add target path mode toggle (default preserves current behavior):
Directory(default): treat input as directory; normalize to end with/; always append source filename.Exact Path(single-file only): treat input as full remote file path; do not append filename.
-
Start button enablement:
remoteSourceConnectionId != nullremoteSourcePaths.length > 0remoteSelectedTargets.length > 0
SftpFilePickerModal
Add a "multi-select files" mode:
- Directories are navigable only (click to enter), never selectable.
- Files are toggle-selectable.
- Add footer actions:
Confirm (N)emits selected file pathsCancel
- Maintain current search + hidden toggle behavior.
Modal selection UX:
- Show a small "Selected (N)" summary area in the modal (e.g. in footer) with the ability to remove a selected file by clicking an
x. - Disable
ConfirmwhenN = 0.
Deterministic output order:
select-manyemits paths in selection-time order (first selected first). If a file is unselected and re-selected, it is appended to the end.
Visual selection indicator:
- File rows show a checkbox (checked when selected). Directory rows never show a checkbox.
Selection behavior:
- Selections persist while navigating folders within the modal (select in dir A, navigate to dir B, select more).
select-manyreturns the set selected in the modal session.TransfersViewmerges returned paths intoremoteSourcePaths(append + de-duplicate), so users can open the picker multiple times to add files.
API surface change:
- Option A (preferred): extend props and emits without breaking existing callers:
- Add optional prop:
multiple?: boolean(default false) - If
multipleis false: keep existingselectsignature(path: string). - If
multipleis true: emit a new event nameselect-manywith(paths: string[]).
- Add optional prop:
Rationale: avoids touching other potential call sites and keeps types explicit.
Data Model / State
-
Keep
remoteTargetDirOrPathas the raw input string. -
Add
remoteTargetMode: 'dir' | 'path'. -
On selection:
- De-duplicate paths.
- Preserve ordering: de-duplicate by full path while keeping first-seen order; new selections append in the order confirmed in the picker.
-
Source connection is part of the meaning of a path:
- When
remoteSourceConnectionIdchanges, clearremoteSourcePaths(require re-pick). - Also remove the source connection from
remoteSelectedTargetsif present.
- When
Execution Model
Transfer items
Each item represents one file transfer from source to one target:
- Label:
#<sourceId>:<sourcePath> -> #<targetId>:<targetDirOrPath> - Uses existing backend task creation and SSE progress.
Store API change:
- Update the existing store method
startRemoteToManyto acceptsourcePaths: string[]instead of a singlesourcePath. TransfersViewis the only caller; update types and call site accordingly.
Where target-path resolution lives:
- Keep the resolution logic in the store (consistent with current
buildRemoteTransferPathhelper). - Extend
startRemoteToManyparams to includetargetMode: 'dir' | 'path'. - Store computes
targetPathper item using{ targetMode, targetDirOrPath, filename }.
Target path resolution
For each sourcePath, compute filename as basename.
Rules are driven by the explicit remoteTargetMode:
-
Mode
dir(default):- Treat
remoteTargetDirOrPathas a directory. - Normalize to end with
/. - Target path = normalizedDir + filename.
- Treat
-
Mode
path(single-file only):- Treat
remoteTargetDirOrPathas an exact remote file path. - Target path =
remoteTargetDirOrPath.
- Treat
Multi-file constraint:
- When
remoteSourcePaths.length > 1, force modedir(disableExact Path).
Compatibility note:
- Keep current behavior as the default: users who previously entered a directory-like value (with or without trailing
/) will still get "append filename" semantics. Exact Pathis an explicit opt-in for single-file runs.
Examples:
- source
"/var/log/a.txt", modedir, target input"/tmp"=> target path"/tmp/a.txt" - source
"/var/log/a.txt", modedir, target input"/tmp/"=> target path"/tmp/a.txt" - source
"/var/log/a.txt", modepath, target input"/tmp/renamed.txt"=> target path"/tmp/renamed.txt"
Validation & Errors
-
UI prevents selecting directories in picker.
-
If users type paths manually, guard client-side before starting:
- Each
sourcePathmust be non-empty. - Each
sourcePathmust not end with/(heuristic directory indicator).
- Each
-
Still handle backend errors (permission denied, no such file) per item.
-
For very large runs (many items), UI should remain responsive:
- Avoid excessive reactive updates; batch updates where feasible.
-
Basename collision:
- Backend uses overwrite semantics; selecting two different files with the same basename into one target directory will overwrite.
- If multiple selected files share the same basename and
remoteTargetMode === 'dir', show a warning before starting.- Warning is non-blocking but requires explicit acknowledgement ("Continue" / "Cancel").
Observability
- Keep existing console logs for remote transfers (optional to reduce noise later).
Rollout / Compatibility
- Backend unchanged.
- Existing Remote -> Many single-file flow remains possible (select one file).
Testing Strategy
-
Frontend:
- Type-check build:
npm run build(includesvue-tsc). - Manual test:
- Select 2-3 files via picker; verify selected list and remove/clear.
- Start transfer to 2 targets; confirm item count = files x targets.
- Verify target path concatenation behavior for directory-like target.
- Error handling: select a file without permission to confirm per-item error is surfaced.
- Type-check build:
-
Backend:
- No change required.
Risks
- Large
files x targetsincreases total tasks/events and can load the client.- Simultaneous SSE connections should be bounded by the concurrency limiter.
- Must explicitly close each SSE subscription when the task reaches a terminal state (success/error/cancelled) to avoid connection buildup.
- Mitigation: cap UI selection count in future, or add a backend batch task later.
Open Questions
- None (scope fixed to files-only, front-end only).