Skip to content

v2.27.0: jump straight to a tab, plus five fixes - #571

Merged
adibhanna merged 8 commits into
mainfrom
v2.27.0
Aug 10, 2026
Merged

v2.27.0: jump straight to a tab, plus five fixes#571
adibhanna merged 8 commits into
mainfrom
v2.27.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Release branch for 2.27.0: one feature and five fixes, all issues closed. Per the usual flow, main fast-forwards from this branch at release time rather than merging through the button.

Feature

  • Jump straight to a tab with {count}gt and Alt+1-9 (Support direct tab navigation #497). {count}gt is absolute, matching vim's own contract (2gt is tab 2, a count past the end lands on the last tab, plain gt still cycles), while {count}gT stays relative. With vim off, nine rebindable shortcuts (tabs.select1 through 9) work from anywhere: Alt+digit cross-platform, Ctrl+digit on macOS because Option+digit types characters on many layouts.

Fixes

Each change was verified over CDP against the built app, and docs are updated in the mirrored surfaces (in-app help, shared keymaps catalog, website rows).

Opening a large vault showed an empty workspace for as long as the full
note listing took: init() and every vault-switch flow awaited
refreshVaultIndexes() before restoreWorkspaceForVault(), and the restore
validated every saved tab against the complete notes index, so the tab
strip could not appear until the whole vault had been walked.

The workspace snapshot is tiny next to the index, so the order is now
reversed by an openVaultWorkspace orchestrator shared by all eight call
sites: restore first, trusting the snapshot (sanitizeWorkspaceLayout
shape-checks paths but no longer requires them to exist), then kick off
the index refresh. Little new reconciliation is needed: refreshNotes has
pruned tabs whose notes vanished since #384, active tabs are read from
disk during restore anyway (a failed read still drops the tab), and tab
titles fall back to the filename until metadata arrives.

Two pieces deliberately wait for the index instead: folder rows join the
startup-collapsed set only once folders exist (Quick Notes and Inbox are
decided at restore time and left alone, in case the user toggled them in
the gap), and the background-tab content warm-up runs after the listing
can vouch for the paths, instead of being computed from an index that is
always empty at restore time.

Verified over CDP against the built app with a seeded 3000-note vault
and a saved workspace containing a deleted note's tab: before, tabs
painted 100ms after the listing applied; after, they paint 313ms before
it, the stale tab is pruned on arrival, and subfolders come up
collapsed. The lead grows with vault size, which is the point.
With auto-pair brackets on (the default), typing {{ in the template
editor produces {{}} with the caret in the middle. The variable
completion then replaced only the text from the opening braces to the
caret with the full {{variable}}, so the auto-paired closers survived
after it and every accepted variable ended in four braces:
{{cursor}}}}. The same leftover appeared without auto-pair when
completing inside an existing {{...}} pair, e.g. editing {{date}} down
to {{ti}} and accepting {{time}}.

The completion's inserted text carries its own closers, so applying it
now swallows up to two } characters already sitting at the caret. Two
is the cap on purpose: it is exactly what the insert provides, so a
cell of extra braces beyond the pair is left alone. The transaction
builder is exported pure (templateVariableApplySpec) in the
cm-auto-pairs style, with unit tests for the auto-pair leftover, the
existing-pair edit, a single stray closer, plain no-closer insertion,
unrelated trailing text, and the never-consume-more-than-two cap.

Verified over CDP against the built app both ways: Settings, New
template, type {{cur, accept the completion. Before: {{cursor}}}}.
After: {{cursor}}.
In visual mode with vim on, `f m` opened the editor context menu instead
of extending the selection to the next m. Worse, the orphaned f motion
stayed pending behind the menu: after Escape, the next key typed was
swallowed as the f target and the selection jumped somewhere unasked.

The context-menu shortcut lives in VimNav's window-level keydown
listener, registered with capture: it fires before CodeMirror sees the
event and stops propagation, so vim never got the chance to consume the
key as an operand. The guard is a new `vimAwaitsNextKey(view)` helper
(cm-vim-pending-input.ts) reading the vim plugin's own pending state:
`expectLiteralNext` (f/t/r awaiting their character) or a non-empty
input key buffer (a count like 2, a g prefix, a register). Both VimNav
menu sites and the editor-level m handler in EditorPane now yield when
it reports pending, so `v f m` finds the m and a plain `v m` still
opens the menu. The native context-menu key is not a character and is
deliberately left alone.

The helper is pure enough to test against a real vim editor in jsdom:
four tests cover normal/visual baseline, f-then-target (asserting the
motion actually consumed the key), a buffered count, and r.

