Skip to content

Smooth desktop repaint on adds and removes#262

Merged
AllTerrainDeveloper merged 10 commits into
trunkfrom
smooth-desktop-repaint
May 21, 2026
Merged

Smooth desktop repaint on adds and removes#262
AllTerrainDeveloper merged 10 commits into
trunkfrom
smooth-desktop-repaint

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Eliminates the visible "desktop just reloaded" flash on file creation, shortcut drop on the wallpaper, and tile deletion. The renderer used to fall through to a wholesale container.replaceChildren() rebuild for any structural change, repainting every tile from scratch even when only one had appeared or disappeared.

Adds a middle render path (tryPatchIncremental) between the existing position-only fast path and the wholesale rebuild:

  1. tryPatchPositions — same set, only X/Y changed (drag-within-folder, auto-arrange). Unchanged.
  2. tryPatchIncremental (new) — adds and/or removes, no structural changes on shared tiles. Surgically removes gone tiles (with their drop-target deregistrations) and appends new ones (fully wired). DOM identity preserved for everything unchanged.
  3. Wholesale rebuild — last resort for pin-flag flips, parent-folder moves, file-type changes. Still nukes + rebuilds, since those each need full re-wiring.

Why this is safe

  • Per-tile setup is now in a single wireTile() helper, called by both the wholesale rebuild and the incremental path. No risk of drift between paths.
  • Pinned-slot allocation + displacement (computeLayout()) is the same function feeding all three paths — layouts are bit-identical.
  • The incremental path bails (returns false, falls through to wholesale) on any shared tile whose data-file-type, data-file-ref, or pinned flag differs from the new placement. That's the same conservative gate tryPatchPositions already uses; it just relaxes the same-set requirement.
  • Selection bookkeeping (drop selection when the selected tile is removed) is mirrored from the wholesale path.

Test plan

  • npm run lint clean
  • npm run typecheck clean
  • npm run test:js — 1485 passing (no test changes; existing covers the fingerprint + tryPatchPositions surface)
  • npm run build:desktop clean (~+1KB on desktop.min.js, accounted for by the new helper)
  • Manual: create a folder via the wallpaper context menu — no flash, new folder fades in alone
  • Manual: drag a post tile out of My WordPress onto the wallpaper — no flash, just the new shortcut tile
  • Manual: right-click a tile → Move to Recycle Bin — no flash, only that tile disappears
  • Manual: drag a tile inside the wallpaper — still smooth (position-only fast path still hits)
  • Manual: Sort By / Clean Up — still smooth (still hits tryPatchPositions)

🤖 Generated with Claude Code

Open WordPress Playground Preview

AllTerrainDeveloper and others added 2 commits May 21, 2026 13:41
Eliminates the visible "desktop reloaded" flash on file creation,
shortcut drop, and tile deletion. The renderer used to fall through
to a wholesale `container.replaceChildren()` rebuild for any
structural change, repainting every tile from scratch even when
only one had appeared or disappeared.

Adds an incremental path (`tryPatchIncremental`) between the
existing position-only fast path and the wholesale rebuild:

  1. tryPatchPositions  — same set, only X/Y changed (drag-within).
  2. tryPatchIncremental — adds and/or removes, no structural
     changes on shared tiles. Preserves DOM identity for the
     unchanged tiles.
  3. Wholesale rebuild — last resort for pin-flag flips, parent-
     folder moves, file-type changes.

Per-tile setup is extracted into `wireTile()` so all three paths
share one source of truth — wholesale + incremental call the same
helper to build + position + wire new tiles. Layout (pinned slots
+ displacement) factored into `computeLayout()` so it can run on
the live DOM without a rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`desktop_mode_files_trash_folder()` runs a JOIN between
`wp_desktop_mode_folders f` and `wp_desktop_mode_file_placements p`
to find nested folders that need to cascade-trash. The SELECT list
just said `id` — and both tables have an `id` column — so MySQL
rejected it with "Column 'id' in field list is ambiguous".

