fix: chevron language switch (highlighting + body swap + conflict-modal regression chain) - #25
Merged
Merged
Conversation
obsidianSemanticClasses ViewPlugin only recomputed cm-keyword/cm-type/etc. decorations on docChanged || viewportChanged || geometryChanged. A languageCompartment.reconfigure(...) dispatch is effects-only and trips none of those flags, so the cached OLD-language class assignments stuck to tokens until the next keystroke and Obsidian-theme rules cascaded the wrong colors. Compare update.startState.facet(language) vs update.state.facet(language) to detect parser swaps from a Compartment.reconfigure and force a re-walk of the freshly-parsed tree. Idiomatic CM6 — same predicate the @codemirror/language facet exists for. Includes regression test that mounts a real EditorView with a Python language Compartment, dispatches reconfigure(java()), and asserts the keyword decoration count changes. Test fails without the predicate fix. Closes Failure A in .planning/debug/language-switch-body-not-swapped.md. Failure B (restore v1.2 starter-snippet body swap on language change) is a separate behavior change tracked for follow-up.
The chevron-driven language switch in v1.3 only wrote `lc-language` frontmatter. Users coming from v1.2 saw the chevron + frontmatter update but their fence body stayed untouched — perceived as a regression even though it was deliberate. With Failure A landed (537f177), highlighting now follows the new parser, but the body itself still didn't swap. Restore v1.2's swap-to-starter behavior with v1.3-shaped semantics: - runLanguageSwitch (src/main/runLanguageSwitch.ts): the 16-step algorithm extracted as a pure helper so it can be unit-tested without instantiating LeetCodePlugin. switchLanguageFromWidget becomes a thin shim that binds deps and delegates. - resolveStarterCode (src/solve/resolveStarterCode.ts): cache-first then live-fetch with a {code, reason: 'ok' | 'network' | 'unavailable'} contract. Differentiates "offline" from "LC has no starter for this language on this problem" so the Notice copy can be actionable. - applyAuthoritativeBodyAndFrontmatter (src/widget/applyAuthoritativeBody.ts): the combined body+fm primitive. Arms SelfWriteSuppression with the originator's registryKey + sha1 of the new body BEFORE dispatching, fans the dispatch out to peer widgets via collectPeerWidgets, then runs processFrontMatter, then acknowledges on every widget. Throw path clears the suppression entry. - dispatchAuthoritativeBodySwap (WidgetController): single CM6 transaction carrying both `changes` (new body text) AND `effects: [Compartment.reconfigure(...)]` so body + parser swap atomically — no visible smear of new text under the old parser. Carries `userEvent: 'leetcode.lang-switch'` so the section-protection extension and DebouncedWriter recognize it as plugin-originated. - hasEverBeenDirtySinceMount latch (WidgetController): sticky bit set on first markChildDirty since mount, cleared only on reloadFromDisk or acknowledgeAuthoritativeBody. Catches "user pasted the OLD starter" so byte-equal-to-old-starter alone never triggers a swap on a widget that has been edited. UX rules: - Clean fence + new starter available -> swap body + parser atomically, fan out to peer widgets. - Clean fence + new starter unavailable -> fm-only write, Notice copy differentiated by reason ('offline' vs 'no LC starter'). - Dirty fence -> preserve user code, fm-only write, Notice with Cmd-Shift-P breadcrumb pointing at the LeetCode: Reset code command. - IME composition active -> defer the entire switch to compositionend via a one-shot listener; never reconfigure the parser mid-CJK menu. - Read-only / embed widget -> fm-only write, no body swap. Tests: - tests/widget/languageSwitch.test.ts (rewritten): 11 tests against the real runLanguageSwitch helper covering Pattern F flush-before-fm, same-slug short-circuit, read-only guard, clean swap, dirty preserve, manual-paste-of-old-starter, race-window typing during cache-miss, IME composition deferral with compositionend re-entry, network/unavailable Notice differentiation, multi-pane fan-out. - tests/widget/applyAuthoritativeBodyAndFrontmatter.test.ts: arm-before- dispatch ordering, peer fan-out, error-path suppression cleanup. - tests/solve/resolveStarterCode.test.ts: 7 reason-code branches. Suite: 2945 passed, 0 failed, 8 skipped (254 test files). Pitfalls 31-36 documented in .planning/research/PITFALLS.md. CLAUDE.md Architecture section augmented with the language-switch flow. Debug session resolved at .planning/debug/resolved/language-switch-body-not-swapped.md.
…reserve-on-dirty gate)
User feedback after dogfooding: the preserve-on-dirty path was confusing.
Once the user typed any character, switching language showed a Notice
("Cmd-Shift-P > LeetCode: Reset code...") instead of swapping the body —
they had to chain two actions to get the new starter. The v1.2 contract
was simpler: switching language always loads the new starter, full stop.
Drop the cleanness gate (childDirty + hasEverBeenDirtySinceMount + body-vs-
oldStarter comparison) and the DIRTY branch entirely. The chevron click is
a destructive action with the same semantics as Reset for the new language.
The hasEverBeenDirtySinceMount latch on WidgetController stays in place —
it's harmless when unused, and the planned per-language buffer feature
(switch-back-restores-typing) is a natural fit for it.
resolveStarterCode no longer needs to fetch the OLD starter (we never
compare against it); we resolve only the new language. extractFenceBody-
FromFullNote drops out of the LanguageSwitchDeps shape and the production
shim's import.
Tests: drop the three obsolete describe blocks (dirty branch, manual-paste-
of-old-starter, race-window typing). Remaining 9 tests still pin Pattern F
flush-before-fm, same-slug short-circuit, read-only guard, body+parser
swap, IME composition deferral with compositionend re-entry,
network/unavailable Notice differentiation, and multi-pane fan-out.
2942 passed, 0 failed, 8 skipped.
… language switch
User reported the ConflictModal ("External edit detected") firing during
the chevron language switch — the body swapped, but a moment later the
modal would pop up.
Root cause: applyAuthoritativeBodyAndFrontmatter does TWO disk writes —
(a) widget.flushNow() writes the new body, (b) processFrontMatter writes
the new lc-language frontmatter. Each fires a separate modify event in
the modify-handler. The first arm-before-dispatch consumed the body
write's echo, but the fm-write echo arrived with no entry left in the
suppression map and fell through to the conflict modal at branch (d).
The Pitfall P2 early-return (currentDocHash === observedHash) doesn't
catch this because acknowledgeAuthoritativeBody only updates
currentDocHash AFTER processFrontMatter resolves — the fm-write modify
fires while currentDocHash is still on the OLD body hash.
Fix: re-arm suppression with the same hash (the fence body is unchanged
across the fm-write — only frontmatter mutates) AFTER flushNow but
BEFORE processFrontMatter. Both modify echoes now land inside their own
suppression TTL window.
Test (7) in applyAuthoritativeBodyAndFrontmatter.test.ts pins the
re-arm contract: two arm calls in strict order arm → flushNow → arm →
processFrontMatter, both with the same hash + originator registryKey.
Updated tests (1) and the languageSwitch clean-swap + multi-pane fan-out
tests to reflect arm count = 2 instead of 1.
Suite: 2943 passed, 0 failed, 8 skipped. Build clean.
…oth modify events via Pitfall P2 User saw the ConflictModal still firing after the previous re-arm fix. Root cause: applyAuthoritativeBodyAndFrontmatter fires TWO modify events on the same path — one from widget.flushNow (body write) and one from processFrontMatter (fm rewrite). The SelfWriteSuppression map is keyed by path with single-entry-per-key semantics, so the first tryConsume drops the entry; whichever modify event lands second falls through to branch (d) and trips the conflict modal regardless of how many times we arm. Re-arming twice didn't fix it because Obsidian batches modify events into the same microtask queue and the relative ordering of the body- write echo vs the fm-write echo isn't guaranteed — whichever runs second after both arms have been consumed loses. The right gate is the modify-handler's existing Pitfall P2 early-return at src/main.ts:1352-1358 — if currentDocHash === observedHash, the fence body is unchanged from what the widget last knew about, so the modify is treated as a self-write absorption regardless of suppression map state. The fix sets currentDocHash to the post-write hash on every widget BEFORE the writes start. The fence body is unchanged across both writes (only frontmatter mutates between them) so both modify events hit P2 absorption. The single suppression.arm before dispatch stays in place — it's still the canonical signal for the body-write echo and feeds the peer-sync routing via peekOriginator. P2 is the belt-and-suspenders gate that catches the fm-write modify when the suppression map has been consumed. Test (7) in applyAuthoritativeBodyAndFrontmatter.test.ts pins the contract: currentDocHash on originator + every peer is updated to sha1(newBody) BEFORE dispatch / flushNow / processFrontMatter run. Suite: 2943 passed, 0 failed, 8 skipped.
…as silently skipping disk write) Diagnostic logs from a real switch revealed that the body was never landing on disk: lang-switch:flushNow-resolved fires 1ms after dispatch because dispatchAuthoritativeBodySwap carries userEvent 'leetcode.lang-switch' which the childDirtyExtension filters — so _childDirty stays false and DebouncedWriter.flush has nothing to write. processFrontMatter then writes only frontmatter; the modify-handler reads the OLD body off disk; observedHash doesn't match currentDocHash (304-byte new Python starter vs 828-byte old Java solution); P2 misses, suppression misses, conflict modal opens. Replace widget.flushNow() with an explicit app.vault.process call that applies rewriteFenceBody atomically. The dispatch still drives the visible CM6 swap (instant, single undo step); vault.process is now the load-bearing disk write. Also fix the hash domain so the modify-handler's gates match. The modify-handler computes observedHash via sha1(extractFenceBody(disk, fenceIndex)). The helper now hashes via the same pipeline: futureFullText = rewriteFenceBody(currentDisk, fenceIndex, newBody) futureFenceBody = extractFenceBody(futureFullText, fenceIndex) expectedHash = sha1(futureFenceBody) This matches DebouncedWriter.flush's arming exactly so any whitespace / line-ending normalization rewriteFenceBody does is reflected in our hash — closing the divergence that bypassed both the suppression match AND P2 absorption when newBody had a quirky byte boundary. Tests updated: - FakeWidget gains fenceIndex - FakeApp gains vault.read + vault.process fakes - Test (4) ordering vault.process → processFrontMatter (was flushNow → processFrontMatter) - Test (7) currentDocHash pre-update visible at vault.process site Pitfall 37: chevron switch's body+fm sequence has TWO modify events; the body write must use vault.process so the disk actually changes, and both echo hashes must match the modify-handler's pipeline. Suite: 2943 passed, 0 failed, 8 skipped.
…al hunt The instrumentation served its purpose — runtime logs identified applyAuthoritativeBodyAndFrontmatter's missing disk write and the hash- domain mismatch (commit dc58cdc fixed both). Strip the seven lang-switch:* log lines from applyAuthoritativeBody.ts, the modify:branch=p2-absorbed + modify:p2-miss + verbose mapKeys/mapHashes/mapAtModalOpen fields from src/main.ts, and the peekMapDebug accessor from selfWriteSuppression.ts. Restores the pre-debug log shape: [lc-debug] modify:enter, [lc-debug] conflict:open with a tighter payload, and the bare modify:branch=conflict-modal-open line. These existed before this debug session and remain in place.
CI lint failures: - src/main/runLanguageSwitch.ts:84 — `as string` assertion redundant after the `typeof === 'string'` narrowing on the previous line. - tests/widget/languageSwitch.test.ts:433 — eslint-plugin-obsidianmd prefers window.setTimeout over global setTimeout for popout-window compatibility (project lint rule).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
User report: clicking the language chevron showed cosmetic changes (icon +
lc-languagefrontmatter) but the fence body never updated, syntax highlighting stayed frozen on the old language, and a few iterations later the "External edit detected" ConflictModal started firing on every switch.This PR is the full fix chain. Each commit addresses a distinct root cause uncovered as the previous one revealed the next layer:
537f177fix(widget): re-render semantic classes on language-facet change —obsidianSemanticClassesViewPlugin only recomputedcm-keyword/cm-type/etc. ondocChanged || viewportChanged || geometryChanged. ACompartment.reconfigureis effects-only, so cached OLD-language classes stuck to tokens until the next keystroke. Addedupdate.startState.facet(language) !== update.state.facet(language)to the recompute predicate.ddd77d6feat(widget): restore v1.2 starter-snippet body swap on language change — v1.3 deliberately dropped v1.2's body swap; users perceived this as a regression. AddedrunLanguageSwitch(16-step helper, unit-testable),resolveStarterCode({code, reason: 'ok' | 'network' | 'unavailable'}),applyAuthoritativeBodyAndFrontmatter(atomic body+fm primitive with multi-pane fan-out),dispatchAuthoritativeBodySwaponWidgetController(single CM6 transaction: body change + parser reconfigure +userEvent: 'leetcode.lang-switch').24469b5refactor(widget): chevron language switch is always-overwrite (drop preserve-on-dirty gate) — original implementation used an instrumentation-first cleanness gate (childDirty + hasEverBeenDirtySinceMount + body-vs-oldStarter). User feedback after dogfooding: confusing — once any character was typed, switching showed a Notice instead of swapping. Dropped the gate per v1.2 semantics. Per-language buffer preservation is parked as a future enhancement.4633597+ffc592d+dc58cdc(the conflict-modal hunt) — chevron switch started tripping the ConflictModal. Workflow-driven debug session with diagnostic logging identified two real issues:Disk write was silently skipped.
dispatchAuthoritativeBodySwapcarriesuserEvent: 'leetcode.lang-switch', which thechildDirtyExtensionfilters — so_childDirtystayed false,DebouncedWriterhad nothing to flush, andwidget.flushNow()returned 1ms after dispatch without writing anything.processFrontMatterthen wrote only frontmatter; the modify-handler read the OLD body (extLen=828Java vsmineLen=304new Python) and trip the modal atmain.ts:1573.Hash domain mismatch. The helper armed suppression with
sha1(newBody)but the modify-handler computedsha1(extractFenceBody(disk, fenceIndex)). Any whitespace / line-ending normalization inrewriteFenceBodymade these diverge.Fix (
dc58cdc): replacewidget.flushNow()with explicitapp.vault.process(file, current => rewriteFenceBody(current, fenceIndex, newBody))—vault.processis now the load-bearing disk write. Hash via the same pipeline DebouncedWriter and the modify-handler use:sha1(extractFenceBody(rewriteFenceBody(disk, idx, newBody))). Both modify echoes (body write + frontmatter rewrite) now match the modify-handler's gates.6f3899echore(widget): strip [lc-debug] diagnostics — clean shipping commit, removes the 7 lang-switch:* and 2 modify-handler diagnostic log lines +peekMapDebugaccessor added during the hunt.End-to-end behavior after this PR
compositionend(no parser reconfigure mid-CJK).Test plan
npm run build— zero TS errors, esbuild bundle produced.npx vitest run— 2943 passed, 0 failed, 8 skipped across 254 test files.tests/main/childEditorSemanticClasses.languageReactivity.test.ts— pins the language-facet recompute predicate (fails without the fix).tests/widget/languageSwitch.test.ts— 9 tests against realrunLanguageSwitch(Pattern F, same-slug, read-only, body+parser swap, IME deferral, network/unavailable Notice differentiation, multi-pane fan-out).tests/widget/applyAuthoritativeBodyAndFrontmatter.test.ts— 7 helper tests covering arm-before-dispatch ordering, peer fan-out, error-path suppression cleanup, currentDocHash pre-update.tests/solve/resolveStarterCode.test.ts— 7 tests covering everyreasonbranch.