docs: 添加 superpowers 规格与计划文档
记录 Remote->Many 多文件与相关实现计划/设计,便于后续追踪与复盘。
This commit is contained in:
1399
docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md
Normal file
1399
docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md
Normal file
File diff suppressed because it is too large
Load Diff
197
docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md
Normal file
197
docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Remote -> Many Multi-File (Files Only) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Allow Transfers -> `Remote -> Many` to select multiple source files (files only) and transfer them to many targets.
|
||||
|
||||
**Architecture:** Frontend-only change. Expand `sourcePaths x targetConnectionIds` into a queue of items; each item uses existing backend `/sftp/transfer-remote/tasks` and SSE progress tracking.
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript (Vite), Pinia, Spring Boot backend unchanged.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
Modify:
|
||||
- `frontend/src/views/TransfersView.vue` (Remote -> Many UI + state)
|
||||
- `frontend/src/components/SftpFilePickerModal.vue` (add multi-select mode)
|
||||
- `frontend/src/stores/transfers.ts` (support `sourcePaths[]`, `targetMode`, improve SSE unsubscription)
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add multi-select mode to SftpFilePickerModal
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/SftpFilePickerModal.vue`
|
||||
|
||||
- [ ] **Step 1: Update component API (props + emits)**
|
||||
- Add optional prop: `multiple?: boolean` (default `false`).
|
||||
- Keep existing event: `select` for single selection.
|
||||
- Add new event: `select-many` for multi selection (payload: `string[]`).
|
||||
|
||||
- [ ] **Step 2: Add selection state and deterministic ordering**
|
||||
- Track selected paths in selection-time order (array) + a set for O(1) membership.
|
||||
- File click behavior:
|
||||
- Directory: navigate into directory.
|
||||
- File:
|
||||
- if `multiple`: toggle selection.
|
||||
- else: emit `select(path)` and close (keep existing).
|
||||
- Persist selection across directory navigation within the modal session.
|
||||
- If a file is toggled off and later toggled on again, re-add it at the end of the array (selection-time order).
|
||||
|
||||
- [ ] **Step 2.1: Add a visible selection indicator**
|
||||
- When `multiple`, render a checkbox in each file row (checked = selected).
|
||||
- Directory rows never render a checkbox.
|
||||
|
||||
- [ ] **Step 3: Add modal footer controls**
|
||||
- Add footer actions when `multiple`:
|
||||
- `Confirm (N)` (disabled when `N=0`) emits `select-many(paths)` then closes.
|
||||
- `Cancel` closes without emitting.
|
||||
- Add a "Selected (N)" compact summary area with remove (`x`) for each selected file.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Expected: build succeeds.
|
||||
- Manual:
|
||||
- Open picker, select multiple files across different directories, ensure selections persist.
|
||||
- Remove items in the "Selected" summary.
|
||||
- Confirm emits all paths in selection-time order.
|
||||
- Verify search still filters entries correctly.
|
||||
- Verify hidden-files toggle still works as before.
|
||||
|
||||
- [ ] **Step 5: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Run: `git commit -m "feat: add multi-select mode to SFTP file picker"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update TransfersView Remote -> Many UI for multi-file selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/TransfersView.vue`
|
||||
|
||||
- [ ] **Step 1: Update remote source model**
|
||||
- Replace `remoteSourcePath: string` with `remoteSourcePaths: string[]`.
|
||||
- UI:
|
||||
- Show count + list of selected paths (basename + full path tooltip).
|
||||
- Provide remove per item + clear all.
|
||||
|
||||
- [ ] **Step 1.1: Add client-side validation for typed paths**
|
||||
- Before starting Remote -> Many:
|
||||
- validate each `remoteSourcePaths[i]` is non-empty
|
||||
- validate it does not end with `/` (heuristic directory indicator)
|
||||
- If invalid: show inline error and block Start.
|
||||
|
||||
- [ ] **Step 2: Wire picker in multi mode**
|
||||
- Call `<SftpFilePickerModal :multiple="true" ... />`.
|
||||
- Listen for `select-many` and merge paths into `remoteSourcePaths`:
|
||||
- append + de-duplicate by full path while preserving first-seen order.
|
||||
|
||||
- [ ] **Step 3: Add target path mode toggle**
|
||||
- Add UI control: `Directory` (default) vs `Exact Path`.
|
||||
- Behavior:
|
||||
- `Directory` treats input as directory; normalize to end with `/`; append source filename.
|
||||
- `Exact Path` uses raw input as full file path (only allowed when exactly one source file is selected).
|
||||
- Disable `Exact Path` when `remoteSourcePaths.length > 1` (force directory mode).
|
||||
|
||||
- [ ] **Step 3.1: Update Start button enablement**
|
||||
- Require `remoteSourceConnectionId != null`.
|
||||
- Require `remoteSourcePaths.length > 0`.
|
||||
- Require `remoteSelectedTargets.length > 0`.
|
||||
- Also require validation in Step 1.1 passes.
|
||||
|
||||
- [ ] **Step 4: Add basename collision warning**
|
||||
- When `Directory` mode and `remoteSourcePaths` contains duplicate basenames:
|
||||
- show a warning dialog/banner before starting (acknowledge to proceed).
|
||||
- message mentions overwrite will happen on each selected target connection.
|
||||
|
||||
- [ ] **Step 5: Source connection change behavior**
|
||||
- When `remoteSourceConnectionId` changes:
|
||||
- clear `remoteSourcePaths`
|
||||
- remove source id from `remoteSelectedTargets` if present
|
||||
|
||||
- [ ] **Step 6: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Manual:
|
||||
- Pick 2 files, choose 2 targets -> queue shows 4 items.
|
||||
- Toggle Directory/Exact Path:
|
||||
- multi-file forces Directory
|
||||
- single-file allows Exact Path
|
||||
- Change source connection -> selected files cleared.
|
||||
|
||||
- [ ] **Step 7: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/views/TransfersView.vue frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Run: `git commit -m "feat: allow selecting multiple remote source files"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update transfers store to support multi-file Remote -> Many + safe SSE unsubscription
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/transfers.ts`
|
||||
|
||||
- [ ] **Step 1: Update store API and types**
|
||||
- Change `startRemoteToMany` params:
|
||||
- from: `sourcePath: string`
|
||||
- to: `sourcePaths: string[]`
|
||||
- Add param: `targetMode: 'dir' | 'path'`.
|
||||
|
||||
- [ ] **Step 2: Implement target path resolution in store**
|
||||
- For each `sourcePath`:
|
||||
- compute `filename` as basename.
|
||||
- if `targetMode === 'dir'`: normalize `targetDirOrPath` to end with `/` and append filename.
|
||||
- if `targetMode === 'path'`: require `sourcePaths.length === 1` and use raw `targetDirOrPath`.
|
||||
|
||||
- [ ] **Step 3: Expand items as `sourcePaths x targets`**
|
||||
- Create a `TransferItem` per pair.
|
||||
- Label includes both source path and target connection.
|
||||
- Concurrency limiter applies across the full list.
|
||||
|
||||
- [ ] **Step 4: Fix SSE subscription cleanup**
|
||||
- In `waitForRemoteTransfer` and upload-wait logic:
|
||||
- ensure the returned unsubscribe is called on terminal states (success/error/cancel).
|
||||
- still store unsubscribe in the run controller so `cancelRun` can close any active subscriptions.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Manual:
|
||||
- Start a small run and ensure completion does not leave SSE connections open (observe via browser network if needed).
|
||||
- Cancel a run and confirm subscriptions close promptly.
|
||||
|
||||
- [ ] **Step 6: Commit (optional)**
|
||||
- If doing commits:
|
||||
- Run: `git add frontend/src/stores/transfers.ts`
|
||||
- Run: `git commit -m "fix: close SSE subscriptions for transfer tasks"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: End-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `frontend/src/views/TransfersView.vue`
|
||||
- Verify: `frontend/src/components/SftpFilePickerModal.vue`
|
||||
- Verify: `frontend/src/stores/transfers.ts`
|
||||
|
||||
- [ ] **Step 1: Frontend build**
|
||||
- Run: `npm --prefix frontend run build`
|
||||
- Expected: success.
|
||||
|
||||
- [ ] **Step 2: Smoke test (manual)**
|
||||
- Start app (if needed):
|
||||
- Backend: `mvn -f backend/pom.xml spring-boot:run`
|
||||
- Frontend: `npm --prefix frontend run dev`
|
||||
- If deps aren't installed:
|
||||
- Frontend: `npm --prefix frontend install`
|
||||
- Use Transfers:
|
||||
- Remote -> Many: pick 2-3 files, 2 targets, Directory mode.
|
||||
- Verify files arrive at target directory with correct names.
|
||||
- Verify errors are per-item, run continues for other items.
|
||||
|
||||
- [ ] **Step 3: Commit (optional)**
|
||||
- If doing a final commit:
|
||||
- Run: `git add frontend/src/views/TransfersView.vue frontend/src/components/SftpFilePickerModal.vue frontend/src/stores/transfers.ts`
|
||||
- Run: `git commit -m "feat: support multi-file Remote -> Many transfers"`
|
||||
@@ -0,0 +1,226 @@
|
||||
# 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 `sourcePath` on 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 `SftpFilePickerModal` to pick a single remote path.
|
||||
|
||||
- Store: `frontend/src/stores/transfers.ts`
|
||||
- `startRemoteToMany({ 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/tasks` creates a single transfer task.
|
||||
|
||||
- Backend service: `backend/src/main/java/com/sshmanager/service/SftpService.java`
|
||||
- `transferRemote(...)` validates `sourcePath` is not a directory.
|
||||
|
||||
## Proposed Approach (Recommended)
|
||||
|
||||
Front-end only changes:
|
||||
|
||||
1) Remote -> Many supports selecting multiple `sourcePath` values.
|
||||
2) For each `(sourcePath, targetConnectionId)` pair, create one backend transfer task using existing `/sftp/transfer-remote/tasks`.
|
||||
3) 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[]`
|
||||
|
||||
- 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 != null`
|
||||
- `remoteSourcePaths.length > 0`
|
||||
- `remoteSelectedTargets.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 paths
|
||||
- `Cancel`
|
||||
- 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 `Confirm` when `N = 0`.
|
||||
|
||||
Deterministic output order:
|
||||
|
||||
- `select-many` emits 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-many` returns the set selected in the modal session.
|
||||
- `TransfersView` merges returned paths into `remoteSourcePaths` (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 `multiple` is false: keep existing `select` signature `(path: string)`.
|
||||
- If `multiple` is true: emit a new event name `select-many` with `(paths: string[])`.
|
||||
|
||||
Rationale: avoids touching other potential call sites and keeps types explicit.
|
||||
|
||||
## Data Model / State
|
||||
|
||||
- Keep `remoteTargetDirOrPath` as 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 `remoteSourceConnectionId` changes, clear `remoteSourcePaths` (require re-pick).
|
||||
- Also remove the source connection from `remoteSelectedTargets` if present.
|
||||
|
||||
## 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 `startRemoteToMany` to accept `sourcePaths: string[]` instead of a single `sourcePath`.
|
||||
- `TransfersView` is the only caller; update types and call site accordingly.
|
||||
|
||||
Where target-path resolution lives:
|
||||
|
||||
- Keep the resolution logic in the store (consistent with current `buildRemoteTransferPath` helper).
|
||||
- Extend `startRemoteToMany` params to include `targetMode: 'dir' | 'path'`.
|
||||
- Store computes `targetPath` per 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 `remoteTargetDirOrPath` as a directory.
|
||||
- Normalize to end with `/`.
|
||||
- Target path = normalizedDir + filename.
|
||||
|
||||
- Mode `path` (single-file only):
|
||||
- Treat `remoteTargetDirOrPath` as an exact remote file path.
|
||||
- Target path = `remoteTargetDirOrPath`.
|
||||
|
||||
Multi-file constraint:
|
||||
|
||||
- When `remoteSourcePaths.length > 1`, force mode `dir` (disable `Exact 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 Path` is an explicit opt-in for single-file runs.
|
||||
|
||||
Examples:
|
||||
|
||||
- source `"/var/log/a.txt"`, mode `dir`, target input `"/tmp"` => target path `"/tmp/a.txt"`
|
||||
- source `"/var/log/a.txt"`, mode `dir`, target input `"/tmp/"` => target path `"/tmp/a.txt"`
|
||||
- source `"/var/log/a.txt"`, mode `path`, 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 `sourcePath` must be non-empty.
|
||||
- Each `sourcePath` must not end with `/` (heuristic directory indicator).
|
||||
- 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` (includes `vue-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.
|
||||
|
||||
- Backend:
|
||||
- No change required.
|
||||
|
||||
## Risks
|
||||
|
||||
- Large `files x targets` increases 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).
|
||||
Reference in New Issue
Block a user