Skip to content

RFC — Filebrowser Quantum Transfer Progress + Tasks Page

Created: 2026-05-14 Status: Implementing — v7 in QA validation Decision: See ADR-021 for the fork-vs-upstream rationale.

Goal

Add real-time progress feedback to all cross-filesystem copy/move operations in Filebrowser Quantum, plus a /tasks route that lists in-flight and recently-finished transfers so reloading the page or opening a second device doesn't destroy visibility.

Why this is needed

  • All four storage roots in our setup (/srv/unohana, /srv/urahara, /srv/filedump, /srv/dlbox) sit on different filesystems. Every cross-folder move triggers a byte copy that takes minutes to hours — the existing indeterminate spinner is unusable.
  • The byte copy continues server-side even if the client disconnects, but there's no way to query it. Closing the tab equals losing visibility.
  • Cloud Commander has the same problem, with its progress bar broken at 1% throughout a multi-hour copy.

Non-goals

  • Resumable transfers. If filebrowser crashes mid-copy, the partial file at the destination is left orphaned. Adding checkpoint/resume is a significant rewrite and not worth it for the rare-restart case. Interrupted transfers are surfaced as state=interrupted and the user re-initiates.
  • Server-side queue / scheduling. Transfers run on the request goroutine as before. No "queue 10 jobs and process serially" behavior. The HTTP request is the unit of work.
  • Other operation types (uploads, archive creation, search indexing) in v1. The job registry is generic enough to support them later, but only copy/move/rename are instrumented now.

Design

Data flow

Browser action (drag, Cmd+V, dialog confirm)
       ↓ PATCH /api/resources
HTTP handler  ──→  jobs.Create(jobID, owner, ...)         ── persist.go writes data/jobs.json
       ↓ for each item:                                     │
       │     fileutils.TotalSize(item)  ── pre-walk for %   │
       │     patchAction(ctx, ...)                          │
       │            ↓                                       │
       │     fileutils.CopyFileWithProgress(ctx, ..., cb)   │
       │            ↓                                       │
       │     io.Copy(&progressWriter{ctx, cb}, src)         │
       │            ↓ on each chunk:                        │
       │     cb fires (throttled 5/sec)  ──┐                │
       │                                    ├─ events.SendToUsers("copyProgress", ...)
       │                                    └─ jobs.UpdateProgress(jobID, copied, ...)
       │
       ↓ jobs.Finish(jobID, state=completed|failed|cancelled)
       ↓ persist.go writes data/jobs.json
       ↓ HTTP response

SSE clients (existing /api/events stream):
       copyProgress events  ──→  Vue dispatches window.copyProgressEvent
                                    ├──→  MoveCopy.vue prompt   (in-dialog progress)
                                    ├──→  CopyPasteConfirm.vue  (in-dialog progress)
                                    └──→  Tasks.vue              (table view live update)

Tasks page on mount:
       GET /api/jobs  →  initial table from registry  →  subscribe to SSE for updates

Backend components

Component File Responsibility
Job registry backend/jobs/registry.go sync.Map[id]*Job; Create / UpdateProgress / Finish / List / Get / Cancel
Persistence backend/jobs/persist.go Atomic-write data/jobs.json on state transitions; LoadFromDisk on startup; PersistNow on shutdown
Cleanup backend/jobs/cleanup.go Drops finished jobs older than 30 min
Counting writer backend/adapters/fs/fileutils/progress.go progressWriter wraps io.Writer; honors ctx.Done() and throttles cb invocations to ~5/sec
Copy primitives backend/adapters/fs/fileutils/file.go CopyFileWithProgress(ctx, ...) and MoveFileWithProgress(ctx, ...); existing CopyFile/MoveFile become thin wrappers passing context.Background() so other callers don't break
Resource handlers backend/adapters/fs/files/files.go CopyResourceWithProgress, MoveResourceWithProgress — same pattern
HTTP layer backend/http/resource.go resourcePatchHandler generates jobID, creates a cancellable ctx, registers the job, builds the per-item progress cb, calls patchAction, calls jobs.Finish at the end with the final state
HTTP layer backend/http/jobs.go New endpoints: list / get / cancel

Frontend components

