Skip to content

OS-file drop — progress UI, live refresh, cancel cleanup, CMO#237

Merged
AllTerrainDeveloper merged 3 commits into
trunkfrom
progress-bar-upload-files
May 18, 2026
Merged

OS-file drop — progress UI, live refresh, cancel cleanup, CMO#237
AllTerrainDeveloper merged 3 commits into
trunkfrom
progress-bar-upload-files

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Builds on the drag-and-drop-from-desktop feature (#236) so the
upload flow has a real progress UI, refreshes the views the user is
looking at, and cleans up after itself when a user cancels.

Summary

  1. <wpd-progress-bar> component — determinate / indeterminate
    linear bar with tones, label + percent header, full CSS-variable
    theming, ARIA wiring. Documented in
    docs/examples/progress-bar.md.
  2. XHR-based uploader + new hooks — the OS-file-drop uploader
    now uses XMLHttpRequest so upload.progress is observable.
    Two new hooks ride alongside the existing ones:
    • desktop-mode.drop.upload-started{ file, fields, context, abort: () => void } fires once the request is open()ed.
    • desktop-mode.drop.upload-progress — per-tick
      { loaded, total, indeterminate }, plus a synthetic 100%
      event on upload.load so a HUD can show "wrapping up".
      BEFORE_UPLOAD / AFTER_UPLOAD / UPLOAD_FAILED semantics are
      unchanged. New UploadAbortedError distinguishes user-cancel
      from filter-cancel.
  3. Floating upload-progress HUD — pinned bottom-right panel,
    one row per file, with a <wpd-progress-bar> and a
    Cancel / Dismiss action. Auto-clears successful uploads after
    2.5s, keeps failures around until dismissed. Suppressable via
    data-desktop-mode-suppress-upload-hud on <body> so plugins
    can take the same hook surface over with their own UI.
  4. Live-refresh My WordPress → Mediamedia-list.ts
    subscribes to after-upload and prepends the new attachment to
    the visible grid via a single wp/v2/media/{id} GET. Scroll
    position and current selection are preserved.
  5. Live-refresh classic Media Library — any open iframe window
    pointing at /wp-admin/upload.php reloads when an upload
    finishes (URL preserved, so filters / pagination survive).
    Suppressable via
    data-desktop-mode-suppress-media-library-refresh.
  6. Context menu on Media tiles — right-click a tile in My
    WordPress → Media for Navigate into, Open file in new tab,
    Delete permanently. Delete confirms via wpdConfirm and
    issues DELETE wp/v2/media/{id}?force=true. Filterable via the
    existing desktop-mode.my-wordpress.tile-context-menu hook.
  7. Per-batch summary toast — multi-file uploads now end with a
    single toast that summarises only the non-zero buckets:
    Uploaded 2 files. Cancelled 1. Failed 1. Single-file batches
    keep their tight original toast (or the server error verbatim).
  8. Cancel-leaves-corrupted-media fix — if the user pressed
    Cancel after the body had been fully sent, the server still
    created the attachment. The uploader now detects that case
    (via xhr.upload.load) and DELETEs the attachment after the
    server responds, so a cancelled upload never appears in the
    Media Library, My WordPress, or anywhere else. The HUD shows
    "Cancelling…" while it waits for the server.
  9. Tile selection clipping fix — My WordPress post/page tile
    cells were 96 × 92 px around an 88 px-wide tile, leaving
    the selected tile's background ring abutting adjacent ribbons.
    Cell pitch bumped to 108 × 112 so the inset selection has
    breathing room and 2-line labels don't bleed into the next
    row.

Already-implemented (clarification)

Drops onto Gutenberg's drop zone, the classic media uploader, and
any [data-drop-zone] are passed through to those handlers by the
chromeless bridge (includes/render/chromeless-bridge.php:1197).
Gutenberg's own uploader handles upload + block insertion — the
OS-file-drop manager intentionally doesn't intercept those
surfaces. Only drops onto non-drop-zone areas escalate to the
shell's confirmation dialog.

Public API additions

JS hooks (src/os-file-drop/hooks.ts)

Hook Kind Payload
desktop-mode.drop.upload-started action { file, fields, context, abort }
desktop-mode.drop.upload-progress action { file, fields, context, loaded, total, indeterminate }

Also exposed via wp.desktop.HOOKS.FILE_DROP_UPLOAD_STARTED /
FILE_DROP_UPLOAD_PROGRESS.

Exports

  • UploadAbortedError from src/os-file-drop/upload.ts.
  • WpdProgressBar, WpdProgressTone from
    src/ui/components/wpd-progress-bar/.
  • fetchMediaItem, deleteMediaItem from
    src/my-wordpress/media-rest.ts.

Body-attribute toggles (opt-out for plugins that want to own

the UI):

  • data-desktop-mode-suppress-upload-hud
  • data-desktop-mode-suppress-media-library-refresh

Files

Area Files
New component src/ui/components/wpd-progress-bar/{ts,styles,test}
New modules src/os-file-drop/progress-hud.ts, src/os-file-drop/library-refresher.ts
Uploader src/os-file-drop/{upload,hooks,dialog,index}.ts
Media view src/my-wordpress/{media-list,media-rest}.ts, src/my-wordpress/index.ts
Styles assets/css/desktop-files.css (HUD)
Hook index src/hooks.ts, src/ui/components/index.ts
Docs docs/examples/progress-bar.md (new), docs/examples/os-file-drop.md, docs/examples/README.md, docs/hooks-reference.md, AGENTS.md
Tests tests/vitest/os-file-drop-upload.test.ts (XHR fake), src/ui/components/wpd-progress-bar/wpd-progress-bar.test.ts (new)

Test plan

  • npm run test:js — 1401/1401 pass.
  • npm run lint — clean.
  • ./node_modules/.bin/tsc --noEmit — clean.
  • npm run build — both bundles emit cleanly.
  • Manual: drop a single image on the wallpaper → dialog →
    upload → HUD shows 0–100% → toast "Uploaded to Media
    Library." → file appears in My WordPress → Media without F5.
  • Manual: drop 3 files → HUD shows three rows → Cancel one
    mid-flight → HUD row goes "Cancelling…" → "Cancelled" →
    summary toast reads "Uploaded 2 files. Cancelled 1." →
    cancelled file is not present in Media Library.
  • Manual: open upload.php in a window, drop a file on the
    wallpaper → after upload, the Media Library iframe reloads
    with the new attachment visible.
  • Manual: right-click a media tile in My WordPress → Media →
    Delete permanently → confirm → tile disappears, count
    decrements, no F5 needed.
  • Manual: right-click → Navigate into → drills into the media
    detail view.
  • Manual: drop a file on the Gutenberg post editor — Gutenberg
    uploads and inserts the block (unchanged behavior).
Open WordPress Playground Preview

- Implemented a linear progress indicator with determinate and indeterminate modes.
- Added support for customizable tones and inline labels with percentage display.
- Created a floating upload-progress HUD to manage multiple file uploads.
- Introduced live-refresh functionality for Media Library after file uploads.
- Enhanced accessibility features with appropriate ARIA attributes.
- Added comprehensive CSS styling and theming options via CSS variables.
- Included unit tests to ensure correct rendering and behavior of the progress bar.
@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude can you review it for me?

@claude

claude Bot commented May 18, 2026

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 3m 26s —— View job


PR #237 Review — OS-file drop: progress UI, live refresh, cancel cleanup, CMO

Overall: Solid, well-structured PR. The architecture is clean, the hook design matches the established event-driven pattern, and the late-cancel-cleanup logic is genuinely clever. I found a few real bugs and some smaller code-quality items to address before merging.


Todo

  • Gather context and understand the request
  • Read core changed files
    • src/os-file-drop/upload.ts
    • src/os-file-drop/progress-hud.ts
    • src/os-file-drop/library-refresher.ts
    • src/os-file-drop/dialog.ts
    • src/os-file-drop/hooks.ts
    • src/my-wordpress/media-list.ts
    • src/my-wordpress/media-rest.ts
    • src/ui/components/wpd-progress-bar/wpd-progress-bar.ts
    • src/ui/components/wpd-progress-bar/wpd-progress-bar.styles.ts
    • src/ui/components/wpd-progress-bar/wpd-progress-bar.test.ts
    • tests/vitest/os-file-drop-upload.test.ts
  • Analyze for bugs, security issues, performance problems
  • Write and post full review

Bugs

1. onComplete filename matching is fragile for duplicate filenames — real data loss risk Fix this →

In progress-hud.ts, onComplete is triggered by AFTER_UPLOAD which doesn't carry the original File object, so it falls back to findRowByFilename:

// progress-hud.ts:224
const r = findRowByFilename( fields.filename || result.filename );
// progress-hud.ts:275
function findRowByFilename( filename: string ): HudRow | null {
    for ( const r of ROWS.values() ) {
        if ( ( r.file.name === filename || nameMatches( r, filename ) ) ) {
            return r;
        }
    }
    return null;
}

If a user drops two files with the same name (e.g. photo.jpg from two different directories — common when downloading and re-uploading), findRowByFilename returns the first match. The first upload to complete marks the second file's row as "success", leaving the first row stuck in "running" forever. The fix is to add file: File to the AFTER_UPLOAD payload in hooks.ts and upload.ts, then match by ROWS.get(payload.file) in onComplete.


2. Missing test for the late-cancel "body-fully-sent" path Fix this →

The most important new code path in this PR — the "cancel after body was already received by the server" cleanup — has no test. The 7 tests in os-file-drop-upload.test.ts cover early abort (before upload.load), but not the scenario where cancelRequested = true and bodyFullySent = true triggers a DELETE of the newly-created attachment.

At minimum, a test should verify:

  1. Calling abort() from the UPLOAD_STARTED handler when upload.load fires first does not call xhr.abort().
  2. The server response with 201 triggers a second DELETE XHR.
  3. The returned promise rejects with UploadAbortedError.

The FakeXhr stub is already capable of this — it just needs a hook to call abort() from within the upload.load handler.


Code quality / design

3. formatBytes is duplicated in dialog.ts and progress-hud.ts

Both files define their own formatBytes:

  • dialog.ts:241 — uses 1 decimal place for MB, 0 for KB
  • progress-hud.ts:360 — uses v >= 100 || i === 0 ? 0 : 1 decimal rule

Two implementations with subtly different rounding behavior. This should be a shared utility in e.g. src/os-file-drop/format-bytes.ts. Fix this →

4. Sequential upload loop is intentional but worth documenting

// dialog.ts:148
for ( let i = 0; i < total; i++ ) {
    await uploadFile( ... );
}

Uploads run sequentially. For a 10-file batch where each file takes 5s, this is a 50s total upload. The HUD's XHR-based progress would show one active bar at a time. If this is intentional (simpler HUD state, avoids flooding the server), a brief comment would prevent someone from "optimizing" this to parallel later and breaking the single-active-bar UX assumption.

5. progressbar ARIA role is on the shadow-DOM track element, not the host

// wpd-progress-bar.ts:162
track.setAttribute( 'role', 'progressbar' );

The WAI-ARIA progressbar role is set on .track inside the shadow root. In some browsers / AT combos, shadow-DOM roles are not properly surfaced — the host element is what the accessibility tree sees. Consider adding role="progressbar" directly to the host (this.setAttribute(...) or via static get observedAttributes) and mirroring aria-valuemin/max/now to the host, keeping the inner element for styling only. This matches how <input> / <progress> native elements expose themselves.

6. Late-cancel DELETE failures are fully silent — consider a console warning

// upload.ts:220-233
if ( cancelRequested && data.id ) {
    void deleteAttachment( ... );
    const error = new UploadAbortedError();
    // ... reject
}
// upload.ts:293 (deleteAttachment)
cleanup.addEventListener( 'loadend', () => resolve() );

deleteAttachment resolves (never rejects) regardless of the HTTP response status. If the DELETE returns 403 or a network error, the attachment silently stays in the Media Library. The comment acknowledges this, but a console.warn on non-2xx cleanup responses would make debugging much easier for third-party developers seeing phantom attachments.

7. Context menu detail.id shape assumption

// media-list.ts:242
const detail = ( e as CustomEvent< { id: string } > ).detail;
closeAnyMediaTileMenu();
if ( detail.id === 'navigate-into' ) { ... }

The menu event is wpd-context-menu-pick. Looking at the option setup at line 234:

opt.setAttribute( 'value', o.id );

Confirm that <wpd-context-menu> emits { id } (not { value }) in its wpd-context-menu-pick detail. If the component emits { value }, then detail.id is always undefined and all three context-menu actions are silently no-ops. A quick integration test or a look at wpd-context-menu.ts would confirm.


Minor nits

  • library-refresher.ts:88 — The cross-origin fallback uses frame.getAttribute('src') (the attribute, initial URL) rather than the current contentWindow.location.href. If the user has navigated within the iframe, reload resets to the original URL instead of refreshing the current view. Given the comment says "always-same-origin in practice," this dead code path is low-risk, but the logic is semantically wrong.

  • progress-hud.ts:62–65 — The __wpdUploadHud sentinel uses window as unknown as { __wpdUploadHud?: boolean }. This is a reasonable pattern, but adding the flag to the WpGlobal or a module augmentation would make it type-safe without runtime cost.

  • dialog.ts:99 — Inline style.cssText strings throughout the dialog ('display:flex;flex-direction:column;gap:18px;...'). This is a pre-existing pattern in the codebase, but it makes theming/overriding impossible for plugins and buries intent. Low priority, but worth tracking.


What works well

  • The abort/cancel two-path design (bodyFullySent flag switching between xhr.abort() and "wait-then-DELETE") is elegantly correct.
  • UploadAbortedError vs UploadCancelledError distinction is exactly the right abstraction — callers can instanceof-check without parsing message strings.
  • The idempotent sentinel pattern (__wpdUploadHud, __wpdMediaLibraryRefresher) is consistent with the existing codebase idioms.
  • The per-render liveNs namespace in media-list.ts correctly ties the addAction to the window's lifetime and tears it down via host.addTeardown().
  • spliceNewMedia dedup check (querySelector('[data-media-id="..."]')) is correct defensive coding.
  • prefers-reduced-motion support in the progress bar styles is a nice touch.
  • window.open(..., 'noopener,noreferrer') in the file-source context menu is correctly hardened.
  • Test coverage is solid for the non-late-cancel upload scenarios.
  • Docs are kept in sync with the new hook surface — appreciated.

Summary: The critical fix needed before merge is the filename-collision bug in onComplete (item 1) — it's a real data-loss UX issue for anyone dropping two same-named files. The late-cancel test gap (item 2) is important for code confidence. Everything else can be addressed in a follow-up.

@github-actions

github-actions Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude ready for a next round!?

@claude

claude Bot commented May 18, 2026

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 3m 18s —— View job


PR #237 Review — Round 2

Todo

  • Understand the request (follow-up review pass after round 1 feedback)
  • Check what changed since round 1 (git log, diff)
  • Re-examine files that had round-1 issues
  • Write updated review

Round 1 Scorecard — All items addressed ✅

# Issue Status
1 onComplete filename matching (duplicate-name data loss) ✅ Fixed
2 Missing late-cancel "body-fully-sent" test ✅ Fixed
3 Duplicate formatBytes in dialog + HUD ✅ Fixed
4 Sequential upload loop intent undocumented ✅ Fixed
5 ARIA progressbar role only on shadow-DOM track ✅ Fixed
6 Silent late-cancel DELETE failures ✅ Fixed
7 Context-menu detail.id shape unverified ✅ Verified correct
Nit library-refresher fallback used getAttribute('src') instead of current URL ✅ Fixed

What changed — notes per item

1. File-identity matching (Bug 1)AFTER_UPLOAD now carries file: File (the filtered.file reference, not the original), and onComplete in progress-hud.ts calls ROWS.get(payload.file) directly. The hook JSDoc (hooks.ts:89-98) also documents the intent clearly so third-party subscribers know to match by identity.

2. Late-cancel test (Bug 2) — The new test at os-file-drop-upload.test.ts:294 covers all three assertions: the POST XHR is not wire-aborted, a DELETE XHR follows pointing at /wp/v2/media/42?force=true, and the promise rejects with UploadAbortedError. The FakeXhr stub already dispatched upload.load before the response, so triggering abort() from a UPLOAD_PROGRESS handler at loaded === total correctly exercises the bodyFullySent branch.

3. formatBytes (Item 3)src/os-file-drop/format-bytes.ts extracted with a single unified rounding rule. Both dialog.ts:21 and progress-hud.ts:31 import from it. The previous duplication and the subtle rounding divergence are gone.

4. Sequential loop comment (Item 4)dialog.ts:149-157 now has a block comment explaining: HUD counter reads from a single counter, parallelising would need HUD header logic revisit, and shared-host fpm worker pressure. Future maintainers won't "fix" this.

5. ARIA on host (Item 5)_syncAria() in wpd-progress-bar.ts:195 sets role="progressbar" + all aria-value* directly on this (the host). The shadow-DOM .track retains its own copy as a belt-and-suspenders for tools that inspect the shadow root. Three new tests (lines 102, 116, 125) specifically cover the host ARIA surface and the aria-label non-clobber logic. Clean.

6. Silent DELETE warnings (Item 6)deleteAttachment in upload.ts:305-336 now emits console.warn on non-2xx status, network error, and send-throws-synchronously, each with the attachment id and a plain-English "delete manually" note. Third-party developers debugging phantom attachments will now have a trace.

7. Context-menu detail.id (Item 7) — Confirmed: wpd-context-menu-option emits { id: this.dataset.menuItemId ?? this.id ?? '', value: ... }. The option setup at media-list.ts:231 sets opt.dataset.menuItemId = o.id, so detail.id correctly carries o.id. Handlers match.

Nit — library-refresher.ts cross-origin fallbackresolveIframeUrl (line 98) now reads contentWindow.location.href first and falls back to frame.src only when cross-origin throws. The soft-fallback path accurately reflects the user's current position inside the iframe rather than the initial src attribute.


One new finding

UPLOAD_FAILED uses args.file while the other three hooks use filtered.file Fix this →

In upload.ts, the five doAction(UPLOAD_FAILED, ...) calls all pass file: args.file (the original, pre-BEFORE_UPLOAD-filter File), while UPLOAD_STARTED (line 119), UPLOAD_PROGRESS (lines 127, 144), and AFTER_UPLOAD (line 243) all pass file: filtered.file. The ROWS map in progress-hud.ts is keyed by the file from UPLOAD_STARTED, which is filtered.file.

If a BEFORE_UPLOAD filter swaps the File object (filtered.file !== args.file), every failure path — network error, HTTP error, JSON parse error, early abort, and late-cancel — fires UPLOAD_FAILED with the wrong identity. The HUD's onFailed(payload.file, ...) calls ROWS.get(args.file), returns undefined, and the row stays stuck in "running" state indefinitely.

The AFTER_UPLOAD JSDoc already documents this identity requirement explicitly:

"The file field carries the same File reference that UPLOAD_STARTED / UPLOAD_PROGRESS exposed … Subscribers tracking per-file state … should match on this identity rather than the filename"

UPLOAD_FAILED should carry the same guarantee. Fix: change all five UPLOAD_FAILED payloads to file: filtered.file, and update the UPLOAD_FAILED JSDoc in hooks.ts to document the identity guarantee. Low risk in practice (no first-party code runs a BEFORE_UPLOAD filter that swaps the File), but the hook surface is public and the mismatch is a latent trap.


Overall

Everything from round 1 is resolved cleanly — the late-cancel test in particular is thorough and the ARIA overhaul includes a specific regression test for the host-level semantics. The one new finding above is low-severity but easy to fix in the same PR given the files are already open.

@AllTerrainDeveloper AllTerrainDeveloper enabled auto-merge (squash) May 18, 2026 10:15
@AllTerrainDeveloper AllTerrainDeveloper merged commit 971bc9c into trunk May 18, 2026
5 checks passed
@AllTerrainDeveloper AllTerrainDeveloper deleted the progress-bar-upload-files branch May 18, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant