Docs

Markdown Preview Toggle & Tree-View Controls — Delivery Notes

markdown-preview:toggle now 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

  1. markdown-preview:toggle swaps the pane item in place. Stock behavior — even with markdown-preview.openPreviewInSplitPane off — is still a same-pane second tab. In tranquil-config, atom.commands.onWillDispatch intercepts the command and calls stopImmediatePropagation() before markdown-preview’s own per-grammar-selector handler runs, then swaps the editor out of the pane for a MarkdownPreviewView at the same index (and back), via Pane#removeItem/#addItem.
  2. The existing tab-bar “Toggle Markdown Preview” button now works from both sides. It already existed — tranquil-automations/lib/markdown-preview-control.js, a pane-controls.js registration — 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.
  3. Tree-view’s tab-bar buttons (New File / New Folder / Refresh / Collapse All) hide with no project open. pane-controls.js gained a visible field on the per-item contract (a function, re-evaluated per render the same way icon/title/className already were) and a new atom.project.onDidChangePaths re-render trigger — none of the existing triggers (active-item/add-item/remove-item change) fire on a project-path change. tree-view-controls.js sets visible: () => atom.project.getPaths().length > 0 on all four buttons.
  4. 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) and tree-view-empty-project-controls.ts (removes/restores the project path, asserts the button cluster fully hides and reappears). Full coverage list in Smoke Tests.
  5. Fixed a wrong assumption in the pre-existing debugger-breakpoints.ts smoke 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

  • onWillDispatch as the universal interception point. Re-registering on markdown-preview’s own selector (atom-text-editor[data-grammar='...'], rebuilt dynamically off the markdown-preview.grammars config) 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.onWillDispatch fires before any selector-based listener for the event, unconditionally, for every trigger path (keymap, menu, command palette) — confirmed by reading command-registry.js’s handleCommandEvent directly: the will-dispatch emit happens before the matching loop, and stopImmediatePropagation() there sets the same flags the loop checks. One call, no selector to maintain.
  • The Pane#removeItem/addItem moved flag has to be symmetric — this shipped broken once. pane.removeItem(editor, true) (moved:true, “relocated, not closed” — keeps Reopen Last Item honest) deliberately skips telling the workspace-wide ItemRegistry the item left (see Pane::removeItem / didAddPaneItem / didDestroyPaneItem in src/pane.js and src/pane-container.js). Re-adding that same editor later with a plain pane.addItem(editor, {index}) — no moved: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 by MarkdownPreviewView’s root element being its own scroll container whose children get wholesale-replaced by morphdom on every re-render, so the button needed re-stamping on the view’s onDidChangeMarkdown event plus position: sticky styling 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), and global.atom rebuilds 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, because SourceMap.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.ts
  • tranquil-test-suite/smoke/suites/tree-view-empty-project-controls.ts
  • tranquil-test-suite/smoke/suites/debugger-breakpoints.ts (existing suite, assertion corrected — see Debugger — Delivery Notes for the original build)