Verified over CDP against the built app both ways with vim enabled:
before, `v f m` opened the menu and the f stayed pending; after,
`v f m d` deletes "alpha m" out of "alpha m bravo" and `v m` alone
still opens the menu.
:wa and :wall were registered as straight aliases of :qall, on the
theory that autosave leaves nothing to flush so the write half of the
command is a no-op. That reading got the vim contract backwards. In
vim, :wa is a save command: write all changed buffers, close nothing.
Every vim user's muscle memory types it reflexively after an edit, and
here it silently demolished the whole tab layout instead (#569,
reported by @simonlearnscoding). The data was never lost, closing
flushes dirty buffers, but the workspace was.

:wa / :wall now run flushDirtyNotes: every dirty note is written to
disk (task-mutation queues drained first, workspace persisted), no tab
is touched. The quit-and-write family keeps closing, exactly as vim
defines it: :xa / :xall stay, and :wqa / :wqall is newly registered so
the muscle-memory spelling of "save everything and quit" works too.
:w was already write-only and :q / :wq still close just the active
note. No toggle needed; vim semantics are the only behavior now.

The in-app help and the website docs table both listed :wa in the
close-everything row; both now document :wa / :wall as "write every
unsaved note" with :wqa in the closing row.

Verified over CDP against the built app both ways: three tabs open,
dirty the active note with a normal-mode x, run :wa from the real ex
prompt. Before: every tab closes. After: all three tabs stay and the
file on disk has the edit; :wqa then closes all three.
gt and gT could only cycle, so reaching a specific tab in a full strip
meant hammering the same key and watching every stop in between (#497,
requested by @uNyanda, who asked for exactly these two halves).

The vim half follows vim's own contract: {count}gt is absolute (2gt is
tab 2, 5gt is tab 5, a count past the end lands on the last tab, plain
gt still cycles) while {count}gT stays relative and walks that many
tabs back. Both ride the existing nextBuffer/previousBuffer actions,
which now read the count CM-Vim already collects (repeatIsExplicit
distinguishes 1gt from a bare gt). Tab numbers count across panes in
the same order gt cycles through, one shared openTabOrder helper, so
cycling and direct selection can never disagree about which tab is
number three.

