Markdown Preview Toggle & Tree-View Controls — Delivery Notes
markdown-preview:togglenow swaps the pane item in place instead of opening a second tab, and the tab-bar button that drives it works from both source and preview. Tree-view’s own tab-bar buttons now hide when there’s no project open. Both are small UI affordances — no ADR — but getting there took a few genuine wrong turns worth recording, plus a wrong assumption found and fixed in the pre-existing debugger smoke suite along the way. All in tranquil-config and tranquil-automations, plus tranquil-test-suite.
Status
Shipped and smoke-tested. No config or keybinding changed for the user — same shortcut, same tab-bar button, different (in-place) result.
What shipped
markdown-preview:toggleswaps the pane item in place. Stock behavior — even withmarkdown-preview.openPreviewInSplitPaneoff — is still a same-pane second tab. Intranquil-config,atom.commands.onWillDispatchintercepts the command and callsstopImmediatePropagation()before markdown-preview’s own per-grammar-selector handler runs, then swaps the editor out of the pane for aMarkdownPreviewViewat the same index (and back), viaPane#removeItem/#addItem.- The existing tab-bar “Toggle Markdown Preview” button now works from both sides. It already
existed —
tranquil-automations/lib/markdown-preview-control.js, apane-controls.jsregistration — but explicitly excluded the preview view by URI. Removed that exclusion (one line): the preview already reports the same grammar as its source editor (borrowed), which is what made the exclusion necessary in the first place; without it the button works unchanged from both sides. - Tree-view’s tab-bar buttons (New File / New Folder / Refresh / Collapse All) hide with no
project open.
pane-controls.jsgained avisiblefield on the per-item contract (a function, re-evaluated per render the same wayicon/title/classNamealready were) and a newatom.project.onDidChangePathsre-render trigger — none of the existing triggers (active-item/add-item/remove-item change) fire on a project-path change.tree-view-controls.jssetsvisible: () => atom.project.getPaths().length > 0on all four buttons. - Two new smoke suites, driving the real rendered UI rather than raw command dispatch —
markdown-preview-toggle.ts(clicks the tab-bar button through both directions, asserts the pane’s item count never changes) andtree-view-empty-project-controls.ts(removes/restores the project path, asserts the button cluster fully hides and reappears). Full coverage list in Smoke Tests. - Fixed a wrong assumption in the pre-existing
debugger-breakpoints.tssmoke suite (ADR-0025) — found while running the full suite after adding the two above, unrelated to items 1–4 but fixed in the same session. The most interesting part; see the last landmine below.
Hard parts
onWillDispatchas the universal interception point. Re-registering on markdown-preview’s own selector (atom-text-editor[data-grammar='...'], rebuilt dynamically off themarkdown-preview.grammarsconfig) to win on “last registered wins” was the first idea — rejected because it means matching an exact, changeable selector list and racing package activation order.atom.commands.onWillDispatchfires before any selector-based listener for the event, unconditionally, for every trigger path (keymap, menu, command palette) — confirmed by readingcommand-registry.js’shandleCommandEventdirectly: thewill-dispatchemit happens before the matching loop, andstopImmediatePropagation()there sets the same flags the loop checks. One call, no selector to maintain.- The
Pane#removeItem/addItemmovedflag has to be symmetric — this shipped broken once.pane.removeItem(editor, true)(moved:true, “relocated, not closed” — keepsReopen Last Itemhonest) deliberately skips telling the workspace-wideItemRegistrythe item left (seePane::removeItem/didAddPaneItem/didDestroyPaneIteminsrc/pane.jsandsrc/pane-container.js). Re-adding that same editor later with a plainpane.addItem(editor, {index})— nomoved:true— makes the registry see what looks like a duplicate and throw"The workspace can only contain one instance of item". That throw fires after the item is already spliced back into the pane’s array but before the line that destroys the old preview tab, so the visible symptom was both tabs on screen at once plus an uncaught-error toast. Fix: pass{index, moved: true}on both the remove and the re-add. - A custom “inject a button into the preview” mechanism was built, then found to be entirely
redundant. Before finding
markdown-preview-control.js, a bespoke button was stamped directly into the preview’s own DOM element — complicated byMarkdownPreviewView’s root element being its own scroll container whose children get wholesale-replaced bymorphdomon every re-render, so the button needed re-stamping on the view’sonDidChangeMarkdownevent plusposition: stickystyling to survive scrolling. All of it deleted once the existing tab-bar button (item 2 above) turned up — it already lived in the right conceptual place, a tab-bar “panel action” rather than floating in scrollable content, and just needed its stale exclusion removed. Check for an existing mechanism before building a parallel one, even when nothing points at it directly. - A live window kept showing the old broken two-tab layout after reload, well after the
underlying code was fixed. Reload is a genuine full renderer reset — the main process calls
plain
browserWindow.reload()(atom-window.js), andglobal.atomrebuilds from scratch — but it also restores the persisted workspace session (open tabs/panes), saved at unload time. A reload that happened to occur while the broken layout was showing saved that layout, and every reload after kept faithfully restoring the same snapshot regardless of how many times the code got fixed in between. A full app restart only “fixed” it by coincidentally not restoring the previous windows, not because a restart does anything a reload doesn’t. When a fix doesn’t seem to “take” after a reload, test in a fresh New Window rather than reaching for a restart — a stale saved layout is a much likelier explanation. - The debugger suite’s own “must stay unbound” assertion encoded a wrong mental model of what a
verified breakpoint means. The old fixture (
fixtures/debug/breakpoint-drift.ts) placed a marker inside a real function that is simply never called (unreachable()), asserting that binding a breakpoint there should fail. Empirically wrong, confirmed against a live isolated session: V8 legitimately verifies it, because the function’s body is real, compiled code the script loads regardless of whether anything ever calls it — “verified” means “V8 found real code at this location,” not “this line will execute at runtime.” Two more attempts also failed before landing on what actually works: a type-only TS construct (erased entirely by transpilation, no generated code at all) still verified, becauseSourceMap.originalToGenerated(tranquil-debug/lib/source-map.js) deliberately snaps forward to the nearest mapping at or after the requested line rather than refusing an inexact request, matching how real debuggers behave — even the file’s own last trailing comment verified, for the same reason. The only thing that stays genuinely unbound is a row with no mapping anywhere near it; the fix computes one directly in the test (source.split("\n").length + 1000, far past the fixture’s real content) rather than relying on any construct inside normal file content. Renamed the fixture’s marker and the test’s assertion message to stop implying “never executed” and say what’s actually guaranteed: no nearby source-map mapping.
Guarded by
tranquil-test-suite/smoke/suites/markdown-preview-toggle.tstranquil-test-suite/smoke/suites/tree-view-empty-project-controls.tstranquil-test-suite/smoke/suites/debugger-breakpoints.ts(existing suite, assertion corrected — see Debugger — Delivery Notes for the original build)