Skip to content

fix(editor): give Ctrl/Cmd+Shift+Z back to redo - #648

Merged
PathGao merged 1 commit into
masterfrom
fix/give-shift-cmd-z-back-to-redo
Aug 12, 2026
Merged

fix(editor): give Ctrl/Cmd+Shift+Z back to redo#648
PathGao merged 1 commit into
masterfrom
fix/give-shift-cmd-z-back-to-redo

Conversation

@PathGao

@PathGao PathGao commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

toggle-zen-mode was registered on CtrlCmd|Shift|KeyZ. Dynamic keybindings register at weight 1000 against Monaco's 100, so it won — and Monaco assigns that chord to redo.

Monaco's redo:
    primary:   Ctrl/Cmd+Y
    secondary: [Ctrl/Cmd+Shift+Z]
    mac:       { primary: Cmd+Shift+Z }      ← primary only

bindToCurrentPlatform returns the platform object whole rather than merging it, so the default secondary is discarded on macOS:

platform redo's bindings after zen mode took one
macOS Cmd+Shift+Z only no keyboard redo at all
Windows Ctrl+Y + Ctrl+Shift+Z pressing redo opens zen mode; Ctrl+Y survives
Linux same as Windows same — and Ctrl+Shift+Z is the GTK convention, so it is the likelier press

Confirmed by hand on a debug build. The native PredefinedMenuItem::redo in the Edit menu does not save it.

