Skip to content

Ctrl+P Opens The Notes Picker In Quick Capture - #944

Merged
adibhanna merged 1 commit into
ZenNotes:v2.70.0from
uNyanda:fix/quick-capture-shortcuts
Oct 14, 2026
Merged

adibhanna merged 1 commit into
ZenNotes:v2.70.0from
uNyanda:fix/quick-capture-shortcuts

Conversation

@uNyanda

@uNyanda uNyanda commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Three of the four shortcuts the Quick capture window advertises in its footer do nothing when Vim mode is on and the editor holds focus:

Advertised Works
Mod+Enter save ✅ every mode
Mod+N new ❌ Vim's cursor-down takes it in normal and visual
Mod+P notes ❌ Vim's cursor-up takes it
Mod+⇧P cmd ✅ every mode

Mod+W was dead the same way and was not advertised at all. Insert mode is the only mode where any of them worked, because it is the one mode where Vim claims none of them.

Fixes #942.

The Cause

Quick capture renders as its own window root, so App.tsx is never loaded in that window. Its key handling was a plain window listener in the bubble phase, and codemirror-vim consumes Ctrl+P, Ctrl+N and Ctrl+W as normal-mode chords and stops propagation, so the listener never saw them.

The main window already solved this twice. #510 moved its palette shortcut into a capture-phase handler so Ctrl+P wins over Vim's cursor-up. #579 recorded that a shortcut "remapped onto an editor chord (Ctrl+P, Ctrl+E, …), the bubble-phase handler ran only after CodeMirror had already consumed the key, so the shortcuts went dead exactly when the editor held focus." Quick capture is the one window with no capture-phase handler at all.

The Change

A capture-phase listener handles the three chords that collide, and only while no overlay is open. Those branches then come out of the bubble handler, so no chord is handled in two places.

Mod+W needed different treatment, because useCloseWindowShortcut is shared with the main window. It now takes a capture flag that only the Quick capture call site sets. The main window leaves it off, since it already resolves that chord in its own capture-phase handler.

The footer now tracks state: the three chord hints when nothing is open, and ↑↓ Ctrl+N/P move plus esc close while a palette has focus.

Advertising Mod+W close in the no-overlay state is a deliberate choice. Escape is the shortcut the window already documents for closing and it still works, but in insert mode Escape exits insert mode first and only a second Escape closes the window, so closing costs two presses and the first appears to do nothing useful. Mod+W closes in one press, in every mode including insert. It was unadvertised, and in normal mode broken, so the one chord that closes without the double press was both hidden and half broken.

It is advertised only when no palette is open because it does not work when one is. useCloseWindowShortcut is given overlayOpen as its blocker, so Mod+W is inert while the picker or command palette has focus, which is the same rule the main window follows. That is why the palette branch shows esc close rather than the close chord: Escape dismisses the palette and the close chord would not.

The command palette's rows no longer claim Mod+N and Mod+P, because inside that palette those chords navigate the list rather than running the command.

The navigation hint reads Ctrl, not Mod, and that is not a choice. isPaletteNextKey and isPalettePreviousKey match on event.ctrlKey && !event.metaKey, so Cmd+N/Cmd+P never navigate in any palette on any platform. Every other palette already hardcodes the literal Ctrl+N/P move for exactly this reason, and Quick capture now does the same. An earlier draft of this footer used modKey, which would have rendered ⌘N/P move on macOS and reintroduced the exact defect #939 was filed about.

A Second Defect In The Same Window

Three separate problems lived in this one window. All three are in the PR because all three were found while testing the first.

The picker rows put the filename last, behind the path. The filename truncated to a stub while the path stayed readable, which inverts the priority: the user is picking by filename, and the path only exists to separate notes that share one.

This is #635, which is closed. That fix landed in Open Buffers and never reached Quick capture's picker, which carries the same row layout.

The fix mirrors the one already in BufferPalette rather than inventing a second shape:

  • The filename takes its natural width and truncates only when it alone overflows the row. Previously both items shrank in proportion to their content, and the longer path took the larger share.
  • The path becomes a flex-1 column, so it absorbs only what is left and collapses first.
  • dir="rtl" moves the ellipsis to the left edge, so a path that no longer fits keeps its distinguishing tail.
  • text-right holds it against the row's right edge, which is where ml-auto used to put it.

The column also stops repeating what the row already shows. The folder is on the left as an uppercase label and the filename sits beside it, so only the subpath within the folder is rendered. A note at inbox/ZenNotes/Enhancements/Calendar Panel Tasks Placement.md shows ZenNotes/Enhancements, not the full path.

