feat(snippets): compare snippets by dragging them into the diff pane - #19
Conversation
A snippet becomes the same side shape pasted text already produces —
{ path: null, name, content } plus the snippetId that will keep it live — so
the diff view's save, clear, export and tab handling need no special case.
Two rules live here because both are worth testing without a drag event:
a secret snippet maps to null, following the refusal image export already
makes (its mask exists so the plaintext is not on screen, and a diff pane is
the largest screen there is); and the drag payload is parsed defensively.
Only ids ever travel — never content, which a drop target we do not own could
otherwise read — and the list is capped at two ids of bounded length so a
crafted drag cannot make the app decrypt an unbounded set.
Tests written first and watched failing. Nothing is wired to the UI yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Event logic out of the SFC and into a composable, per the standards' rule for exactly this: what goes ON a drag and what is accepted OFF one are both testable without a real drag. Only the id travels. The test asserts that by stringifying the whole payload and checking the snippet's name is nowhere in it — a drop target we do not own can read this, so "we only set the id field" is not the same claim as "the body is not on the drag". A secret snippet cancels the drag outright, the same refusal snippetSource already makes, so the guard holds whether the drop arrives through the UI or not. One test failed for the right reason and the double was at fault: the DataTransfer stub snapshotted `types` at construction, so it never saw setData. A getter now, because a frozen copy would let a broken payload pass unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row is a drag source carrying its id; the window drop resolves that id, reads the content, and hands the result to the SAME dropFiles the file path uses. So one snippet fills a side and waits, two fill both, and dropping onto a complete unsaved comparison raises the existing replace guard — none of it re-implemented where it could drift. The side is shaped exactly like a pasted one, which is why save, clear, export and tabs need no special case. The comparison stays live: a side keeps its snippetId and a watcher re-reads it when the snippet changes, feeding back through receive(), which already marks the comparison unsaved. Editing a snippet therefore updates the pane instead of leaving it quietly stale, and the library is never written to — the flow only ever calls load(). One bug found and fixed while building: the watcher was keyed on `updatedAt`, a millisecond timestamp, so a snippet created and edited inside the same millisecond left the key unchanged and the pane silently stale. Keyed on the ciphertext iv now, which is fresh on every re-encrypt and so changes exactly when the content did. Secret snippets are not draggable, and the store refuses them again on arrival — the guard does not live only in the UI. npm run check: 1895 passed. Docker e2e: 4 passed, including the live-edit link, driven with a real DataTransfer shared by dragstart and drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.diffbro is not a suffix of .diffbrokey; the real point is that a dropped key and a dropped sealed diff each open their own flow rather than a comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Changes requested
The routing through dropFiles is the right call and the security posture is sound — id-only payload, three-layer secret refusal, defensive parsing. One real bug, and it is one the new e2e provably cannot catch.
🔴 1 · The drop overlay will never appear for a real snippet drag
onDragEnter decides via carries(e) → snippetIds(e) → dragIdsFrom → transfer.getData(DRAG_TYPE).
Per the HTML drag-and-drop spec, the drag data store is in protected mode during dragenter and dragover: types is readable, but getData() returns the empty string. Data only becomes readable on drop.
So in a real browser drag:
snippetIds(e)is[]on dragentercarries(e)ishasFiles(e) || false, which isfalsefor a snippet dragonDragEnterreturns early →activenever goes true → no overlaysnippetDragis never true → the new "Drop a snippet to compare" copy is dead code
The drop itself still works, because onDrop reads after protected mode ends. So the feature functions while its only affordance never appears — the user gets no indication the drag is valid.
Why the e2e misses it: compare-snippets.spec.mjs constructs its own DataTransfer and dispatches synthetic DragEvents. A synthetic event's data store is not in protected mode, so getData works there and the test passes. This is the exact case where a green e2e is not evidence.
Fix: gate dragenter/dragover on types alone — Array.from(e.dataTransfer?.types ?? []).includes(DRAG_TYPE) — and keep dragIdsFrom for drop. Worth a unit test that asserts the enter-guard never calls getData.
🟠 2 · snippetDrag is never reset
onDragLeave and onDrop clear active and depth but leave snippetDrag true. Drag a snippet out without dropping, then drag a file in, and the overlay reads "Drop a snippet to compare" for a file drag. One line in each.
✅ Otherwise verified
- Only the id travels; the test asserting it by stringifying the whole payload is the right shape of assertion.
- Secret refusal at three layers, with the store-side check meaning the guard is not UI-only.
dropSnippetsreusesdropFiles, so the replace guard and one-then-wait cannot drift.onDropwas split when it crossed the complexity cap rather than the cap being raised.- Watcher keyed on the ciphertext
ivrather than a millisecond timestamp — the right fix for the bug the description records.
Review finding 1, and one the e2e provably could not catch. During dragenter and dragover the drag data store is in PROTECTED mode: `types` is readable, but getData() returns "". The overlay guard resolved ids — so on a real drag it saw none, `carries()` was false, and the overlay never appeared. The drop still worked, because that reads after protected mode ends: the feature functioned while its only affordance was invisible. The e2e passed throughout because it dispatches synthetic DragEvents with a DataTransfer it built, and a synthetic store is not protected. The unit test added here asserts the shape rather than the outcome — its stub throws if getData is called during enter, so the guard cannot regress into reading the payload again. Finding 2: snippetDrag stayed true after a leave or a drop, so dragging a snippet out and a file in left the overlay reading "Drop a snippet to compare". Cleared in both paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Both findings resolved — approving
Re-checked against the diff.
1 · protected-mode guard — isSnippetDragType reads types only, and both carries() and snippetDrag now use it; dragIdsFrom is left for drop, where the payload is legitimately readable.
The test is the part worth noting: rather than asserting the outcome, its stub throws if getData is called during enter. That pins the shape of the fix, not just its effect — the guard cannot silently regress into reading the payload again, which is exactly how this bug would come back.
2 · snippetDrag reset — cleared in both onDragLeave (at depth 0) and onDrop.
npm run check green; Docker e2e 4 passed after the change.
Carried forward
The e2e still drives synthetic DragEvents, so it exercises the drop path but not protected mode. That is a real limit of the harness rather than a defect — Playwright cannot produce a native OS drag here — and the unit test now covers the gap it leaves. Worth remembering the next time a green drag e2e is treated as proof.
Approved.
Progress said 3/11 while every step was ticked, and six Validation lines were unanswered — the same drift that hid three Docs-impact rows on the diagram spec. Answered with facts rather than ticks: check green at 1897, Docker e2e 4 passed, docs done, token usage measured (300M processed, overwhelmingly cache read, and the window is wall-clock so it covers the diagram work too — said on the line rather than presented as this feature alone). Under the definitions now in specs/README.md that is shipped: every point implemented and confirmed, PR #19 open with the agent review resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#18 landed the CLI spec, which appended a session round-trip test to the same end of diffStore.test.js that this branch appended dropSnippets to. Both suites are kept — they test unrelated things and neither supersedes the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-approving after the merge from main
The earlier approval was dismissed by the push that resolved the conflict.
#18 landed the CLI spec, which appended a session round-trip suite to the same end of diffStore.test.js that this branch appended dropSnippets to. Both are kept — they test unrelated things and neither supersedes the other. No source file conflicted.
CI on the merged head: check pass, e2e pass (15m58s). Nothing else changed.
Approved.
#20 landed the diagram spec, whose suites append to the same end of diffStore.test.js that dropSnippets does — the third time this branch has conflicted there, and every time the same collision: two branches appending, not disagreeing. All four suites kept: dropSnippets, the two diagram ones, and the session round trip. check green at 1974; the WHOLE e2e suite run before pushing this time — 307 passed, 2 skipped — because last round a partial local run let a broken assertion through to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Re-approving — third merge from main, CI green
#20 landed while this sat open, and its diagram suites append to the same end of diffStore.test.js that dropSnippets does. Third conflict on this branch, all four of the same kind: two branches appending, never disagreeing. All four suites kept — dropSnippets, the two diagram ones, and the session round trip.
CI on this head: check pass, e2e pass.
Verified locally before pushing: npm run check 1974 passed, and the whole e2e suite — 307 passed, 2 skipped. That is deliberate: on the previous round only the feature's own spec was run locally, and CI then caught an assertion (ui-affordances pinning the format-tile count) that a partial run could never have seen.
Worth acting on rather than repeating
Three re-conflicts from one append point is a structural signal, not bad luck. The end of diffStore.test.js has become a shared target for every new feature's store tests. Either land this promptly, or give new suites their own files so they cannot collide — the conflicts carry no information and each one is a chance to resolve it wrongly.
Approved.
Spec:
specs/2026-08-02-compare-snippets/plan.md(11/11 steps).A sidebar row becomes a drag source; the window drop resolves it and hands the result to the same
dropFilesthe file path uses.Why it is small
_placealready accepted an object source andreceiveassigns the whole object to the side, so{ path: null, name, content, snippetId }was already a valid side — the same shape paste mode produces. Routing throughdropFilestherefore inherits, with no new code: one snippet fills a side and waits, two fill both, and dropping onto a complete unsaved comparison raises the existing replace guard. "Just like any ordinary diff view" needed no work at all — save, clear, export and tabs already apply.Staying live
A side keeps its
snippetId; a watcher re-reads when the snippet changes and feeds back throughreceive(), which already setsdiffSaved = false. So an edit updates the pane and correctly re-dirties the comparison rather than leaving it claiming to match what was saved. The library is never written to — the flow only ever callsload().Security
DataTransfer, so the body never goes on it. Asserted by stringifying the whole payload and checking the snippet name appears nowhere — a stronger claim than "we only set the id field".dragIdsFromchecks the type before reading, parses defensively, caps at 2 ids of ≤200 chars, and ids are resolved against the store.draggable,startDragcancels, anddropSnippetsrefuses again on arrival, so the guard is not UI-only.Bug found and fixed while building
The watcher was keyed on
updatedAt, a millisecond timestamp — so a snippet created and edited inside the same millisecond left the key unchanged and the pane silently stale, which is precisely the failure the feature exists to prevent. Keyed on the ciphertextivnow: fresh on every re-encrypt, so it changes exactly when the content did.Verification
npm run check— 1895 passed, coverage floors unchanged.compare-snippets.spec.mjs, 4 passed, including the live-edit link, driven with a realDataTransfershared bydragstartanddrop.snippetSource) + 6 (useSnippetDrag) + 5 (useSnippetDiffSync) + 6 (dropSnippets), each written first and watched failing.For the reviewer
An earlier e2e failure was traced to the test, not the code — it drove
Edit → .editorin the view dialog, but Edit opens a separate Edit Snippet dialog and a previewed snippet (claude, mermaid) renders no raw editor. Confirmed pre-existing against a stashed tree.🤖 Generated with Claude Code