The non-vim half is nine rebindable shortcuts, tabs.select1 through 9,
dispatched from the app-level handler so they work with vim off and
regardless of focus. Alt+digit cross-platform; on macOS the default is
Ctrl+digit, because Option+digit types characters on many layouts (the
#514 trap) and Cmd+1/2/4/5/6 already mean sidebar, connections, and
the pane modes (the same escape Ctrl+Tab uses). Direct selection
targets open tabs only: no recent-notes fallback, "tab 3" is an open
tab or nothing.

Docs updated in all the mirrored places: in-app help (vim card and
shortcut table), the website docs rows, and the shared keymaps catalog
(whose mirror test caught the omission).

Verified over CDP against the built app: 2gt lands on tab 2, plain gt
cycles on, 9gt clamps to the last of three, 2gT walks two back, and
Ctrl+3 / Ctrl+1 (the mac defaults) jump directly. Unit tests cover the
select-order math, clamping, the no-fallback rule, and multi-step gT
wrapping.
The help has promised Obsidian-style size hints "same syntax as
images" since Excalidraw embeds got them, but only drawings ever
honored them. ![[assets/pic.jpg|100x50]] rendered full-width with
"100x50" leaking into the alt text, and the markdown spelling
![caption|100x50](pic.jpg) did the same, in the editor's live preview
and the reading view alike (#570, reported by @corpserot with both
spellings tried).

The hint parser already existed for drawings; images now share it. A
new splitEmbedLabel helper separates a label's caption from a trailing
size segment (hint-only labels, caption|hint, pipes inside captions
all covered), and three surfaces consume it:

- The reading view gets one remarkImageSizeHints pass over image
  nodes. Both spellings arrive there with the hint in the alt (the
  wikilink branch routes through an image node), so a single pass
  strips the hint and emits real width/height attributes. Attachment
  chips ride the image node type with their labels as display names,
  so only image-classified and remote urls participate.
- The live-preview widget parses the hint for both spellings and sizes
  its img.
- Both surfaces apply the size as inline style as well as attributes,
  because the embed class stretches images to width: 100% and
  presentational attributes lose to any CSS rule. The first fix
  attempt set attributes only; the pixels on screen did not move, and
  only measuring getBoundingClientRect in the running app caught it.
  The class's max-width: 100% still caps hints wider than the pane.

Verified over CDP against the built app both ways with the reporter's
exact two lines: before, both images render 854px wide with the hint
in the alt; after, both render exactly 100x50 with clean captions, in
Edit and Preview. Seven new unit tests cover the label splitting and
the rendered attributes.

@adibhanna adibhanna left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Multi-agent review of this branch at xhigh effort: 31 candidate findings, 15 survived adversarial verification (14 confirmed, 1 plausible), 2 refuted. 13 are anchored as inline comments; two sit on pre-existing code outside the diff hunks, so they live here:

correctness / CONFIRMED: VimNav.tsx:612: #497 gave {count}gt absolute-jump semantics, but count-awareness only exists in the editor-focused codemirror-vim path; VimNav's global gt/gT fallback (preview mode, non-editor focus) drops the typed count and cycles one tab instead of jumping to tab N. With 5 tabs open and focus in the rendered preview (the #321 fallback path), typing 2gt moves to the NEXT tab instead of tab 2. The new help.ts entry promises the vim contract without qualifying that it only works while the editor owns focus.

correctness / CONFIRMED: Editor.tsx:773: the documented ex-line equivalents :tabnext/:tabprevious (and :bn/:bp) still discard any count or argument, so the two spellings of the same vim command now disagree. :tabnext 3 (or :3tabnext), which in vim jumps to tab 3, ignores its params entirely and calls navigateActiveBuffer(state, 1), silently advancing one tab; the same dropped-count inconsistency as the VimNav fallback, but on the ex-command entry point.

The findings cluster in three areas: the #564 workspace-restore rework (phantom/un-prunable tabs), the #497 tab shortcuts (modal bleed-through, override shadowing, AZERTY and Alt-code and Mission Control conflicts, dropped counts), and #570 size-hint parsing (numeric captions consumed, zero dimensions mishandled). Plus one template-completion change that can delete literal braces from note content.

if (typeof value !== 'string') return null
return existingPaths.has(value) || isWorkspaceVirtualTabPath(value) ? value : null
if (typeof value !== 'string' || !value) return null
return value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: #564: sanitizeWorkspaceLayout no longer verifies tab paths, and refreshNotes' selectedPath exemption then keeps a never-verified ghost tab alive, so a stale snapshot restores to a phantom active tab with a blank editor instead of being pruned. (Same root cause surfaces at store.ts:4290 and store.ts:4340.)

Vault synced between two machines; the notes open in the saved workspace were renamed or deleted on the other machine. On launch, the active tab's eager read fails and rewritePathsInTree prunes it, promoting a sibling stale tab to activeTab (nextActive = nextTabs[0]) whose content was never loaded and whose note does not exist. When the index lands, refreshNotes' keep() retains that path because it equals s.selectedPath (the don't-blank-mid-save exemption), so the user lands on a tab titled after a nonexistent note showing an empty editor that survives subsequent refreshes until manually closed. Pre-2.27 restore ran after refreshVaultIndexes and checked every tab against existingPaths, so the phantom never appeared.

if (typeof value !== 'string') return null
return existingPaths.has(value) || isWorkspaceVirtualTabPath(value) ? value : null
if (typeof value !== 'string' || !value) return null
return value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: With path validation removed here, an all-stale snapshot whose active tab is virtual trips the #384 preserve guard, so every dead note tab is preserved on every refresh for the whole session.

A restored snapshot has a virtual tab (e.g. Tasks or a diagram tab) active plus background note tabs synced from another machine that do not exist locally. The eager read skips background tabs, so they all survive restore. On refreshNotes the prune would leave zero note tabs, so preserveLayoutIfPruneEmptiesNoteTabs (pane-layout.ts:292) returns the pre-prune layout, and it does so again on every subsequent refresh: the phantom tabs persist all session, and clicking any of them yields a failed note load instead of the tab quietly disappearing as the pre-PR existingPaths filter guaranteed.

insert: string
): TransactionSpec {
const after = state.doc.sliceString(to, to + 2)
const consumed = after.startsWith('}}') ? 2 : after.startsWith('}') ? 1 : 0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: Completion apply no longer replaces exactly the typed range: it unconditionally swallows up to two closing braces after the caret even when they are literal note content.

The old apply dispatched changes for exactly [from, to]; the new templateVariableApplySpec always extends the replacement over one or two } characters sitting after the caret, without checking they were produced by auto-pair or belong to this variable. In a note documenting escaped template syntax, e.g. the user types {{da immediately before an existing literal }} that closes a different construct (a Handlebars/Jinja snippet written as prose), accepting the completion deletes those two literal brace characters from the note body, corrupting the surrounding text instead of only inserting the variable.

image.alt = alt
const data = (image.data ??= {}) as { hProperties?: Record<string, unknown> }
const hProperties = (data.hProperties ??= {})
if (size.width) hProperties.width = size.width

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: #570: falsy-zero width/height checks run after splitEmbedLabel has already consumed the label, so a |0 or |0xH hint destroys the alt/caption without applying the corresponding dimension (and |0x300 applies height only, distorting the image).

A note containing ![[diagram.png|0x300]] renders in reading view with height="300" but no width attribute (parseEmbedSizeHint's ^(\d+) accepts 0, splitEmbedLabel returns {width: 0, height: 300}, then if (size.width) skips the 0), stretching the image, and the label is gone (alt=""). ![[diagram.png|0]] loses its label entirely with no resize at all. The same falsy-zero pattern repeats in cm-live-preview.ts LocalImageWidget (if (this.width)) and local-assets.ts (hintWidth > 0), so live preview mirrors the distortion.

const raw = (label ?? '').trim()
if (!raw) return { alt: '', size: null }
const wholeSize = parseEmbedSizeHint(raw)
if (wholeSize) return { alt: '', size: wholeSize }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: #570: splitEmbedLabel treats a purely numeric markdown alt text as a whole-label size hint, so an intentional numeric caption is consumed: the image is resized and its caption/alt is silently erased.

A note with ![2024](chart.png), where '2024' is the caption (a year), now renders the chart with width="2024" (stretched to the pane's max-width cap) and alt=""; the reading-view figcaption falls back to the filename and screen readers lose the alt text. Before 2.27 the caption displayed '2024' and the image kept its natural size. This may be intended Obsidian parity for the wikilink form ![[img|600]], but it also rewrites the semantics of pre-existing standard-markdown alt text in users' notes; worth a deliberate verdict.

group: "global",
title: `Go to tab ${n}`,
description: `Jump straight to tab ${n}, counted across panes in the same order gt cycles.`,
defaultBinding: `Alt+${n}`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: Alt+digit default bindings match on event.key, so on keyboard layouts where digits are shifted (French/Belgian AZERTY, Czech) Alt+1..9 produce keys like & or é and the new tab shortcuts never fire.

A Windows or Linux user on a French AZERTY layout presses Alt+1 to jump to tab 1 (as documented in the in-app help): resolveKeyFromEvent takes the typed-character fast path and builds "Alt+&", which never equals the stored binding "Alt+1", so nothing happens and the entire #497 non-vim feature is dead by default on those layouts. The codebase already documents this exact trap class (#514, the defaultBindingMac comment about Option+printable chords) but only shielded macOS; the cross-platform Alt+digit defaults introduced here are the first digit-based defaults and hit the same key-vs-layout pitfall unshielded.

title: `Go to tab ${n}`,
description: `Jump straight to tab ${n}, counted across panes in the same order gt cycles.`,
defaultBinding: `Alt+${n}`,
defaultBindingMac: `Ctrl+${n}`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / PLAUSIBLE: The macOS default Ctrl+1..9 collides with Mission Control's 'Switch to Desktop N' shortcuts, which macOS auto-enables once a user has multiple Spaces, so the OS consumes the key before the app sees it.

A Mac user with two or more Spaces presses Ctrl+2 as the in-app help ('Ctrl+1 ... Ctrl+9 on macOS') advertises: macOS switches to Desktop 2 instead of tab 2, and the app never receives the keydown. The advertised default shortcut silently does nothing in-app for these users; only rebinding or {count}gt works, and nothing in the UI explains why.

// presentational attributes lose to any CSS rule, so the hint also goes
// on as inline style. The class's max-width: 100% still caps a hint
// wider than the pane.
if (this.width) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

correctness / CONFIRMED: LocalImageWidget.toDOM applies #570 size hints only when truthy onto a cached img element whose cache key (url|mtime) does not include the hint, so a reused element keeps stale width/height attributes and inline styles after the hint is removed or shrunk.

Type ![[pic.png|300x200]]; the image renders and, once loaded, the element is admitted to the #472 element cache keyed by url|mtime. Put the cursor on the line (widget destroyed, element disconnected but still cached), edit the hint to |300 or delete it entirely, and move the cursor off: eq() correctly rebuilds the widget, but takeCachedImage returns the same element still carrying width=300/height=200 attributes and inline styles, and since this.width/this.height are now undefined nothing clears them, so the image stays at the old size (or renders 300px wide with a stale 200px height, distorting the aspect ratio) until the file's mtime changes or the cache evicts. The same leak fires when one note embeds ![[pic.png|100]] and another embeds ![[pic.png]] plain: whichever renders second inherits the 100px style.

* closed (#568).
*/
export function vimAwaitsNextKey(view: EditorView | null | undefined): boolean {
const vim = view ? getCM(view)?.state.vim : null

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

simplification / CONFIRMED: The new vimAwaitsNextKey helper (plus its test file) duplicates the pre-existing exported isVimAwaitingArgument in lib/vim-nav.ts:190 (#147), which implements the identical predicate (expectLiteralNext || keyBuffer.length > 0); VimNav.tsx now imports and calls both names for the same vim-pending state in the same handler.

No runtime failure today, but the two copies are one behavioral tweak away from diverging: a future fix to pending-key detection (e.g. also treating inputState.operator as pending) applied to one helper leaves the leader-key guard at VimNav.tsx:1018 and the #568 context-menu guards at VimNav.tsx:1073/1150 plus EditorPane disagreeing about whether vim owns the next key, reintroducing the #147/#568 class of stolen-operand bugs in whichever path kept the stale copy. The #568 change could have imported isVimAwaitingArgument (already covered by vim-nav.test.ts) instead of adding a second module, test file, and name for the same logic.

Comment thread packages/app-core/src/App.tsx Outdated
state.setWordWrap(!state.wordWrap)
return
}
// Alt+1..9 (⌃1..9 on macOS) — jump straight to tab N (#497). Position

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

style / CONFIRMED: New comment uses an em dash, violating the house writing rule (never use an em dash in code comments, commits, docs, or UI copy).

The newly added comment // Alt+1..9 (⌃1..9 on macOS) — jump straight to tab N (#497) contains an em dash; restructure with a comma, colon, or parentheses.

…571)

The release review confirmed twelve defects and one plausible trap
across the branch's changes. This lands all of them in one follow-up
commit so each release commit keeps telling its own story.

Restore (#564): trusting the snapshot before the index exists was the
point, but the strict check the old restore order gave for free never
came back. A snapshot synced from another machine could promote a
never-verified tab to active after the eager read pruned the ghost,
and refreshNotes' selectedPath exemption then kept it alive forever; a
virtual-active snapshot full of stale note tabs tripped the #384
anti-wipe guard on every refresh instead. openVaultWorkspace now runs
a one-time strict validation once the first listing lands: every
restored tab must exist, be virtual, or hold unsaved edits. An empty
listing skips the pass, because it is indistinguishable from a failed
one (the #384 lesson). The refreshNotes exemption also requires the
note to be loaded or dirty, so nothing that never loaded can ride it.

Templates (#566): the completion swallowed up to two closing braces
without asking whose they were. It now counts the line's earlier {{
against }}: if an earlier construct is still open, the braces after
the caret are its closers, literal note content, and they stay.

Size hints (#570): zero is not a size. parseEmbedSizeHint rejects
0-dimension hints at the source, so |0x300 stays a caption instead of
eating the label and applying half a resize. splitEmbedLabel now takes
its source: a bare numeric alt in standard markdown (![2024](chart))
stays the author's caption; only the wikilink form keeps Obsidian's
whole-label hint. And the live-preview widget clears width/height on
a cached element it reuses, since the #472 cache key is url|mtime and
knows nothing about hints.

Tab shortcuts (#497): the new dispatch loop bails while a modal,
palette, menu, or Settings is open (the house rule), and steps aside
when the pressed combination is one the user explicitly rebound to a
later-checked action: an explicit rebind outranks a shipped default.
Two keyboard realities got shields: Alt+numpad digits never resolve on
Windows (that is Alt-code character entry, the OS owns it), and digit
bindings fall back to the physical Digit1-9 position so Alt+1 fires on
AZERTY, where the digit row types punctuation (the #514 trap class,
now cross-platform). The Mission Control collision on macOS Ctrl+digit
stays the default deliberately (Option types characters and the Cmd
digits are taken), but the caveat now ships in the keymap description
and the help row, so an affected user learns to rebind instead of
concluding the feature is broken.

Cleanup: vimAwaitsNextKey duplicated isVimAwaitingArgument one PR
after #147 shipped it; the module and its name are gone, VimNav and
EditorPane call the one helper, and the real-editor test moved to
vim-nav-awaiting-argument.test.ts because vim-nav.test.ts mocks getCM.
One comment lost its em dash.

Verified over CDP against the built app (scratchpad
verify-571-fixes.mjs): a seeded stale snapshot's ghost tab disappears
once the index lands, Ctrl+2 jumps to tab 2, Ctrl+1 is inert under the
command palette and works again after Escape. Full app-core suite and
typecheck green.
@adibhanna
adibhanna merged commit c3098f9 into main Aug 10, 2026
@adibhanna
adibhanna deleted the v2.27.0 branch August 10, 2026 17:33
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