Neither overlay scrolled the selection into view. Moving the highlight past the bottom of the list left it out of sight, with no way to tell which row was selected. SearchPalette, CommandPalette and BufferPalette all scroll the active row already, so this was an omission specific to Quick Capture rather than a missing idea. Both overlays now carry the same effect, keyed off a data-qc-idx attribute on each row.

Why Not The Other Way

There were two ways to resolve the overlap between Ctrl+N and Ctrl+P as Quick Capture shortcuts and as palette navigation keys.

Option A: let the shortcuts win everywhere. Keep Mod+N starting a new note and Mod+P opening the picker even while a palette is open, and narrow palette navigation to Ctrl+J and Ctrl+K. The footer would advertise ↑↓ Ctrl+J/K move.

Option B: the palette owns the keyboard while it is open. Ctrl+N and Ctrl+P start a new note and open the picker only when nothing is open. Inside a palette they navigate. The footer advertises ↑↓ Ctrl+N/P move. This is what is implemented.

Option B is the main window's existing behaviour, and that is the reason for choosing it.

The only chord that actually misbehaved there was Ctrl+P, which toggled the palette rather than navigating. That is #939, fixed by the branch this sits on top of. Ctrl+N always navigated correctly, because the overlay guard in App.tsx returns early when a palette is open, so the newNoteHere handler behind it never fires. Nothing about Ctrl+N needed resolving in the main window, and the same guard shape is what this PR gives Quick Capture.

Option A was considered and rejected on two grounds.

It would narrow navigation for no reason. With Ctrl+N already correct everywhere, taking it away inside a palette is a regression dressed as a fix.

It creates dead keys. Once the capture handler stands aside while an overlay is open and the bubble branches are gone, Ctrl+N and Ctrl+P do nothing there. Under Option A that is the goal, but it has to be arrived at deliberately, and the failure mode is the reverse one: narrowing navigation to Ctrl+J and Ctrl+K without also re-enabling the shortcuts leaves two inert keys in every palette.

The trade is that Ctrl+N no longer starts a new note from inside the command palette, where it did before in Quick Capture specifically. It is the same trade the main window makes, and it is why the gate on "no overlay open" is the load-bearing line in the capture handler.

Tests

QuickCaptureApp.test.ts gains 11 tests, all passing. The file is now 25 tests, 24 passing, and the one failure is cancels a Vim character selection, a 5000 ms timeout that is pre-existing flake on this machine: it fails identically on a stashed clean tree.

The existing #765 Escape cases and both Cmd+W close-shortcut cases still pass, which is the regression risk. Escape deliberately stays in the bubble phase because its branch reads e.defaultPrevented on the grounds that CodeMirror has already run, so it is the chord most at risk from the listener moving phase.

Reaching the phase under test

The chords are claimed in a capture-phase listener on window, because codemirror-vim consumes Ctrl+P, Ctrl+N and Ctrl+W as normal-mode chords and stops propagation. A test that dispatches on the editor therefore proves nothing, since the editor eats the key before any handler sees it.

Two dispatch targets are needed, and the distinction is the whole point:

  • onWindow(...) dispatches on document. The capture path still starts at window, so the listener runs, while the editor never sees the key. This is how the chords that open or create are tested.
  • press(input, ...) dispatches on the focused overlay input, where a real keystroke lands once an overlay is open. This is how navigation inside an overlay is tested.

Every chord test also puts Vim into normal mode first, via one Escape, because insert mode is the mode where the bug does not reproduce.

What each test asserts

The chords now fire in normal mode. Four tests, one per chord.

Test Asserts
opens the note picker on Mod+P in normal mode, where Vim would take it The note picker is open. Also that nothing was saved and the window was not closed, so the chord did not fall through to Vim's cursor-up or to a submit.
starts a new note on Mod+N in normal mode createNote was called and no overlay opened, which is what Vim's cursor-down used to do instead.
hides the window on Mod+W in normal mode, keeping the draft windowClose called exactly once. This is the capture flag on the shared hook.
opens the command palette on Mod+Shift+P in normal mode The command palette is open, confirmed by its Run a command… placeholder rather than the picker's, so the two overlays cannot be confused.

Navigation wins once an overlay has focus. Three tests, which is the other half of the fix: the capture listener has to stand aside, or the chord re-enters it and toggles the overlay shut.

