Engineering notes

One drag gesture: every row drag is a native OS drag

Why a hidden Alt+drag export became one native drag serving the other pane, another window and Explorer, how cwdio spots its own drop, and why it only copies.

Retells ADR 0030 Published 15 August 2026 View as Markdown

Dragging a selection out of cwdio into Explorer used to be nominally possible and effectively absent. It was Alt+drag, it appeared in no menu or help text, it meant something else on Windows, it crashed the main process from a remote panel, and a plain drag out showed the no-drop cursor — which reads as “this app can’t do that”. ADR 0030 replaced it with one gesture. This is why the obvious fixes did not work and what did.

The shape of the problem

In a file manager the destination of a drag is not known at grab time. You pick up files and then decide — the other pane, a folder row, another window, Explorer. Any design that forces the choice at dragstart fights the user’s model. A modifier held at grab time is exactly such a design.

But on Windows dragstart is precisely where the choice must be made: an in-flight HTML5 drag cannot be upgraded into a native one, because Chromium is already inside its own modal drag loop. So the gesture has to commit to native or HTML5 the instant it starts, without knowing where it will end.

What was ruled out

  • A native addon calling DoDragDrop — the classic answer, dead since Electron 28: an addon cannot construct the nested-loop permission Chromium requires, so its modal loop starves mouse events.
  • drag-rs, the best current implementation, has no Node bindings and would hit the same wall in-process.
  • Chromium’s DownloadURL promise drag needs no preventDefault(), so it would leave the HTML5 drag intact — but it is single-file, and on Windows setting it clobbers the other formats, breaking the very in-app drag it was meant to preserve.
  • A pointer-driven internal drag that hands off at the window edge — the higher-fidelity end state, and still the follow-up — is a large rewrite that was not needed to make the gesture work.

The decision

There is exactly one row-drag gesture, and for anything with a real path it is a native OS drag. The controller calls webContents.startDrag at dragstart and the panel hands the gesture over. Released on the other pane, a folder row, another cwdio window, Explorer or any app that takes files, the files go there — nothing decided at grab time.

The part that makes it viable: cwdio recognises its own drag. The controller captures the dragged set at dragstart and holds it for the drag’s duration. While that set is armed, a Files drop landing on one of its own panels is routed through the internal drop path, so an in-app release keeps the full internal semantics — the guards against dropping a folder into itself or its own subtree, Shift as move, conflict prompts, undo. Only when the controller has no armed set is a drop treated as a genuine import from outside.

That needed startDrag to become a call that reports: the access policy is still enforced in the main process and a refusal has to surface, and — because startDrag does not return until the drop completes — its return is what disarms the captured set. Without it, a drag released outside the window would leave the set armed and the next real Explorer drop would be mistaken for our own.

Rows with no local path — remote, in-archive — keep Chromium’s ordinary HTML5 drag. Going native and then finding nothing to hand the OS would cancel the drag and strand the captured set; both were real defects caught in review of the first cut.

Measured, not assumed

Four platform assumptions were tested against Electron 42 with a throwaway probe: an app can receive its own startDrag back (the 2016 blocker is gone); multi-file works; cross-window works — window #1 to window #2 lands as a real file drop; and startDrag does not freeze the app: it holds the call for the drag’s duration, roughly half a second to a second in the measurements, but dragenter/dragover/drop all fire inside that window. A nested loop that keeps pumping, not a freeze.

The re-entry result is why changing your mind mid-drag recovers fully — and why cross-window drag, silently broken before because the dragged set lived per renderer, now works.

What was given up, knowingly

  • The cursor cannot show move. A native drag’s allowed effect is copy-only, and asking for more makes Chromium refuse the drop. Shift still performs a move — read at drop time — it just cannot be advertised.
  • Drag-out is copy-only, and stays that way. An accidental drag that moved files out of a file manager is the worst failure the product has; Explorer itself copies across volumes. The deliberate, keyboard-first way to move files out is Ctrl+X and paste — which is what ADR 0031 then built.
  • The drop highlight expires rather than waiting to be told. Every “the drag ended” signal proved unreliable — Chromium skips the final dragleave on a crossed panel, dragend never fires for a native drag, a drop outside the window sends nothing. Two attempts to find a terminal signal both stranded the outline. It now lapses when dragover stops repeating, which also covers foreign drags from Explorer.
  • The gesture cannot be end-to-end tested. A native drag cannot be driven by Playwright by any means: dragTo leaves a real drag in flight, and synthetic events still reach startDrag, which with no real mouse gesture sometimes returns at once and sometimes blocks the main process forever — hanging the app and the whole test worker. Three separate attempts each hung the suite. The pure decisions and the transfer engine are covered; the renderer wiring between them is not, and the ADR says so plainly.

Follow-ups named in the record: staging remote and archive rows to a temp folder so they too can leave the app, and the pointer-driven internal drag that would restore the move cursor and make the gesture testable. The user-facing summary is on the Windows integration page.

Questions

Can I drag files from cwdio into Explorer or another app?

Yes — a plain drag, no modifier. Anything with a real local path leaves as a native OS drag, single or multi-file, and lands in Explorer, another cwdio window, a mail client or any app that takes files. Remote and in-archive rows keep the ordinary in-app drag and cannot leave the app.

Why does the cursor not show 'move' when I hold Shift?

A native drag in Electron is copy-only in what it advertises, and requesting a different effect makes Chromium refuse the drop. Shift still performs a move inside cwdio — it is read at drop time — the cursor just cannot say so.

Can I move files out of cwdio by dragging?

No, and deliberately: drag-out is copy-only, because an accidental drag that moved files out of a file manager is the worst failure the product could have. To move files to another application, cut with Ctrl+X and paste there — the clipboard carries a move.