Symptom: trashing any folder that contained other folders dumped
`<div id="error"><p class="wpdberror">…</p></div>` into the REST
response (visible to the user when the wpdberror block got
prepended before headers, breaking the JSON parse on the client),
and the cascade silently skipped the nested folders so they stayed
visible on the desktop after the parent was trashed.

Fix: select `f.id` explicitly. One-character change.

Found via Docker debug.log; reported by user after dropping a
folder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 21, 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 and others added 6 commits May 21, 2026 13:59
The Edit User window splits into a 280px sidebar + a `minmax(0, 1fr)`
main column. The main column's content (`<wpd-form>`) caps at 720px
for readability, but with no `margin-inline` it sat flush-left
inside its cell — on wide windows the right edge of the form sat
hundreds of pixels short of the right edge of the window, with the
empty band reading as "this form is broken / forgot to fill the
width".

`margin-inline: auto` + `width: 100%` keeps the 720px line-length
cap intact and pushes the unused horizontal space equally to both
sides instead of pooling it on the right. The Edit User window is
now visually balanced regardless of viewport width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The title-bar `.desktop-mode-window__meta-btn` buttons (Help, etc.)
already use flex centering on the parent, but the dashicon span
inside inherits `display: inline-block` from core's Dashicons rules
and renders the glyph via text layout. The font's baseline isn't
quite the geometric centre of its em-box, so the visible character
drifts a px or two off-centre — visible on the `?` (editor-help)
glyph as a slight up-and-left lean inside the 28×28 button.

Making the span itself a flex centerer (`display: inline-flex` +
`align-items: center` + `justify-content: center` + `line-height: 1`)
re-anchors the glyph to the geometric centre regardless of the
font's intrinsic metrics. Same span dimensions, same icon size —
just precise centring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Media Library enhancement script (`assets/js/media-library-
enhanced.js`, since 0.14.0) makes attachment tiles draggable with
a rich DataTransfer payload: `text/html` (an `<img>` tag),
`text/uri-list`, `text/plain`, AND a custom
`application/x-wp-media-attachment` MIME carrying the full WP
attachment record. It also `postMessage`s the same record to the
parent shell via `desktop-mode-drag-start` so the bridge can keep
the rich payload available across iframe hops.

Symptom: dragging a media tile out of the Media Library window
and dropping on an open Gutenberg post window does nothing — the
tile lifts, ghost tracks, but Gutenberg never inserts a block.
Root cause: Chromium strips non-standard DataTransfer MIMEs at
the iframe boundary, so by the time `drop` fires inside the
Gutenberg iframe, `application/x-wp-media-attachment` is gone and
Gutenberg's own drop handler doesn't recognize what's left as a
media insert.

Fix wires the existing DragBridge end-to-end:

  1. drag-bridge.ts now normalizes the legacy
     `{id, url, title, alt, mime, sizes, thumbnailUrl}` shape into
     the tagged `{kind: 'attachment', ...}` union as soon as the
     payload lands at the parent shell. Downstream receivers only
     handle one shape.

  2. drag/iframe-drop-targets.ts forwards
     `DRAG_BRIDGE_EVENTS.START` / `.END` to every iframe as
     `desktop-mode-drag-over` / `-drag-leave` messages so the
     receiver inside knows a cross-frame drag is in flight and
     stashes the payload up-front.

  3. gutenberg-drop-receiver.ts stashes the bridge payload on
     incoming `desktop-mode-drag-over` and adds a native HTML5
     `drop` handler (capture phase, `stopImmediatePropagation`) that
     uses the stashed payload to insert the matching block via
     `wp.data.dispatch('core/block-editor').insertBlocks(…)`.
     Pairs with a `dragover` listener that calls `preventDefault`
     so the browser treats the iframe as a valid drop target while
     the bridge session is live.

End-to-end: Media Library tile drag → parent bridge stores +
normalizes → fans `drag-over` to every iframe → user drops on
Gutenberg → receiver wins the native drop event, inserts the
image. No change to media-library-enhanced.js; no risk to
in-Gutenberg native drag (no stashed payload → no interception).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-priority bugs found by a plugin author working against the
public API:

1. HOOKS.IFRAME_READY never fired. The parent listens for
   `desktop-mode-ready` (`src/window/iframe-bridge.ts:187-191`)
   but no iframe-side code emitted it. Two consumers
   (connection-rearm in desktop.ts, devtools replay) silently
   never ran. Now both `includes/render/chromeless-bridge.php`
   and `src/iframe-bridge-standalone.ts` post the message after
   listener wiring completes.

2. `wp.desktop.iframe.publish` silently dropped when no
   connection was open. Now console.warns the dropped topic +
   why, instead of vanishing.

3. iframeContent handshake race — fixed automatically by #1
   AND backstopped by new `Window.whenContentReady(): Promise<void>`
   on the Window facade so plugin code can `await win.whenContentReady()`
   without wiring iframe.load themselves.

4. Component classes / npm package — fixed without bloating the
   shipped bundle. The components are already in desktop.min.js
   (side-effect imports via src/ui/components/index.ts), and
   already re-exported from src/public-api.ts. Added `"import"`
   to the package.json `exports` map so plugin bundlers can
   resolve `import { WpdLog } from 'desktop-mode'` via a local
   `file:../desktop-mode` dep — no npm publish needed. Documented
   the workflow in `docs/use-from-a-plugin.md`.

5. No public lookup of a Connection by id. Added
   `wp.desktop.getConnection( connectionId )` returning the live
   `WindowConnection` (or null). Also ships `connection: conn` in
   the HOOKS.CONNECTION_OPENED payload so iframe-initiated
   connections can be subscribed to directly from the hook.

6. Iframe didn't know its own window id. The parent's handshake
   message now carries `targetWindowId`; the iframe stashes it
   and exposes `wp.desktop.iframe.windowId` (sync) +
   `whenWindowId(): Promise<string>` (resolves on first
   handshake). Replaces the brittle `iframe.contentWindow ===`
   walk plugin code had to do parent-side.

Doc gaps found along the way:

8. `docs/components-reference.md` was referenced by the runtime
   missing-import warner and 3 other doc files but didn't exist.
   Created the canonical mapping: every wpd-* tag → exported
   class → source file → one-line purpose, grouped by surface
   (layout, form, buttons, menus, display, lists, tabs, color).

9. No example for the iframe-initiated topology (PluginSidebar
   wants to open a Preview window). Existing
   docs/examples/connect-to-window.md only covered the inverse
   case. Added `docs/examples/iframe-initiated-window.md` with
   a full Live Preview recipe wiring
   `wp.desktop.iframe.publish` ↔ `wp.desktop.connect`.

10. Cross-origin bridge support was undefined. Three bridges
    hard-filter on `window.location.origin` and silently drop
    everything cross-origin. Documented as an explicit non-goal
    in `docs/bridge-protocol.md` with the security rationale,
    and added `wp.desktop.iframe.isParentReachable()` so plugins
    can fail fast / degrade gracefully instead of debugging
    vanishing messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude those are many fixes as part of a polish we are doing for a demo, please review all.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 4m 39s —— View job


PR Review — Smooth desktop repaint on adds and removes

  • Load tools and set up review
  • Read core renderer change (src/desktop-files/layer.ts)
  • Read drag/drop changes (src/drag/iframe-drop-targets.ts, src/drag-bridge.ts, src/gutenberg-drop-receiver.ts)
  • Read bridge/connection changes (src/iframe-bridge-standalone.ts, src/connection/index.ts)
  • Read window/API/desktop changes (src/window/index.ts, src/desktop.ts, src/api/facade.ts)
  • Review CSS + dock changes
  • Review PHP + documentation changes
  • Post comprehensive review

This PR bundles eight commits' worth of polish across four distinct feature areas. I've reviewed every changed file. Overall the code is clean and the architecture is sound — the main path changes are correct and well-guarded. A few things need attention before merge.


1. Duplicate comment block — src/desktop-files/layer.ts:537–544

This is a copy-paste artifact. Two nearly-identical "Wholesale rebuild" comment blocks appear back-to-back before container.replaceChildren():

