diff --git a/docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md b/docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md new file mode 100644 index 0000000..d295920 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-remote-multi-file-transfer.md @@ -0,0 +1,1399 @@ +# Remote -> Many 多文件传输 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:** 为 Remote -> Many 传输模式添加支持选择多个远程文件的能力,所有文件推送到统一目标目录,使用并发传输提升效率。 + +**Architecture:** 在现有单文件传输架构上扩展,前端 `SftpFilePickerModal.vue` 支持多选模式(v-model 从 string 改为 string[]),`TransfersView.vue` 和 `transfers.ts` 支持批量源文件路径。后端 `createTransferRemoteTask()` 保持单文件任务接口,前端并发创建多个任务并复用现有 SSE 进度追踪机制。 + +**Tech Stack:** +- Frontend: Vue 3 + TypeScript + Pinia +- Backend: Spring Boot 2.7 + Java 8 + JSch +- SSE (Server-Sent Events) 用于进度追踪 +- 并发控制复用 `runWithConcurrency` 工具函数 + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `frontend/src/components/SftpFilePickerModal.vue` | 支持多选模式,v-model 改为 string[],批量选择文件路径 | +| `frontend/src/views/TransfersView.vue` | `remoteSourcePath: string` → `remoteSourcePaths: string[]`,支持文件选择按钮和批量路径输入 | +| `frontend/src/stores/transfers.ts` | 新增 `startRemoteToManyMulti()` 并发处理多个源文件,复用现有 `runWithConcurrency` 和 SSE 机制 | +| `frontend/src/api/sftp.ts` | 新增 `createRemoteToManyMultiTask()` 批量创建远程传输任务(可选,可直接复用 `createRemoteTransferTask`) | +| `backend/src/main/java/com/sshmanager/controller/SftpController.java` | 新增 `createRemoteToManyMultiTask()` 支持 `sourcePaths: String[]`,每个文件创建独立任务 | +| `backend/src/main/java/com/sshmanager/service/SftpService.java` | 无改动(`transferRemote()` 已支持单文件,复用即可) | + +--- + +## 任务分解 + +### Task 0: 前端基础改造准备 + +**Files:** +- Modify: `frontend/src/components/SftpFilePickerModal.vue:1-160` +- Modify: `frontend/src/views/TransfersView.vue:34-152` + +- [ ] **Step 1: 修改 SftpFilePickerModal.vue 支持多选模式** + +```vue + + + +``` + +- [ ] **Step 2: 修改 TransfersView.vue 支持多文件源路径** + +```vue + + + +``` + +- [ ] **Step 3: 运行前端类型检查** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build +``` + +Expected: Build succeeds with no TypeScript errors in modified files. + +- [ ] **Step 4: 浏览器验证 UI 变更** + +- 手动启动前端开发服务 `npm run dev` +- 切换到 Remote -> Many 标签页 +- 点击 "浏览" 按钮,验证文件选择器支持多选(单击切换选中状态,右上角确认按钮显示选中数量) +- 选中多个文件后,验证源路径输入框显示路径列表,再次点击浏览按钮可追加选择 +- 清空按钮可清空已选择的路径 + +--- + +### Task 1: 后端新增批量任务创建 API + +**Files:** +- Modify: `backend/src/main/java/com/sshmanager/controller/SftpController.java:504-547` + +- [ ] **Step 1: 修改 SftpController.createTransferRemoteTask() 支持多源路径** + +```java +@PostMapping("/transfer-remote/batch-tasks") +public ResponseEntity> createRemoteToManyMultiTask( + @RequestParam Long sourceConnectionId, + @RequestParam String[] sourcePaths, + @RequestParam Long targetConnectionId, + @RequestParam String targetDirOrPath, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + + if (sourcePaths == null || sourcePaths.length == 0) { + Map err = new HashMap<>(); + err.put("error", "sourcePaths is required"); + return ResponseEntity.badRequest().body(err); + } + + if (sourceConnectionId == null) { + Map err = new HashMap<>(); + err.put("error", "sourceConnectionId is required"); + return ResponseEntity.badRequest().body(err); + } + + if (targetConnectionId == null) { + Map err = new HashMap<>(); + err.put("error", "targetConnectionId is required"); + return ResponseEntity.badRequest().body(err); + } + + if (targetDirOrPath == null || targetDirOrPath.trim().isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "targetDirOrPath is required"); + return ResponseEntity.badRequest().body(err); + } + + List> taskResponses = new ArrayList<>(); + + for (String sourcePath : sourcePaths) { + ResponseEntity> validation = validateTransferPaths(sourcePath, targetDirOrPath); + if (validation != null) { + Map err = new HashMap<>(); + err.putAll(validation.getBody()); + err.put("sourcePath", sourcePath); + taskResponses.add(err); + continue; + } + + String sourcePathTrimmed = sourcePath.trim(); + String filename = sourcePathTrimmed.split("/").filter(p -> !p.isEmpty()).reduce((a, b) -> b).orElse(sourcePathTrimmed); + String targetPath = targetDirOrPath.endsWith("/") ? (targetDirOrPath + filename) : targetDirOrPath; + + TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, + sourcePathTrimmed, targetPath); + status.setController(this); + String taskKey = transferTaskKey(userId, status.getTaskId()); + transferTasks.put(taskKey, status); + + Future future = transferTaskExecutor.submit(() -> { + status.setStatus("running"); + try { + if (Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + executeTransfer(userId, sourceConnectionId, sourcePathTrimmed, targetConnectionId, targetPath, status); + status.markSuccess(); + } catch (Exception e) { + if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + status.markError(toSftpErrorMessage(e, sourcePathTrimmed, "transfer")); + log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", + status.getTaskId(), sourceConnectionId, sourcePathTrimmed, targetConnectionId, targetPath, e.getMessage(), e); + } + }); + status.setFuture(future); + + taskResponses.add(status.toResponse()); + } + + Map result = new HashMap<>(); + result.put("tasks", taskResponses); + result.put("count", taskResponses.size()); + return ResponseEntity.ok(result); +} +``` + +- [ ] **Step 2: 修改 SftpController.createTransferRemoteTask() 保持单文件兼容性** + +```java +@PostMapping("/transfer-remote/tasks") +public ResponseEntity> createTransferRemoteTask( + @RequestParam Long sourceConnectionId, + @RequestParam String sourcePath, + @RequestParam Long targetConnectionId, + @RequestParam String targetPath, + Authentication authentication) { + Long userId = getCurrentUserId(authentication); + ResponseEntity> validation = validateTransferPaths(sourcePath, targetPath); + if (validation != null) { + Map err = new HashMap<>(); + err.putAll(validation.getBody()); + return ResponseEntity.status(validation.getStatusCode()).body(err); + } + + TransferTaskStatus status = new TransferTaskStatus(UUID.randomUUID().toString(), userId, sourceConnectionId, targetConnectionId, + sourcePath.trim(), targetPath.trim()); + status.setController(this); + String taskKey = transferTaskKey(userId, status.getTaskId()); + transferTasks.put(taskKey, status); + + Future future = transferTaskExecutor.submit(() -> { + status.setStatus("running"); + try { + if (Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + executeTransfer(userId, sourceConnectionId, sourcePath, targetConnectionId, targetPath, status); + status.markSuccess(); + } catch (Exception e) { + if (e instanceof InterruptedException || Thread.currentThread().isInterrupted()) { + status.markCancelled(); + return; + } + status.markError(toSftpErrorMessage(e, sourcePath, "transfer")); + log.warn("SFTP transfer task failed: taskId={}, sourceConnectionId={}, sourcePath={}, targetConnectionId={}, targetPath={}, error={}", + status.getTaskId(), sourceConnectionId, sourcePath, targetConnectionId, targetPath, e.getMessage(), e); + } + }); + status.setFuture(future); + + return ResponseEntity.ok(status.toResponse()); +} +``` + +- [ ] **Step 3: 运行后端编译测试** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn clean compile +``` + +Expected: Compilation succeeds with no errors. + +- [ ] **Step 4: 启动后端并验证 API** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend && mvn spring-boot:run +``` + +- 验证 `/api/sftp/transfer-remote/tasks` 接口(单文件)仍正常工作 +- 验证 `/api/sftp/transfer-remote/batch-tasks` 接口存在,可通过 curl 验证: + +```bash +curl -X POST "http://localhost:48080/api/sftp/transfer-remote/batch-tasks" \ + -H "Authorization: Bearer " \ + -d "sourceConnectionId=1&sourcePaths=/path/file1.txt&sourcePaths=/path/file2.txt&targetConnectionId=2&targetDirOrPath=/target/" +``` + +Expected: Response includes `tasks` array with individual task status. + +--- + +### Task 2: 前端 API 封装和 Store 改造 + +**Files:** +- Modify: `frontend/src/api/sftp.ts:226-230` +- Modify: `frontend/src/stores/transfers.ts:368-410` + +- [ ] **Step 1: 添加 createRemoteToManyMultiTask API** + +```typescript +export function createRemoteToManyMultiTask( + sourceConnectionId: number, + sourcePaths: string[], + targetConnectionId: number, + targetDirOrPath: string +) { + const params = new URLSearchParams() + params.append('sourceConnectionId', String(sourceConnectionId)) + sourcePaths.forEach((p) => params.append('sourcePaths', p)) + params.append('targetConnectionId', String(targetConnectionId)) + params.append('targetDirOrPath', targetDirOrPath) + + return client.post<{ tasks: RemoteTransferTask[]; count: number }>('/sftp/transfer-remote/batch-tasks', null, { + params, + }) +} +``` + +- [ ] **Step 2: 新增 startRemoteToManyMulti store method** + +```typescript +async function startRemoteToManyMulti(params: { + sourceConnectionId: number + sourcePaths: string[] + targetConnectionIds: number[] + targetDirOrPath: string + concurrency?: number +}) { + const { sourceConnectionId, sourcePaths, targetConnectionIds, targetDirOrPath } = params + const concurrency = params.concurrency ?? 3 + + if (sourceConnectionId == null) return + + const runId = uid('run') + const runItems: TransferItem[] = [] + + for (const sourcePath of sourcePaths) { + const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath + for (const targetId of targetConnectionIds) { + runItems.push({ + id: uid('item'), + label: `#${sourceConnectionId}:${sourcePath} -> #${targetId}:${targetDirOrPath}`, + status: 'queued' as const, + progress: 0, + }) + } + } + + const run: TransferRun = { + id: runId, + mode: 'REMOTE_TO_MANY' as const, + title: `Remote ${sourcePaths.length} files -> ${targetConnectionIds.length} targets`, + createdAt: now(), + items: runItems, + status: 'queued' as const, + } + + runs.value = [run, ...runs.value] + + let cancelled = false + const unsubscribers: (() => void)[] = [] + controllers.set(runId, { + abortAll: () => { + cancelled = true + }, + unsubscribers, + }) + + const tasks: (() => Promise)[] = sourcePaths.flatMap((sourcePath) => { + const filename = sourcePath.split('/').filter(Boolean).pop() || sourcePath + return targetConnectionIds.map((targetId, index) => { + const itemIndex = runItems.findIndex((i) => + i.label.includes(filename) && i.label.includes(`#${targetId}`) + ) + if (itemIndex === -1) return null + const item = runItems[itemIndex]! + + return async () => { + if (item.status === 'cancelled' || cancelled) return + item.status = 'running' + item.progress = 0 + item.startedAt = now() + runs.value = [...runs.value] + console.log('[Remote->Many Multi] Starting transfer:', item.label, 'targetId:', targetId) + try { + const targetPath = targetDirOrPath.endsWith('/') + ? targetDirOrPath + filename + : targetDirOrPath + + const task = await createRemoteToManyMultiTask(sourceConnectionId, [sourcePath], targetId, targetPath) + const taskId = task.data.tasks[0]?.taskId + if (!taskId) { + throw new Error('Failed to create transfer task') + } + console.log('[Remote->Many Multi] Task created:', taskId) + await waitForRemoteTransfer(taskId, (progress) => { + console.log('[Remote->Many Multi] Progress update:', progress, 'item:', item.label) + item.progress = Math.max(item.progress || 0, progress) + runs.value = [...runs.value] + }, unsubscribers) + + item.status = 'success' + item.progress = 100 + item.finishedAt = now() + console.log('[Remote->Many Multi] Transfer completed:', item.label) + runs.value = [...runs.value] + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } } + const msg = err?.response?.data?.error || (e as Error)?.message || 'Transfer failed' + console.error('[Remote->Many Multi] Transfer failed:', item.label, 'error:', msg) + if (msg === 'Cancelled') { + item.status = 'cancelled' + item.progress = 100 + } else { + item.status = 'error' + item.progress = 100 + item.message = msg + } + item.finishedAt = now() + runs.value = [...runs.value] + } finally { + runs.value = [...runs.value] + } + } + }).filter((t): t is () => Promise => t !== null) + }) + + await runWithConcurrency(tasks, concurrency) + runs.value = [...runs.value] +} +``` + +- [ ] **Step 3: 在 transfers.ts export 中添加新方法** + +```typescript +return { + runs, + recentRuns, + controllers, + clearRuns, + cancelRun, + startLocalToMany, + startRemoteToMany, + startRemoteToManyMulti, +} +``` + +- [ ] **Step 4: 运行前端类型检查** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. + +--- + +### Task 3: 端到端测试和验证 + +**Files:** +- Manual verification only + +- [ ] **Step 1: 清空数据库并重启后端** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/backend +rm -rf data/sshmanager +mvn spring-boot:run +``` + +- [ ] **Step 2: 启动前端** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager/frontend +npm run dev +``` + +- [ ] **Step 3: 创建测试连接** + +- 在 Connections 页面添加 2-3 个测试连接(例如本地 Docker 容器) + +- [ ] **Step 4: Remote -> Many 单文件传输(兼容性测试)** + +- 切换到 Remote -> Many 标签页 +- 选择源连接 +- 使用文件选择器选择单个文件(或手动输入路径) +- 选择一个目标连接 +- 点击 "开始转发" +- 验证任务创建成功,进度栏正常更新 + +- [ ] **Step 5: Remote -> Many 多文件传输(新增功能)** + +- 切换到 Remote -> Many 标签页 +- 点击 "浏览" 按钮 +- 在文件选择器中: + - 单击多个文件切换选中状态 + - 右上角 "确认 (N)" 按钮应显示选中数量 + - 选中 2-3 个文件后点击确认 +- 源路径输入框应显示类似 `/path/file1.txt, /path/file2.txt` +- 选择一个目标连接 +- 点击 "开始转发" +- 验证任务队列中创建了 `sourceFiles.length * targetConnections.length` 个子任务 +- 每个子任务的进度独立更新 +- 全部完成后状态应为 "Success" + +- [ ] **Step 6: 并发控制验证** + +- 设置并发为 1(串行) +- 选择 3 个源文件和 2 个目标连接(共 6 个子任务) +- 观察任务执行顺序,应为串行执行 +- 设置并发为 3,重复以上步骤,观察并发执行 + +- [ ] **Step 7: 错误处理验证** + +- 选择不存在的文件路径 +- 验证错误消息正确显示在对应子任务中 +- 其他子任务不受影响继续执行 + +- [ ] **Step 8: 取消任务验证** + +- 开始传输后立即点击 "取消任务" +- 验证所有未完成任务标记为 "Cancelled" +- 控制台日志显示取消信息 + +- [ ] **Step 9: 登录流程验证** + +- 退出登录 +- 重新登录 +- 创建新的传输任务 +- 验证认证流程正常 + +--- + +### Task 4: 文档和清理 + +**Files:** +- Create: `docs/REMOTE_MULTI_FILE_TRANSFER.md` + +- [ ] **Step 1: 创建功能文档** + +```markdown +# Remote -> Many 多文件传输功能说明 + +## 功能概述 +Remote -> Many 模式现在支持选择多个远程文件,所有文件推送到统一目标目录,使用并发传输提升效率。 + +## 使用方式 + +### 前端操作 +1. 切换到 Remote -> Many 标签页 +2. 选择源连接 +3. 点击 "浏览" 按钮打开文件选择器 +4. 在文件选择器中: + - 单击文件切换选中状态 + - 选择多个文件后点击右上角 "确认 (N)" 按钮 + - 或手动在源路径输入框输入路径(英文逗号分隔) +5. 选择一个或多个目标连接 +6. 设置并发数(默认 3) +7. 点击 "开始转发" + +### 并发控制 +- 并发数支持 1-6,建议 2-4 +- 并发越高越吃带宽与 CPU +- 后端是逐个调用 transfer-remote;并发适中即可 + +### 进度追踪 +- 每个文件-目标对独立显示进度 +- 悬停可查看详细错误信息 +- 支持取消所有未完成任务 + +## 技术细节 + +### API 端点 +- `POST /api/sftp/transfer-remote/batch-tasks` - 批量创建传输任务 + - `sourceConnectionId` - 源连接 ID + - `sourcePaths` - 源文件路径数组(可重复) + - `targetConnectionId` - 目标连接 ID + - `targetDirOrPath` - 目标目录或路径 + +- `GET /api/sftp/transfer-remote/tasks/{taskId}/progress` - SSE 进度追踪 + +### 并发控制 +- 前端使用 `runWithConcurrency` 工具函数 +- 每个文件-目标对创建独立任务 +- 任务状态独立追踪,互不影响 + +### 错误处理 +- 单个文件传输失败不影响其他文件 +- 错误信息显示在对应子任务中 +- 支持取消未完成任务 +``` + +- [ ] **Step 2: 运行最终构建验证** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager && docker compose -f docker/docker-compose.yml up --build -d +``` + +Expected: Docker service starts successfully with no errors. + +- [ ] **Step 3: 查看日志** + +```bash +docker compose -f docker/docker-compose.yml logs -f +``` + +Verify: No stack traces or critical errors in application logs. + +- [ ] **Step 4: 提交代码** + +```bash +cd /home/liumangmang/GiteaRepos/LiuMangMang/ssh-manager +git add -A +git commit -m "feat: add multi-file support for Remote -> Many transfer mode" +``` + +--- + +## 执行摘要 + +| 阶段 | 任务 | 预计时间 | +|------|------|----------| +| 前端基础改造 | Task 0 | 15 分钟 | +| 后端 API 扩展 | Task 1 | 10 分钟 | +| Store 改造 | Task 2 | 10 分钟 | +| 端到端测试 | Task 3 | 20 分钟 | +| 文档和清理 | Task 4 | 5 分钟 | +| **总计** | | **60 分钟** | + +--- + +## 完成检查项 + +- [ ] 前端 UI 支持多文件选择(`SftpFilePickerModal.vue` 多选模式) +- [ ] `TransfersView.vue` 支持 `remoteSourcePaths: string[]` +- [ ] `transfers.ts` 新增 `startRemoteToManyMulti()` 方法 +- [ ] 后端新增 `/api/sftp/transfer-remote/batch-tasks` 接口 +- [ ] 每个文件-目标对独立任务,共享 SSE 进度追踪 +- [ ] 并发控制复用 `runWithConcurrency` 工具函数 +- [ ] 前端类型检查通过(`npm run build`) +- [ ] 后端编译通过(`mvn clean compile`) +- [ ] Docker 构建成功,服务启动正常 +- [ ] 端到端测试通过(单文件兼容性、多文件传输、并发控制、错误处理、取消任务、登录流程) +- [ ] 功能文档已创建 +- [ ] 代码已提交 diff --git a/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md b/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md new file mode 100644 index 0000000..a143908 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-remote-to-many-multi-file.md @@ -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 ``. + - 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"` diff --git a/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md b/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md new file mode 100644 index 0000000..2b09eb1 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-remote-to-many-multi-file-design.md @@ -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 ``), 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: `#: -> #:` +- 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).