Add file uploads: push server-side files to the browsed remote folder - #16
Merged
Conversation
Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
There was a problem hiding this comment.
Pull request overview
Adds server-side uploads to complement the existing download-only workflow: users can pick local (server) files/folders and push them into the currently-browsed remote SFTP directory, reusing the existing transfer queue/progress plumbing.
Changes:
- Add
POST /api/uploadand upload expansion logic (including recursive local dir expansion with symlink skipping). - Extend transfer progress/enqueue types with a
directionfield and update UI to show upload vs download. - Add coverage for upload route behavior, direction round-trip, and recursive local listing behavior.
Show a summary per file
| File | Description |
|---|---|
| test/uploadRoute.test.ts | New tests for expandUpload (single file, directory fan-out, cleanup paths, root-cwd join). |
| test/localFs.test.ts | Adds coverage for listFilesRecursive, including symlink exclusion. |
| test/downloadManager.test.ts | Verifies direction defaulting + round-trip through list(). |
| src/web/api.ts | Adds enqueueUpload client call to /api/upload. |
| src/ui/index.css | Styles for checkbox rows and focused file-row highlighting in picker. |
| src/ui/components/TransferQueue.tsx | Adds direction icon + upload/download-aware status labels and segment display tweaks. |
| src/ui/components/RemoteBrowser.tsx | Adds an Upload button hook in the toolbar. |
| src/ui/components/FolderPicker.tsx | Adds chooseItems mode with per-row checkboxes and multi-select output. |
| src/ui/App.tsx | Wires Upload flow end-to-end; refreshes listing when uploads into current cwd complete. |
| src/shared/types.ts | Extends TransferProgress with optional direction. |
| src/shared/api.ts | Adds UploadEnqueueInput and SftpApi.enqueueUpload. |
| src/server/services.ts | Startup cleanup now also removes _ul- ephemeral remotes. |
| src/server/routes/uploads.ts | New upload route + expandUpload implementation and dataDir exclusion guard. |
| src/server/rclone/downloadManager.ts | Persists transfer direction (default download) on enqueue. |
| src/server/localFs.ts | Adds listFilesRecursive for directory uploads (skips symlinks). |
| src/server/app.ts | Registers the new upload routes. |
| README.md | Documents upload behavior and performance characteristics. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Low
Comment on lines
+133
to
+136
| writeFileSync(join(outsideRoot, 'secret.txt'), 'shh') | ||
| symlinkSync(join(outsideRoot, 'secret.txt'), join(uploadRoot, 'escape.txt')) | ||
| symlinkSync(outsideRoot, join(uploadRoot, 'escape-dir')) | ||
|
|
Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
Comment on lines
+55
to
59
| const focusRow = (index: number): void => { | ||
| const selector = mode === 'chooseItems' ? 'input[type="checkbox"]' : 'button' | ||
| const targets = listRef.current?.querySelectorAll<HTMLElement>(selector) | ||
| targets?.[index]?.focus() | ||
| } |
Comment on lines
+148
to
+152
| if (entry.isDirectory()) { | ||
| await walk(entryPath, relPath) | ||
| } else if (entry.isFile()) { | ||
| const stats = await stat(entryPath) | ||
| results.push({ relPath, size: stats.size }) |
…te in picker Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
Comment on lines
+344
to
+348
| if (enqueued.length === 0) { | ||
| setBrowseError('That folder has no files to upload.') | ||
| } else { | ||
| for (const transfer of enqueued) uploadDestRef.current.set(transfer.id, destDir) | ||
| } |
Comment on lines
+55
to
+58
| size: file.size, | ||
| segments, | ||
| direction: 'upload', | ||
| cleanupRemote: jobRemote |
Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
Comment on lines
+130
to
+140
| if ( | ||
| update.direction === 'upload' && | ||
| update.status === 'completed' && | ||
| update.uploadRemoteDir === cwdRef.current | ||
| ) { | ||
| if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) | ||
| refreshTimerRef.current = setTimeout(() => { | ||
| refreshTimerRef.current = null | ||
| navigateToRef.current(cwdRef.current) | ||
| }, 600) | ||
| } |
Comment on lines
+136
to
+159
| export async function listFilesRecursive( | ||
| _scope: FsScope, | ||
| dir: string | ||
| ): Promise<{ relPath: string; size: number }[]> { | ||
| const results: { relPath: string; size: number }[] = [] | ||
|
|
||
| async function walk(current: string, relPrefix: string): Promise<void> { | ||
| const dirents = await readdir(current, { withFileTypes: true }) | ||
| for (const entry of dirents) { | ||
| const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name | ||
| const entryPath = join(current, entry.name) | ||
| const st = await lstat(entryPath) | ||
| if (st.isSymbolicLink()) continue | ||
| if (st.isDirectory()) { | ||
| await walk(entryPath, relPath) | ||
| } else if (st.isFile()) { | ||
| results.push({ relPath, size: st.size }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| await walk(dir, '') | ||
| return results | ||
| } |
…ir walk Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
Comment on lines
+130
to
+141
| if ( | ||
| update.direction === 'upload' && | ||
| update.status === 'completed' && | ||
| update.uploadRemoteDir === cwdRef.current | ||
| ) { | ||
| const target = update.uploadRemoteDir | ||
| if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) | ||
| refreshTimerRef.current = setTimeout(() => { | ||
| refreshTimerRef.current = null | ||
| if (cwdRef.current === target) navigateToRef.current(target) | ||
| }, 600) | ||
| } |
Comment on lines
+114
to
+116
| const remoteDir = uiToRemotePath(input.remoteDir) | ||
| const jobRemote = `_ul-${randomUUID()}` | ||
| await client.cloneRemote(session.remoteName(), jobRemote) |
Comment on lines
+98
to
101
| status: 'queued', | ||
| direction: input.direction ?? 'download', | ||
| uploadRemoteDir: input.uploadRemoteDir | ||
| } |
…tion-aware error Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e
dentifrag
marked this pull request as ready for review
July 23, 2026 23:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Siphon has been download-only: rclone copies files from the remote SFTP server onto the Siphon server's local disk. This adds the reverse direction: upload files and folders that already exist on the Siphon server's disk into the remote SFTP folder you are currently browsing.
Source files come from the server's local disk (reusing the existing local file picker), not from the browser/device. The destination is the remote folder shown in the browser (its cwd). Uploads are enabled by default.
How it works
Uploads reuse the existing transfer queue, SSE progress stream, cancel/clear, and concurrency controls. The transfer manager was already direction-agnostic (
operations/copyfilewithsrcFs/dstFs), so the change is small.Backend
TransferProgress/RcloneEnqueueInputgain an optionaldirection('download'default), set inenqueue().listFilesRecursive()walks a local dir for folder uploads and does not follow symlinks, so recursion cannot escape the configured roots.POST /api/upload(src/server/routes/uploads.ts) mirrors/api/downloadper item: it requires an active session, confines the source viaresolvePath, rejects sources insidedataDir(sorclone.confand keys can't be read out), clones the session remote to a per-request_ul-<uuid>job remote, and fans out one transfer per file viaexpandUpload(). Every remote destination path is built withpath.posix.join(never string interpolation) so uploading into the remote root does not produce an absolute path._ul-remotes.Frontend
FolderPickergains achooseItemsmode (multi-select local files and folders via per-row checkboxes; folder names still navigate) alongside the unchangedchooseDirdownload-folder mode.Security and scope
DOWNLOAD_DIRSalready scopes (or the whole filesystem in open mode, same as downloads). Sources insidedataDirare refused.Testing
npm run typecheck: clean.npm test: 114/114 passing, including newtest/uploadRoute.test.ts(single file, nested dir, empty-dir cleanup, listing-failure cleanup, and thecwd='/'no-leading-slash case), alistFilesRecursivesymlink-exclusion test, and a managerdirectionround-trip test.npm run web:build: succeeds.