Windows and Linux look milder but read worse: macOS gives no response, while there the screen switches to zen mode — unrelated to what was pressed. And the panel has no Redo row (it is Monaco's, never registered), so nothing tells that user Ctrl+Y exists.

The fix is to move zen mode, not to give redo a second key

The defect is that we took a chord already correctly assigned. Handing the victim a replacement key buries that instead of fixing it.

Zen mode goes to Mod+Shift+D — free in Monaco on all three platforms (its only D chords are Mod+D, Ctrl+D, Mod+K Mod+D), free in the registry, in every addAction/addCommand, in MarkdownViewer's document handler, and among the Rust accelerators; not macOS system-reserved. F8 was rejected because Apple keyboards need Fn for it, and VS Code's Mod+K Z sits in a namespace that may itself move.

The guardrail is the point

One key moved leaves the next person free to repeat this, and the symptom is silent — a basic operation quietly disappears. scripts/monacoChordOwnership.spec.ts now checks every addAction and addCommand binding against Monaco's real defaults on all three platforms. Four rules: no unexplained collision, no stale allow-list row, the unrecoverable set still belongs to its owner, and the unrecoverable set has no allow-list escape hatch — so undo, redo, cut, copy, paste, save and select-all cannot be taken by writing a justification.

Every allow-list entry it forced, all eight pre-existing:

ours chord what Monaco loses platforms
fmt-italic Mod+I triggerSuggest all
fmt-underline Mod+U cursorUndo all
fmt-quote Mod+Shift+. inPlaceReplace.down all
view-toggle-edit Cmd+E findWithSelection macOS only
view-toggle-live Mod+L expandLineSelection all
file-reveal Ctrl+Shift+R editor.action.refactor Win + Linux only
continueListOnEnter Enter acceptSelectedSuggestion et al. all
indentListItemOnTab Tab tab et al. all

None is the size of the zen-mode one — every command stays in the command palette, and the two list keys are when-gated and fall through. Two are worth an eye: fmt-underline over cursorUndo is the nearest thing to an undo-family casualty (cursor history, not text), and view-toggle-edit / file-reveal collide on only some platforms, which the table now records rather than leaving to be rediscovered.

Two measurement corrections

The right Monaco snapshot is the web branch, not the native one. clipboard.js binds Mod+X/C/V only when isNative, and Markpad runs in a WebView: 366 / 299 / 293 rows rather than 369 / 305. Using the native branch would have forced three bogus allow-list entries for chords the shipped app legitimately owns.

Faking process.platform is not sufficientplatform.js checks for a Node process before navigator, so process.versions.node has to be hidden across the import and the platform then comes from the user agent. isNative === false and isMacintosh are both asserted after import so the mode cannot silently flip.

One deletion beyond the brief

MONACO_DEFAULTS in formatShortcutKeymap.test.ts — eleven hand-copied chords that described themselves as the weakest assertion in the file. The new spec reads the same data for real, so the copy was a second source of truth for exactly the thing being checked. Its one deliberate exception (Quote over inPlaceReplace.down) moved into the allow-list, so coverage is strictly stronger.

Falsification

Guardrail written with zen mode absent from the allow-list, run against the unfixed tree: 3 of 5 red, naming toggle-zen-mode takes Shift+Meta+Z from Monaco's redo on all three platforms. Mutating fmt-strikethrough onto Mod+Shift+K fails the same way. Adding an allow-list row for zen mode on Shift+Meta+Z fails with "that chord is redo" — the escape hatch is genuinely closed.

npm test 843 → 842 (the deleted copy) · vitest 351 → 356 · check 0 errors.

🤖 Generated with Claude Code

Zen mode was bound to CtrlCmd|Shift|KeyZ. On macOS that is redo's ONLY
editor binding: Monaco's `mac` override replaces the default rule rather
than adding to it, so the Ctrl+Y that Windows and Linux keep does not
exist there. `addAction` registers at weight 1000 and every Monaco
default sits at 0 or 100, so zen mode did not share the key, it took it —
confirmed by hand on a debug build, where Cmd+Shift+Z entered zen mode
and nothing redid. Undo still worked, so the edit was recoverable only by
retyping it.

The Edit menu's native `PredefinedMenuItem::redo` does not save the user;
the hand test settles that, and it is left alone.

Zen mode moves to Ctrl/Cmd+Shift+D — distraction-free, unclaimed in
Monaco on all three platforms, unclaimed by the document-level handler
and by the two Rust accelerators, not reserved by macOS, and a neighbour
of the app's existing Ctrl/Cmd+Shift row. Redo keeps the chord; giving
redo a second binding would have papered over taking a key that was
already correctly assigned.

THE GUARDRAIL

Moving one key leaves the next person free to do the same thing again,
and nothing in the suite could see it: the only description of Monaco's
keymap was eleven chords hand-copied into formatShortcutKeymap.test.ts,
and what it could not see was what it did not list.

`scripts/monacoChordOwnership.spec.ts` reads the keymap instead. It
imports `monaco-editor` under jsdom and dumps
`KeybindingsRegistry.getDefaultKeybindings()` once per platform (366 /
299 / 293 keybindings over 223 / 165 / 161 distinct chords on macOS /
Windows / Linux), then fails on any `addAction` or `addCommand` chord
Monaco already assigns. Deliberate overrides stay possible through a
named allow-list where each row argues why that particular loss is
acceptable, and rows are checked for staleness so the table cannot only
grow. The chords whose loss is unrecoverable — undo, redo, select all,
cut, copy, paste, save — are held separately and cannot be allow-listed
at all.

The dump is taken in Monaco's web branch, not its native one: the app
runs in a WebView, where clipboard.js leaves Ctrl/Cmd+X/C/V unbound
"since browsers do that for us", which is why Editor.svelte binds them
itself. A native-mode dump would have demanded three allow-list entries
for a conflict the shipped app does not have.

The allow-list starts with eight rows, all pre-existing: fmt-italic over
triggerSuggest, fmt-underline over cursorUndo, fmt-quote over
inPlaceReplace.down, view-toggle-edit over findWithSelection (macOS
only), view-toggle-live over expandLineSelection, file-reveal over
refactor (Windows and Linux only), and the two `when`-gated list-key
commands on Enter and Tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit aa7466f into master Aug 12, 2026
4 checks passed
@PathGao
PathGao deleted the fix/give-shift-cmd-z-back-to-redo branch August 12, 2026 15:58
PathGao added a commit that referenced this pull request Aug 12, 2026
#645 and #648 were each green and semantically conflict. #648 landed the
chord-ownership guardrail with allow-list rows naming `indentListItemOnTab`;
#645 had already renamed that handler to `handleTabKey` and added three more
key registrations. Merged, the rows are stale AND four chords are undeclared,
so master went red.

The guardrail is working, not misfiring: `Mod+Enter` and `Mod+Shift+Enter`
genuinely take `insertLineAfter`/`insertLineBefore` from Monaco, and nothing
said so out loud. Every row now names the chord it takes, on which platforms,
and why the loss is acceptable — for these four the answer is that the key is
specialised rather than taken, and the handler re-triggers the core command
everywhere its own branch declines.

Falsified by deleting the `handleModEnterKey` row: red on all three platforms
with the exact command list it displaces.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 12, 2026
…he chord (#652)

* fix(editor): stop Mod+Shift+K from deleting a line

`Mod+Shift+K` is `editor.action.deleteLines` — VS Code's convention,
inherited by standalone Monaco and inherited again by this app. It
silently destroys the line the caret is on, and it was being mis-fired.
None of the mainstream Markdown editors binds it: Typora, Obsidian, iA
Writer and Bear all leave that chord alone. It is a code-editor key that
leaked into a prose editor.

A REMOVAL RULE, not a no-op command on the same chord. A rule whose
command id starts with `-` is what Monaco's own resolver reads as "drop
this default" (`KeybindingResolver.handleRemovals`); binding a
do-nothing command instead would leave the key dead AND leave a fake
command sitting in front of Monaco's. The rules are global to the Monaco
module rather than to one editor, so the disposable is released with the
editor — this component is rebuilt every time a tab goes to reading mode
and back, and one rule per mount would pile up.

The COMMAND stays. Delete Line keeps its command palette entry, the same
rule the table delete verbs on this branch live under: dropping a key is
not dropping a command, and a destructive verb keeps a home.

The test reads Monaco rather than a copy of it, the way
listContinuation.test.ts pins facts about `coreCommands.js`: the `-`
prefix is read out of `keybindingResolver.js`, Delete Line's chord out of
`linesOperations.js`, and the rule the app registers is evaluated with
Monaco's real `KeyMod`/`KeyCode`. So an upstream rename or re-chord fails
here instead of leaving a rule that quietly matches nothing, and nothing
of ours may take the freed chord.

Originally verified on `feat/tab-through-a-table` as d8bebdc and never
pushed, so it missed #645. Reapplied here: #648 deleted `MONACO_DEFAULTS`
from formatShortcutKeymap.test.ts in the meantime, and the commit's only
dependency on it was a comment, so the test itself carried over intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(shortcuts): Mod+K inserts a link, and the chords move off Mod+K

THE DECISION. Ctrl/Cmd+K is Insert Link in Typora, Bear, iA Writer and
GitHub — the only command in the whole keymap survey where four
independent sources agree exactly — and this app's link button had no
keyboard shortcut at all. Insert Table moves from the `Mod+K T` sequence
to `Mod+Alt+T` (Typora's and Bear's macOS chord), Zen Mode from
`Mod+Shift+D` to `Mod+Alt+Z`, and the three list buttons — which had
actions and no keys — get `Mod+Shift+7`, `Mod+Shift+8` and
`Mod+Shift+9`.

CHORD SEQUENCES ARE GONE, which is what started this: `Mod+K` then a
second key was awkward enough to press that people stopped. Every
binding here is one keystroke now, and `shortcutRegistry.test.ts` holds
that as a rule rather than as a habit — for both keyboard layers and for
the panel, so a new sequence on some other prefix fails too.

WHAT MOD+K COSTS, enumerated rather than summarised. Monaco binds no
complete keybinding there; it binds 31 chord SEQUENCES behind it,
carrying 30 commands, identically on all three platforms. Twenty are
folding, the other ten are add/remove comment line, format selection,
reveal definition aside, peek widget focus, set/select-from selection
anchor, move selection to next find match, show hover and trim trailing
whitespace. All thirty keep their command palette entry.

The folding bulk is smaller than it looks, and this was checked rather
than assumed: nothing in `src/` registers a folding-range provider, and
Monaco's own `basic-languages/markdown/markdown.js` declares folding
only through `<!-- #region -->` markers nobody writes. What those twenty
commands operate on is indentation folding. The folding this app's users
actually have is the preview-side system in `foldState.ts`, which is
click-driven and untouched. No replacement fold chords are added.

THE GUARDRAIL COULD NOT SEE ANY OF THAT. `monacoChordOwnership.spec.ts`
keys Monaco's map by the FULL chord, so `Meta+K` is not in it and the
ownership rule matched nothing — it would have waved the largest keymap
change this app has made straight through. A second rule now catches a
chord claimed as a whole key that Monaco uses as a PREFIX, and
DELIBERATE_OVERRIDES grew a `namespace` row shape whose staleness check
looks behind the prefix instead of at it.

INSERT COLUMN HAS NO CHORD, AND THIS IS THE ONE OPEN QUESTION. It was on
`Mod+K C`, which can no longer be typed. `Mod+Alt+C` was the intended
replacement and is not available: on macOS it is Monaco's
`toggleFindCaseSensitive`, bound whenever the editor has focus, and
registered with `registerEditorCommand` rather than
`registerEditorAction` — so it has no command palette entry to fall back
on, and taking it would leave find-case-sensitivity reachable only by
mouse. That is the one thing no override in that file is allowed to do,
so no collision was shipped. Insert Column is palette-only until a chord
is chosen; the free letters on all three platforms are A B D E G H I J K
M N O Q S T U V X Y Z.

`Mod+Shift+9` for the task list has no precedent and the comments say so
rather than implying one: GitHub documents no task-list chord, nor do
Typora or Obsidian, and the two editors that do bind one disagree and
are both occupied here (Bear's `Cmd+T` is New File; iA Writer's
`Opt+Cmd+L` is Monaco's `toggleFindInSelection` on macOS). It is the
digit adjacent to GitHub's documented 7 and 8, and nothing more.

Every chord was verified against Monaco's real keybinding registry
dumped per platform under jsdom, not against a hand-copied list, and
against the app's own registry, every addAction/addCommand, the document
handler in MarkdownViewer.svelte and the Rust menu accelerators.

Tests: 902 pass, vitest 357, check 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tests): stop asserting the two platforms differ by one word

#651 made `Alt` render as `Opt` on macOS. Two expectations still spelled the
pre-#651 output: one literal `Cmd+Alt+T`, and one assertion that the Cmd
rendering IS the Ctrl rendering with `Ctrl` swapped for `Cmd`.

That second one is a copy of the assumption #651 removed — "only Mod is
substituted". Re-adding an Alt→Opt swap beside it would put the word map in a
third place, so it now proves only what it is for: that the two renderings are
still the same chord, same length and same final key. `shortcutRegistry.test.ts`
owns the per-platform naming and fails on its own when the map breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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