Test Asserts
moves down rather than toggling once the picker has focus From row 0 of 2, Ctrl+N lands on row 1. The picker is still open and still has 2 rows, so it navigated rather than closing.
holds at the top row on Ctrl+P instead of closing the picker Ctrl+P from the top row clamps and leaves the picker open. This is the direct regression test for the reported symptom. Then a separate step moves down and back up to prove Ctrl+P still navigates when there is somewhere to go, so the clamp cannot be passing merely because the chord does nothing.
stops moving at the last row instead of wrapping or closing Ctrl+N past the end holds on the last row, rather than wrapping to the top or closing.

The footer tells the truth. Two tests.

Test Asserts
swaps the footer hints while an overlay is open Idle shows notes and close; with the picker open it shows move and esc close, and no longer advertises new or cmd, which do nothing there. Also asserts Ctrl+N/P is present and ⌘N/P is absent, which pins the Ctrl-only rule described above.
drops the two command hints that no longer run inside the command palette Save and start a new note and Open another note… are still listed but carry no Mod+N/Mod+P hint, and Mod+Enter keeps its ↩ because it is not a navigation chord.

The two defects from the same window. Two tests.

Test Asserts
shows only the subpath beside the filename For inbox/ZenNotes/Enhancements/Calendar Panel Tasks Placement.md the row renders the title and ZenNotes/Enhancements. Asserts inbox appears exactly once, so the folder label is not repeated in the path column, and that no cell ends in .md, so the filename is not either.
scrolls the active row into view in both overlays Navigating with Ctrl+N calls scrollIntoView({ block: 'nearest' }) in the picker, then again in the command palette after dismissing the first. Both overlays get their own check because they are separate components with separate effects.

Notes on the test environment

Three things were needed that the file did not have, and each is worth knowing before adding the next test here.

Element.prototype.scrollIntoView has to be stubbed. jsdom has no layout, so it is not implemented, and the new scroll effect calls it on mount. Without the stub the failure surfaces as an unrelated-looking TypeError inside a React passive-effect commit hook.

Notes are seeded by re-running defineProperty on window, not by assignment. beforeEach installs window.zen without writable: true, so window.zen = ... throws.

activeRow() matches on the exact class token bg-paper-200 via classList, not on a substring. The inactive row carries hover:bg-paper-200/60, which contains the active row's class name as a substring, so a naive className.includes picks the wrong row and makes every navigation assertion pass or fail for the wrong reason.

Known Gaps

  • The new tests dispatch synthetic events, so they prove the handler wiring and not the physical key. What a real Vim does to Ctrl+N and Ctrl+W at the keyboard level is still only covered by hand. The manual pass is the only thing that exercises the real chord path.
  • Cmd+N and Cmd+P do not navigate inside any palette. palette-nav.ts matches ctrlKey and rejects metaKey, so on macOS the chords a user would reach for are inert inside every palette in the app. That is pre-existing and app-wide, and out of scope here, but this PR does make Quick capture's footer state the rule rather than hide it.
  • Vim's Ctrl+P cursor-up is unavailable in Quick capture once this lands. That matches the main window, where the app shortcut already wins, but it is a change for anyone relying on the Vim binding in that window.
  • Mod+Enter and Mod+Shift+P moved to capture phase for consistency rather than need. They work today. Leaving one chord in the bubble handler while its neighbours are in the capture handler is the arrangement that produced this bug.

Review Focus

The capture flag on useCloseWindowShortcut is the part worth a careful look. It is the only change touching shared code, and getting it wrong would either double-handle the chord in the main window or leave Mod+W broken there.

Second is the gate on overlayRef.current !== 'none' in the capture handler. It is the single line that keeps Option B honest, and the three navigation tests are what hold it in place.