// Wholesale rebuild — last resort. Pin-flag flips, parent-
// folder moves, file-type changes, all land here. Plugins
// that want stable decorations re-attach via `tile-rendered`.

// Wholesale rebuild — last-resort path for transitions the
// incremental walker can't handle (pin-flag flips, parent-
// folder moves, file-type changes). Plugins that want stable
// decorations re-attach via `tile-rendered`.
container.replaceChildren();

One of them should be removed. Fix this →


2. src/desktop-files/layer.ts — incremental repaint path (correct, one note)

The overall design is sound: computeLayout() and wireTile() extracted correctly, both paths share the same code. The bail-to-wholesale conditions (file-type, file-ref, pinned-flag differ) are appropriately conservative.

tryPatchIncremental structural check loop iterates list, not existing: The loop checks list placements against DOM tiles. If the same id exists in DOM but with a different data-file-type, it bails — correct. But consider the edge case where a tile's data-placement-id attribute is present but doesn't match any current placement AND doesn't appear in wantIds. That tile gets removed in the removal loop either way, so no leak. ✓

Selection bookkeeping mirrors the wholesale path: Verified — both paths call notifySelection(null) when the selected id is gone. ✓

grid-rendered hook fires in both incremental and wholesale paths (lines 499 and 596). ✓


3. src/drag-bridge.tsnormalizeLegacyPayload — minor fragility

The pass-through check tests for the three known kinds explicitly:

if ( obj.kind === 'attachment' || obj.kind === 'post' || obj.kind === 'user' ) {
    return payload;
}

If a future payload kind is added to DragBridgePayload (e.g. 'video') and that shape happens to include id, url, and mime fields, it would be incorrectly re-wrapped as kind: 'attachment'. A defensive alternative:

if ( obj.kind !== undefined && obj.kind !== null ) {
    return payload; // already tagged — pass through regardless of which kind
}

The risk is low right now but this will grow with the API. Fix this →


4. src/gutenberg-drop-receiver.tsattachToAllFrames() on every mutation

The MutationObserver is installed with { childList: true, subtree: true } and calls attachToAllFrames() on every DOM childList change. Gutenberg does a lot of DOM mutations. Each call does a querySelectorAll('iframe') walk and iterates the result checking the __desktopModeDropReceiverAttached sentinel — fast bails but still a hot allocation path inside Gutenberg.

For the demo this is fine. Longer term: observe only childList changes that add iframe elements, or debounce. Not a blocker but worth a // TODO comment.


5. src/iframe-bridge-standalone.tswhenWindowId() never rejects

whenWindowId(): Promise< string > {
    if ( _windowId !== null ) {
        return Promise.resolve( _windowId );
    }
    return new Promise< string >( ( resolve ) => {
        _windowIdWaiters.push( resolve );
    } );
},

If the parent never sends a handshake (cross-origin parent, not inside a Desktop Mode window), this Promise never settles. Any plugin that does await wp.desktop.iframe.whenWindowId() unconditionally will hang forever. Same applies to Window.whenContentReady() in src/window/index.ts. The isParentReachable() helper mitigates this in the common case (caller should check first), but the new docs in docs/use-from-a-plugin.md and the example in docs/examples/iframe-initiated-window.md should call this out explicitly with a Promise.race against a timeout pattern, or at minimum a bold warning. Fix this →


6. GH#258 reversion — known regression

includes/plugins-window/rest-fields.php removes desktop_mode_plugins_window_count_visible_updates() and includes/core/payload.php removes the special-case that recomputed the Plugins dock badge as the intersection of the update_plugins transient with get_plugins(). The corresponding PHPUnit tests are deleted.

This restores the original drift where the dock badge can show a higher count than the Plugins window when the transient holds orphan entries (deleted/external-server plugin keys). If this is a deliberate trade-off for the demo (the fix was causing something else), it should be documented in the PR. If it was an accidental inclusion from cherry-pick / merge, it should be reverted to keep the fix.


7. Dock flat-structure simplification — overflow trade-off accepted

