Skip to content

feat(snippets): compare snippets by dragging them into the diff pane - #19

Merged
mindaugaskasp merged 10 commits into
mainfrom
feat/compare-snippets
Aug 3, 2026
Merged

feat(snippets): compare snippets by dragging them into the diff pane#19
mindaugaskasp merged 10 commits into
mainfrom
feat/compare-snippets

Conversation

@mindaugaskasp

Copy link
Copy Markdown
Owner

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 dropFiles the file path uses.

Why it is small

_place already accepted an object source and receive assigns the whole object to the side, so { path: null, name, content, snippetId } was already a valid side — the same shape paste mode produces. Routing through dropFiles therefore 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 through receive(), which already sets diffSaved = 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 calls load().

Security

  • Only the id travels. A drop target outside the app can read a 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".
  • The payload is untrusted. dragIdsFrom checks the type before reading, parses defensively, caps at 2 ids of ≤200 chars, and ids are resolved against the store.
  • Secret snippets are refused in three places — the row is not draggable, startDrag cancels, and dropSnippets refuses 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 ciphertext iv now: fresh on every re-encrypt, so it changes exactly when the content did.

Verification

  • npm run check1895 passed, coverage floors unchanged.
  • Docker e2e — compare-snippets.spec.mjs, 4 passed, including the live-edit link, driven with a real DataTransfer shared by dragstart and drop.
  • Unit: 9 (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 → .editor in 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

mindaugaskasp and others added 6 commits August 2, 2026 23:19
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>

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)dragIdsFromtransfer.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 dragenter
  • carries(e) is hasFiles(e) || false, which is false for a snippet drag
  • onDragEnter returns early → active never goes true → no overlay
  • snippetDrag is 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.
  • dropSnippets reuses dropFiles, so the replace guard and one-then-wait cannot drift.
  • onDrop was split when it crossed the complexity cap rather than the cap being raised.
  • Watcher keyed on the ciphertext iv rather 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>
diff-bro-reviewer[bot]
diff-bro-reviewer Bot previously approved these changes Aug 2, 2026

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings resolved — approving

Re-checked against the diff.

1 · protected-mode guardisSnippetDragType 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>
diff-bro-reviewer[bot]
diff-bro-reviewer Bot previously approved these changes Aug 3, 2026

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mindaugaskasp
mindaugaskasp enabled auto-merge (squash) August 3, 2026 09:28
#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>
@mindaugaskasp
mindaugaskasp merged commit 7fe0124 into main Aug 3, 2026
2 checks passed

@diff-bro-reviewer diff-bro-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mindaugaskasp
mindaugaskasp deleted the feat/compare-snippets branch August 3, 2026 12:15
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