Quick capture renders as its own window root, so App.tsx and its
capture-phase handler are never loaded there. Key handling was a plain
window listener in the bubble phase, and codemirror-vim consumes Ctrl+P,
Ctrl+N and Ctrl+W as normal-mode chords and stops propagation. Three of
the four shortcuts the footer advertises were dead in exactly the mode
they matter in. The main window already fixed this twice (ZenNotes#510, ZenNotes#579);
neither fix reached this window.

A capture-phase listener now claims those chords, and only while no
overlay is open. Inside an overlay its input has focus and the palette's
own isPalettePreviousKey/isPaletteNextKey branch navigates, so standing
aside there is what keeps Ctrl+P navigating rather than closing. That is
the main window's behaviour, and Ctrl+N always worked there because the
overlay guard returns early before newNoteHere.

Mod+W needed different treatment, because useCloseWindowShortcut is
shared with the main window. It takes a capture flag that only this call
site sets; the main window leaves it off, since it already resolves that
chord itself and doing both would double-handle it.

Three further defects in the same window, all found while testing the
first:

- The picker rows put the filename last behind the path, so the filename
  truncated to a stub while the path stayed readable. This is ZenNotes#635, whose
  fix landed in Open Buffers and never reached here. The row layout now
  mirrors BufferPalette, and the path column shows only the subpath, since
  the folder is already labelled on the left and the filename sits beside
  it.
- Neither overlay scrolled the selection into view, so moving past the
  bottom of the list left the highlight off screen. Both now do, keyed off
  data-qc-idx to stay clear of SearchPalette's data-search-idx.
- The footer is now state-aware, and the command overlay no longer claims
  Mod+N/Mod+P on rows whose chords move the selection instead.

The navigation hint reads Ctrl, not Mod, and that is load-bearing rather
than stylistic. isPaletteNextKey and isPalettePreviousKey match on
ctrlKey and reject metaKey, so Cmd+N/Cmd+P navigate in no palette on any
platform. Every other palette hardcodes the literal for that reason. An
earlier draft used modKey here, which would have rendered a Cmd hint that
does nothing on macOS.

11 tests added. They dispatch on document for the chords that open or
create, so the capture path runs while the editor never sees the key, and
on the focused input for navigation inside an overlay. Each puts Vim into
normal mode first, since insert mode is where the bug does not reproduce.
@uNyanda
uNyanda requested a review from adibhanna as a code owner October 14, 2026 16:35
@adibhanna
adibhanna changed the base branch from main to v2.70.0 October 14, 2026 17:12
@adibhanna
adibhanna merged commit 3d6a71a into ZenNotes:v2.70.0 Oct 14, 2026
10 checks passed
adibhanna added a commit that referenced this pull request Oct 14, 2026
…mes your key

Review follow-up to #944, kept apart so @uNyanda's commit stands as theirs.

The picker's path column is right to left so its ellipsis lands on the
left, and #944's comment says an LRE/PDF pair keeps the path itself left
to right, as Open Buffers does (#635). The pair was missing from the code,
so a subpath that starts with digits or punctuation came out reordered: a
note in inbox/2026/Planning showed "Planning/2026". The path is now wrapped
in U+202A and U+202C, the same string BufferPalette renders, and the test
pins it.

The footer's new close hint was written as Mod+W, but the key behind it is
Close active tab, which follows a rebind and can be unbound (#893). A
rebound key was advertised as Mod+W, and an unbound one as a key that does
nothing. useCloseShortcutDisplay reads the binding the way
useCloseWindowShortcut does, kept current through the same storage event,
and the footer shows it, or leaves the hint out once it is unbound.

The in-app help's Quick capture section gains Ctrl+N / Ctrl+P: inside the
note picker and the command palette they move the selection, which the
footer now says and the help did not.

Checked: the app-core typecheck and its full suite (3,235 tests, 2 new),
and the built app over CDP. With Vim on and the editor in normal mode,
Ctrl+P opens the picker, Ctrl+N and Ctrl+P move within it, Ctrl+N outside
it saves the draft to Quick Notes and starts a new one, the footer reads
"⌘W close", and the 2026/Planning row reads in order.
@adibhanna

Copy link
Copy Markdown
Contributor

Thanks @uNyanda, merged into the v2.70.0 release branch (3d6a71a), so this ships in ZenNotes 2.70.0. The write-up made it easy to review, and the design held up: the capture listener standing aside while an overlay is open, the capture flag on the shared hook, and the tests for both overlays.

I checked it in the built app with Vim on and the editor in normal mode. Ctrl+P opens the picker, Ctrl+N and Ctrl+P move inside it without closing it, and Ctrl+N outside it saves the draft and starts a new one. On the build without this branch, the same keys did nothing.

A follow-up commit, f8e7fe1, changes three things:

  • The picker's path column. The comment describes the LRE/PDF pair that Open Buffers uses, but the pair was not in the code. Without it, a subpath that starts with digits comes out reordered in the right-to-left column: a note in inbox/2026/Planning showed Planning/2026. The path is now wrapped in U+202A and U+202C, the same string BufferPalette renders, and the subpath test pins it.
  • The close hint. The footer wrote it as Mod+W, but that key is Close active tab, which can be rebound or unbound (Close window shortcut (cmd+w) does not close Settings or Floating windows #893). The footer now shows the actual binding, and leaves the hint out when it is unbound. Two tests cover it.
  • In-app help. The Quick capture section now lists Ctrl+N / Ctrl+P for moving in the picker and the command palette, matching the new footer.

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.

Quick Capture Shortcuts Are Dead In Normal And Visual Mode

2 participants