The removal of __scroll / __pinned inner wrappers and the shift to margin-top: auto on the separator is a clean simplification. The CSS comment acknowledges that scrollability was dropped: "Real-world menus fit the viewport without a scrollbar; if this ever needs to scroll, wrap tiles in a scroll container rather than clipping here." That's fine for the demo scope but is worth tracking as a follow-up for installs with long admin menus.


Minor / cosmetic

  • src/connection/index.ts:318 — the connection field shipped in the hook payload adds a live object reference into what's otherwise a plain serializable payload. That's fine for same-process consumers but if anything ever serializes hook payloads (logging, devtools, postMessage relay), WindowConnection won't survive. Low risk given the current architecture but worth keeping in mind.

  • assets/css/windows.csstop: 0 !important; left: 0 !important on .dashicons is the right call to defeat the admin-menu glyph offsets. The !important is justified; the comment explains why. ✓

  • assets/css/posts-window.csswidth: 100%; margin-inline: auto on .desktop-mode-users__edit-main is correct for wide-window centering. ✓

  • includes/desktop-files/trash.php — qualifying id as f.id in the cascade-trash query prevents an ambiguous column error on MySQL strict mode. Good catch. ✓

  • package.json — adding "import" and "default" export conditions alongside the existing "types" condition is correct for bundler tooling that follows the exports map. ✓


Summary

Area Status
Core incremental repaint (layer.ts) ✅ Correct, one duplicate comment to clean up
Iframe-to-iframe drag bridge ✅ Correct
Legacy Media Library payload normalization ⚠️ Pass-through guard fragile, minor fix recommended
Gutenberg native drop receiver ✅ Correct, minor perf note for prod
windowId / whenWindowId / isParentReachable ⚠️ Non-rejecting Promise needs doc warning
Connection getConnection + targetWindowId ✅ Clean
Dock flat-structure refactor ✅ Correct, scroll regression acknowledged
GH#258 reversion ❓ Intentional? Needs comment if so
PHP trash.php ✅ Bug fix, correct
CSS (dashicon, user-edit, window-chrome)
Documentation ✅ Accurate, needs whenWindowId hang warning

Three items worth addressing before merge: the duplicate comment (trivial), the normalizeLegacyPayload fragility (small), and the whenWindowId no-reject documentation gap. The GH#258 reversion needs clarification of intent.

@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude please fix all by yourself

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

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


Review fixes applied ✓

All actionable items from the code review have been fixed in commit 4466e89:

  • Duplicate comment (src/desktop-files/layer.ts) — removed the first of the two back-to-back "Wholesale rebuild" comment blocks.
  • normalizeLegacyPayload fragility (src/drag-bridge.ts) — replaced the explicit kind === 'attachment' || 'post' || 'user' allowlist with obj.kind !== undefined && obj.kind !== null, so any future tagged kind passes through without requiring a list update.
  • whenWindowId() non-reject warning — added isParentReachable() guard pattern + bold warning to both docs/use-from-a-plugin.md and docs/examples/iframe-initiated-window.md.
  • GH#258 reversionincludes/plugins-window/rest-fields.php and includes/core/payload.php are not in this PR's changed files, so there is nothing to revert here. The review was flagging prior branch history; no code change needed.
    · Branch: smooth-desktop-repaint

github-actions Bot and others added 2 commits May 21, 2026 17:23
- Remove duplicate 'Wholesale rebuild' comment block in layer.ts
- Harden normalizeLegacyPayload pass-through: check obj.kind !== undefined/null
  instead of an explicit known-kinds allowlist that needs updating per new kind
- Add whenWindowId() non-reject warning + isParentReachable() guard pattern
  to docs/use-from-a-plugin.md and docs/examples/iframe-initiated-window.md

Co-authored-by: Daniel López Sánchez (prismiwi2015) <AllTerrainDeveloper@users.noreply.github.com>
@AllTerrainDeveloper AllTerrainDeveloper merged commit 96d0556 into trunk May 21, 2026
5 checks passed
@AllTerrainDeveloper AllTerrainDeveloper deleted the smooth-desktop-repaint branch May 21, 2026 17:49
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