Component File Responsibility
API client frontend/src/api/jobs.js list / get / cancel against /api/jobs
Route frontend/src/router/index.ts /tasksTasks.vue (requires auth)
Tasks view frontend/src/views/Tasks.vue Table of jobs from /api/jobs; live-updates from copyProgressEvent window events; per-job rate (5-sec moving avg), ETA, elapsed; Cancel button
SSE router frontend/src/notify/events.js New case 'copyProgress': dispatches window.CustomEvent('copyProgressEvent', { detail })
Existing dialog frontend/src/components/prompts/MoveCopy.vue "Move to..." dialog — replaces indeterminate spinner with bytes/percent/rate/ETA
Existing dialog frontend/src/components/prompts/CopyPasteConfirm.vue Cmd+C/Cmd+V dialog — same treatment
Sidebar frontend/src/components/sidebar/General.vue "Tasks" button → /tasks

SSE message format

// On job start
{
  "eventType": "copyProgress",
  "message": {
    "jobID": "f2c8a3b14d2e",
    "phase": "start",
    "action": "copy",
    "itemCount": 1
  }
}

// During copy (throttled to 5/sec)
{
  "eventType": "copyProgress",
  "message": {
    "jobID": "f2c8a3b14d2e",
    "phase": "progress",
    "itemIndex": 0,
    "itemCount": 1,
    "itemName": "/music",
    "copied": 4422052013,
    "total": 4428835203,
    "currentFile": "/srv/unohana/music/twenty one pilots/Blurryface/03 - Ride.opus"
  }
}

// On completion
{
  "eventType": "copyProgress",
  "message": {
    "jobID": "f2c8a3b14d2e",
    "phase": "done",
    "state": "completed",
    "succeeded": 1,
    "failed": 0
  }
}

Persistence format

data/jobs.json is a flat array of Job objects. Written atomically (tempfile + rename) on every state transition (Create, Finish, Cancel) — not on every progress update (high-frequency). On startup any state=running entry is rewritten to state=interrupted with error "server restarted while job was running".

Cancellation semantics

progressWriter.Write checks ctx.Err() on every chunk (default 32 KB). When DELETE /api/jobs/{id} calls cancel(), the next Write returns context.Canceled, io.Copy aborts, and the calling stack unwinds back to resourcePatchHandler which marks the job as state=cancelled. Partial files at the destination are left in place — the user can clean them up manually. (See "Non-goals".)

Validation plan

  1. v1 → v6 (already done): incremental progress bar + rate/ETA. Validated via real 4.4 GB music copy in QA. All seven progress events flowed through SSE; UI rendered correctly in Safari private window.
  2. v7 (this RFC): jobs registry + tasks page + cancel. Validation steps:
  3. Trigger a copy, navigate to /tasks, see the row with bytes climbing.
  4. Reload the browser mid-copy; row remains, progress continues.
  5. Click Cancel; row transitions to state=cancelled within ~1 second.
  6. Restart the filebrowser-qa container mid-copy; reload /tasks; row should show state=interrupted.
  7. Open /tasks from a second device (NetBird VPN); same job visible.
  8. v8 (anticipated): cleanup pass — remove debug-only paths, finalize sidebar styling, write CHANGELOG entries, prepare for either a permanent fork commit or an upstream draft PR.

Open questions

  1. Multiple concurrent jobs from one user. The progress dialog (MoveCopy / CopyPasteConfirm) uses progressJobID to filter SSE events. But two simultaneous moves would only show one in the dialog. The tasks page handles this fine. Acceptable for v7; revisit if it becomes a friction point.
  2. File-count progress. Currently we only report bytes. Pre-walking already gives us file count for free — exposing filesDone / filesTotal is ~20 lines on backend + a span on frontend. Punted to v8.
  3. Job retention. 30 minutes after finish. Long enough to debug, short enough to keep data/jobs.json small. Tune later if needed.
  4. Admin-vs-owner visibility. Currently admins see everyone's jobs. Probably fine for our single-user use case but worth a setting if we ever onboard others.

Upstream alignment

If the design holds up after a few weeks of use: 1. Open an issue on gtsteffaniak/filebrowser describing the registry approach. 2. Decompose the patch into smaller PRs: (a) CopyFileWithProgress API, (b) SSE event type, (c) frontend dialog progress, (d) jobs package + /tasks page. 3. Each PR is digestible independently. Maintainer can accept any subset.