From b42be41ac427c5afc0b4ac49e78d6b1b06066277 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Tue, 19 May 2026 19:22:09 -0700
Subject: [PATCH 01/14] Add customizable Markdown preview keybindings
---
README.md | 11 +
Resources/Localizable.xcstrings | 84 +++++++
Resources/markdown-viewer/shell.html | 96 +++++++-
Sources/AppDelegate.swift | 4 +-
Sources/KeyboardShortcutContext.swift | 14 ++
Sources/KeyboardShortcutRecorder.swift | 8 +-
Sources/KeyboardShortcutSettings.swift | 92 +++++++-
.../KeyboardShortcutSettingsControls.swift | 1 +
.../KeyboardShortcutSettingsFileStore.swift | 14 +-
.../MarkdownPreviewKeyboardShortcuts.swift | 84 +++++++
Sources/Panels/MarkdownViewerAssets.swift | 8 +
Sources/Panels/MarkdownWebRenderer.swift | 49 ++++
Sources/Panels/MarkdownWebSupport.swift | 15 ++
cmux.xcodeproj/project.pbxproj | 4 +
cmuxTests/MarkdownPanelTests.swift | 213 ++++++++++++++++++
web/app/[locale]/docs/configuration/page.tsx | 5 +-
.../[locale]/docs/keyboard-shortcuts/page.tsx | 1 +
web/data/cmux-shortcuts.ts | 17 ++
web/data/cmux.schema.json | 28 +--
web/messages/en.json | 2 +
web/messages/ja.json | 2 +
21 files changed, 724 insertions(+), 28 deletions(-)
create mode 100644 Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
diff --git a/README.md b/README.md
index 2dd588da552..9cdd28e4d5a 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,17 @@ Command palette navigation shortcuts, including ⌃ P, are also customizable and
| ⌥ ⌘ I | Toggle Developer Tools (Safari default) |
| ⌥ ⌘ C | Show JavaScript Console (Safari default) |
+### Markdown Preview
+
+Markdown preview shortcuts are local to the focused Markdown panel and are customizable in `Settings → Keyboard Shortcuts`.
+
+| Shortcut | Action |
+|----------|--------|
+| H J K L | Scroll left/down/up/right |
+| ⌃ U / ⌃ D | Move up/down half a page |
+| / / ⇧ / | Search forward/backward |
+| N / ⇧ N | Find next/previous |
+
### Notifications
| Shortcut | Action |
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 528fb35c76c..89c67e9789e 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -113623,6 +113623,90 @@
"ja": { "stringUnit": { "state": "translated", "value": "グローバル検索" } }
}
},
+ "shortcut.markdownScrollLeft.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Left" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 左へスクロール" } }
+ }
+ },
+ "shortcut.markdownScrollDown.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Down" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へスクロール" } }
+ }
+ },
+ "shortcut.markdownScrollUp.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Up" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へスクロール" } }
+ }
+ },
+ "shortcut.markdownScrollRight.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Right" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 右へスクロール" } }
+ }
+ },
+ "shortcut.markdownPageUp.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Page Up" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へページ移動" } }
+ }
+ },
+ "shortcut.markdownPageDown.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Page Down" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へページ移動" } }
+ }
+ },
+ "shortcut.markdownFindForward.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Search Forward" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前方検索" } }
+ }
+ },
+ "shortcut.markdownFindBackward.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Search Backward" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 後方検索" } }
+ }
+ },
+ "shortcut.markdownFindNext.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Next" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 次を検索" } }
+ }
+ },
+ "shortcut.markdownFindPrevious.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Previous" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前を検索" } }
+ }
+ },
+ "markdown.web.searchPromptForward": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Search Markdown" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown を検索" } }
+ }
+ },
+ "markdown.web.searchPromptBackward": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Search Markdown backward" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown を後方検索" } }
+ }
+ },
"statusMenu.searchAllWindows": {
"extractionState": "manual",
"localizations": {
diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html
index 5d500626788..6b03649e2c8 100644
--- a/Resources/markdown-viewer/shell.html
+++ b/Resources/markdown-viewer/shell.html
@@ -1089,11 +1089,13 @@
function restoreMarkdownScrollState(state) {
if (!state) { return; }
+ if (state.nearTop) {
+ setMarkdownScrollY(0);
+ return;
+ }
function apply() {
var targetY;
- if (state.nearTop) {
- targetY = 0;
- } else if (state.nearBottom) {
+ if (state.nearBottom) {
targetY = markdownMaxScrollY();
} else if (state.anchor && state.anchor.id) {
var anchor = document.getElementById(state.anchor.id);
@@ -1417,6 +1419,94 @@
openMarkdownCandidate(rawPath);
}, true);
+ var markdownPreviewSearchQuery = '';
+
+ function markdownPreviewScroller() {
+ return document.scrollingElement || document.documentElement || document.body;
+ }
+
+ function markdownPreviewScrollBy(deltaX, deltaY) {
+ var scroller = markdownPreviewScroller();
+ var nextLeft = Math.max(0, (window.scrollX || scroller.scrollLeft || 0) + deltaX);
+ var nextTop = clampScrollY((window.scrollY || scroller.scrollTop || 0) + deltaY);
+ var root = document.documentElement;
+ var previousBehavior = root.style.scrollBehavior;
+ root.style.scrollBehavior = 'auto';
+ try {
+ window.scrollTo(nextLeft, nextTop);
+ } finally {
+ root.style.scrollBehavior = previousBehavior;
+ }
+ scroller.scrollLeft = nextLeft;
+ setMarkdownScrollY(nextTop);
+ return true;
+ }
+
+ function markdownPreviewScrollSelectionIntoView() {
+ if (!window.getSelection) { return; }
+ var selection = window.getSelection();
+ if (!selection || selection.rangeCount < 1) { return; }
+ var range = selection.getRangeAt(0);
+ var rect = range.getBoundingClientRect();
+ if (!rect || (!rect.top && !rect.bottom)) { return; }
+ var targetY = markdownScrollY() + rect.top - Math.max(24, Math.round(window.innerHeight * 0.25));
+ setMarkdownScrollY(targetY);
+ }
+
+ function markdownPreviewPromptSearch(backwards) {
+ var promptKey = backwards ? 'searchPromptBackward' : 'searchPromptForward';
+ var fallback = backwards ? 'Search Markdown backward' : 'Search Markdown';
+ var query = window.prompt(cmuxString(promptKey, fallback), markdownPreviewSearchQuery);
+ if (query == null) { return false; }
+ markdownPreviewSearchQuery = String(query);
+ return markdownPreviewSearch(markdownPreviewSearchQuery, backwards);
+ }
+
+ function markdownPreviewSearch(query, backwards) {
+ var value = String(query == null ? '' : query);
+ if (!value) { return false; }
+ markdownPreviewSearchQuery = value;
+ if (typeof window.find !== 'function') { return false; }
+ var found = !!window.find(value, false, !!backwards, true, false, false, false);
+ if (found) {
+ markdownPreviewScrollSelectionIntoView();
+ }
+ return found;
+ }
+
+ window.__cmuxMarkdownPreviewSearch = markdownPreviewSearch;
+
+ window.__cmuxMarkdownPreviewHandleKeyCommand = function(command) {
+ switch (command) {
+ case 'scrollLeft':
+ return markdownPreviewScrollBy(-72, 0);
+ case 'scrollDown':
+ return markdownPreviewScrollBy(0, 72);
+ case 'scrollUp':
+ return markdownPreviewScrollBy(0, -72);
+ case 'scrollRight':
+ return markdownPreviewScrollBy(72, 0);
+ case 'pageUp':
+ return markdownPreviewScrollBy(0, -Math.max(120, Math.round(window.innerHeight * 0.5)));
+ case 'pageDown':
+ return markdownPreviewScrollBy(0, Math.max(120, Math.round(window.innerHeight * 0.5)));
+ case 'findForward':
+ return markdownPreviewPromptSearch(false);
+ case 'findBackward':
+ return markdownPreviewPromptSearch(true);
+ case 'findNext':
+ return markdownPreviewSearchQuery
+ ? markdownPreviewSearch(markdownPreviewSearchQuery, false)
+ : markdownPreviewPromptSearch(false);
+ case 'findPrevious':
+ return markdownPreviewSearchQuery
+ ? markdownPreviewSearch(markdownPreviewSearchQuery, true)
+ : markdownPreviewPromptSearch(true);
+ default:
+ return false;
+ }
+ };
+
window.__cmuxRenderMarkdown = function(md) {
try {
headingSlugCounts = Object.create(null);
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index f0646bd7a2d..f6080f393f9 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -11014,7 +11014,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// here would swallow the first stroke and leave the second one
// orphaned, breaking that keystroke for the focused terminal/browser
// input.
- guard action != .showHideAllWindows && action != .globalSearch else { return false }
+ guard action != .showHideAllWindows,
+ action != .globalSearch,
+ !action.isSurfaceLocalShortcutAction else { return false }
return KeyboardShortcutSettings.shortcut(for: action).hasChord
}
}
diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift
index 43e9d3f4848..b5b81e1d55a 100644
--- a/Sources/KeyboardShortcutContext.swift
+++ b/Sources/KeyboardShortcutContext.swift
@@ -16,6 +16,7 @@ extension KeyboardShortcutSettings.Action {
case application
case nonBrowserPanel
case browserPanel
+ case markdownPanel
case rightSidebarFocus
var isAlwaysAvailable: Bool {
@@ -30,6 +31,8 @@ extension KeyboardShortcutSettings.Action {
return !focusedBrowserPanel && !rightSidebarFocused
case .browserPanel:
return focusedBrowserPanel
+ case .markdownPanel:
+ return false
case .rightSidebarFocus:
return rightSidebarFocused
}
@@ -49,6 +52,17 @@ extension KeyboardShortcutSettings.Action {
var shortcutContext: ShortcutContext {
switch self {
+ case .markdownScrollLeft,
+ .markdownScrollDown,
+ .markdownScrollUp,
+ .markdownScrollRight,
+ .markdownPageUp,
+ .markdownPageDown,
+ .markdownFindForward,
+ .markdownFindBackward,
+ .markdownFindNext,
+ .markdownFindPrevious:
+ return .markdownPanel
case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock:
return .rightSidebarFocus
case .renameTab, .renameWorkspace:
diff --git a/Sources/KeyboardShortcutRecorder.swift b/Sources/KeyboardShortcutRecorder.swift
index d44998d2a3d..5a0adb25b31 100644
--- a/Sources/KeyboardShortcutRecorder.swift
+++ b/Sources/KeyboardShortcutRecorder.swift
@@ -7,6 +7,7 @@ struct KeyboardShortcutRecorder: View {
var subtitle: String? = nil
@Binding var shortcut: StoredShortcut
var displayString: (StoredShortcut) -> String = { $0.displayString }
+ var requireFirstStrokeModifier: Bool = true
var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = {
.accepted($0)
}
@@ -41,6 +42,7 @@ struct KeyboardShortcutRecorder: View {
isRecording: $isRecording,
hasPendingRejection: hasPendingRejection,
displayString: displayString,
+ requireFirstStrokeModifier: requireFirstStrokeModifier,
transformRecordedShortcut: transformRecordedShortcut,
onRecordingChanged: onRecordingChanged,
onRecorderFeedbackChanged: onRecorderFeedbackChanged
@@ -130,6 +132,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable {
@Binding var isRecording: Bool
var hasPendingRejection: Bool = false
let displayString: (StoredShortcut) -> String
+ let requireFirstStrokeModifier: Bool
let transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution
let onRecordingChanged: (Bool) -> Void
let onRecorderFeedbackChanged: (ShortcutRecorderRejectedAttempt?) -> Void
@@ -138,6 +141,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable {
let button = ShortcutRecorderNSButton()
button.shortcut = shortcut
button.displayString = displayString
+ button.requireFirstStrokeModifier = requireFirstStrokeModifier
button.transformRecordedShortcut = transformRecordedShortcut
button.onShortcutRecorded = { newShortcut in
shortcut = newShortcut
@@ -155,6 +159,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable {
func updateNSView(_ nsView: ShortcutRecorderNSButton, context: Context) {
nsView.shortcut = shortcut
nsView.displayString = displayString
+ nsView.requireFirstStrokeModifier = requireFirstStrokeModifier
nsView.transformRecordedShortcut = transformRecordedShortcut
nsView.onRecordingChanged = { recording in
isRecording = recording
@@ -179,6 +184,7 @@ final class ShortcutRecorderNSButton: NSButton {
}
}
var displayString: (StoredShortcut) -> String = { $0.displayString }
+ var requireFirstStrokeModifier = true
var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = {
.accepted($0)
}
@@ -308,7 +314,7 @@ final class ShortcutRecorderNSButton: NSButton {
}
if pendingChordStart == nil {
- switch ShortcutStroke.recordingResult(from: event, requireModifier: true) {
+ switch ShortcutStroke.recordingResult(from: event, requireModifier: requireFirstStrokeModifier) {
case let .accepted(firstStroke):
let firstShortcut = StoredShortcut(first: firstStroke)
switch transformRecordedShortcut(firstShortcut) {
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index a749fb7d5fa..7a124728547 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -123,6 +123,16 @@ enum KeyboardShortcutSettings {
// Panels
case saveFilePreview
+ case markdownScrollLeft
+ case markdownScrollDown
+ case markdownScrollUp
+ case markdownScrollRight
+ case markdownPageUp
+ case markdownPageDown
+ case markdownFindForward
+ case markdownFindBackward
+ case markdownFindNext
+ case markdownFindPrevious
case openBrowser
case focusBrowserAddressBar
case browserBack
@@ -201,6 +211,16 @@ enum KeyboardShortcutSettings {
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .toggleRightSidebar: return String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar")
case .saveFilePreview: return String(localized: "shortcut.saveFilePreview.label", defaultValue: "Save File Preview")
+ case .markdownScrollLeft: return String(localized: "shortcut.markdownScrollLeft.label", defaultValue: "Markdown Preview: Scroll Left")
+ case .markdownScrollDown: return String(localized: "shortcut.markdownScrollDown.label", defaultValue: "Markdown Preview: Scroll Down")
+ case .markdownScrollUp: return String(localized: "shortcut.markdownScrollUp.label", defaultValue: "Markdown Preview: Scroll Up")
+ case .markdownScrollRight: return String(localized: "shortcut.markdownScrollRight.label", defaultValue: "Markdown Preview: Scroll Right")
+ case .markdownPageUp: return String(localized: "shortcut.markdownPageUp.label", defaultValue: "Markdown Preview: Page Up")
+ case .markdownPageDown: return String(localized: "shortcut.markdownPageDown.label", defaultValue: "Markdown Preview: Page Down")
+ case .markdownFindForward: return String(localized: "shortcut.markdownFindForward.label", defaultValue: "Markdown Preview: Search Forward")
+ case .markdownFindBackward: return String(localized: "shortcut.markdownFindBackward.label", defaultValue: "Markdown Preview: Search Backward")
+ case .markdownFindNext: return String(localized: "shortcut.markdownFindNext.label", defaultValue: "Markdown Preview: Find Next")
+ case .markdownFindPrevious: return String(localized: "shortcut.markdownFindPrevious.label", defaultValue: "Markdown Preview: Find Previous")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar")
case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back")
@@ -349,6 +369,26 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false)
case .saveFilePreview:
return StoredShortcut(key: "s", command: true, shift: false, option: false, control: false)
+ case .markdownScrollLeft:
+ return StoredShortcut(key: "h", command: false, shift: false, option: false, control: false)
+ case .markdownScrollDown:
+ return StoredShortcut(key: "j", command: false, shift: false, option: false, control: false)
+ case .markdownScrollUp:
+ return StoredShortcut(key: "k", command: false, shift: false, option: false, control: false)
+ case .markdownScrollRight:
+ return StoredShortcut(key: "l", command: false, shift: false, option: false, control: false)
+ case .markdownPageUp:
+ return StoredShortcut(key: "u", command: false, shift: false, option: false, control: true)
+ case .markdownPageDown:
+ return StoredShortcut(key: "d", command: false, shift: false, option: false, control: true)
+ case .markdownFindForward:
+ return StoredShortcut(key: "/", command: false, shift: false, option: false, control: false)
+ case .markdownFindBackward:
+ return StoredShortcut(key: "/", command: false, shift: true, option: false, control: false)
+ case .markdownFindNext:
+ return StoredShortcut(key: "n", command: false, shift: false, option: false, control: false)
+ case .markdownFindPrevious:
+ return StoredShortcut(key: "n", command: false, shift: true, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .focusBrowserAddressBar:
@@ -401,6 +441,42 @@ enum KeyboardShortcutSettings {
}
}
+ var allowsBareFirstStroke: Bool {
+ switch self {
+ case .markdownScrollLeft,
+ .markdownScrollDown,
+ .markdownScrollUp,
+ .markdownScrollRight,
+ .markdownPageUp,
+ .markdownPageDown,
+ .markdownFindForward,
+ .markdownFindBackward,
+ .markdownFindNext,
+ .markdownFindPrevious:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var isSurfaceLocalShortcutAction: Bool {
+ switch self {
+ case .markdownScrollLeft,
+ .markdownScrollDown,
+ .markdownScrollUp,
+ .markdownScrollRight,
+ .markdownPageUp,
+ .markdownPageDown,
+ .markdownFindForward,
+ .markdownFindBackward,
+ .markdownFindNext,
+ .markdownFindPrevious:
+ return true
+ default:
+ return false
+ }
+ }
+
func displayedShortcutString(for shortcut: StoredShortcut) -> String {
if shortcut.isUnbound {
return shortcut.displayString
@@ -464,6 +540,12 @@ enum KeyboardShortcutSettings {
return .accepted(.unbound)
}
+ if !allowsBareFirstStroke,
+ shortcut.firstStroke.modifierFlags.isEmpty,
+ shortcut.firstStroke.key != "space" {
+ return .rejected(.bareKeyNotAllowed)
+ }
+
switch self {
case .showHideAllWindows, .globalSearch:
return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcutResult(
@@ -728,6 +810,8 @@ enum KeyboardShortcutSettings {
switch action.resolvedRecordedShortcutIgnoringConflicts(shortcut) {
case let .accepted(normalizedShortcut):
return normalizedShortcut
+ case .rejected(.bareKeyNotAllowed):
+ return nil
case .rejected:
if action.usesNumberedDigitMatching || action == .showHideAllWindows || action == .globalSearch {
return nil
@@ -2244,14 +2328,14 @@ extension ShortcutStroke {
}
extension StoredShortcut {
- static func parseConfig(_ rawValue: String) -> StoredShortcut? {
+ static func parseConfig(_ rawValue: String, allowBareFirstStroke: Bool = false) -> StoredShortcut? {
if isUnboundConfigToken(rawValue) {
return .unbound
}
- return parseConfig(strokes: [rawValue])
+ return parseConfig(strokes: [rawValue], allowBareFirstStroke: allowBareFirstStroke)
}
- static func parseConfig(strokes: [String]) -> StoredShortcut? {
+ static func parseConfig(strokes: [String], allowBareFirstStroke: Bool = false) -> StoredShortcut? {
guard !strokes.isEmpty, strokes.count <= 2 else { return nil }
if strokes.count == 1, let rawValue = strokes.first, isUnboundConfigToken(rawValue) {
return .unbound
@@ -2260,7 +2344,7 @@ extension StoredShortcut {
guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else {
return nil
}
- guard !firstStroke.modifierFlags.isEmpty || firstStroke.key == "space" else { return nil }
+ guard allowBareFirstStroke || !firstStroke.modifierFlags.isEmpty || firstStroke.key == "space" else { return nil }
let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil
return StoredShortcut(first: firstStroke, second: secondStroke)
}
diff --git a/Sources/KeyboardShortcutSettingsControls.swift b/Sources/KeyboardShortcutSettingsControls.swift
index caff0b6af3a..b47ea5df647 100644
--- a/Sources/KeyboardShortcutSettingsControls.swift
+++ b/Sources/KeyboardShortcutSettingsControls.swift
@@ -192,6 +192,7 @@ struct ShortcutRecorderSettingsControl: View {
subtitle: subtitle,
shortcut: $shortcut,
displayString: displayString,
+ requireFirstStrokeModifier: !action.allowsBareFirstStroke,
transformRecordedShortcut: { action.normalizedRecordedShortcutResult($0) },
validationMessage: validationPresentation?.message,
validationButtonTitle: validationPresentation?.swapButtonTitle,
diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift
index f6a2620b9a6..5c73ca12d88 100644
--- a/Sources/KeyboardShortcutSettingsFileStore.swift
+++ b/Sources/KeyboardShortcutSettingsFileStore.swift
@@ -859,9 +859,19 @@ final class CmuxSettingsFileStore {
) -> StoredShortcut? {
let shortcut: StoredShortcut? = {
if rawValue is NSNull { return .unbound }
- if let stroke = jsonString(rawValue) { return StoredShortcut.parseConfig(stroke) }
+ if let stroke = jsonString(rawValue) {
+ return StoredShortcut.parseConfig(
+ stroke,
+ allowBareFirstStroke: action.allowsBareFirstStroke
+ )
+ }
if let strokes = jsonStringArray(rawValue) {
- return strokes.isEmpty ? .unbound : StoredShortcut.parseConfig(strokes: strokes)
+ return strokes.isEmpty
+ ? .unbound
+ : StoredShortcut.parseConfig(
+ strokes: strokes,
+ allowBareFirstStroke: action.allowsBareFirstStroke
+ )
}
return nil
}()
diff --git a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
new file mode 100644
index 00000000000..c882e58d853
--- /dev/null
+++ b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
@@ -0,0 +1,84 @@
+import AppKit
+
+enum MarkdownPreviewKeyCommand: String, CaseIterable {
+ case scrollLeft
+ case scrollDown
+ case scrollUp
+ case scrollRight
+ case pageUp
+ case pageDown
+ case findForward
+ case findBackward
+ case findNext
+ case findPrevious
+}
+
+enum MarkdownPreviewKeyboardShortcutResolver {
+ typealias ShortcutProvider = (KeyboardShortcutSettings.Action) -> StoredShortcut
+
+ private static let commandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] = [
+ (.scrollLeft, .markdownScrollLeft),
+ (.scrollDown, .markdownScrollDown),
+ (.scrollUp, .markdownScrollUp),
+ (.scrollRight, .markdownScrollRight),
+ (.pageUp, .markdownPageUp),
+ (.pageDown, .markdownPageDown),
+ (.findForward, .markdownFindForward),
+ (.findBackward, .markdownFindBackward),
+ (.findNext, .markdownFindNext),
+ (.findPrevious, .markdownFindPrevious),
+ ]
+
+ static var actions: [KeyboardShortcutSettings.Action] {
+ commandActions.map(\.1)
+ }
+
+ static func command(
+ for event: NSEvent,
+ pendingFirstStroke: ShortcutStroke? = nil,
+ shortcutForAction: ShortcutProvider = KeyboardShortcutSettings.shortcut(for:)
+ ) -> MarkdownPreviewKeyCommand? {
+ if let pendingFirstStroke {
+ return chordCommand(
+ for: event,
+ pendingFirstStroke: pendingFirstStroke,
+ shortcutForAction: shortcutForAction
+ )
+ }
+
+ for (command, action) in commandActions {
+ let shortcut = shortcutForAction(action)
+ guard !shortcut.hasChord, shortcut.matches(event: event) else { continue }
+ return command
+ }
+ return nil
+ }
+
+ static func chordPrefix(
+ for event: NSEvent,
+ shortcutForAction: ShortcutProvider = KeyboardShortcutSettings.shortcut(for:)
+ ) -> ShortcutStroke? {
+ for (_, action) in commandActions {
+ let shortcut = shortcutForAction(action)
+ guard shortcut.hasChord,
+ shortcut.firstStroke.matches(event: event) else { continue }
+ return shortcut.firstStroke
+ }
+ return nil
+ }
+
+ private static func chordCommand(
+ for event: NSEvent,
+ pendingFirstStroke: ShortcutStroke,
+ shortcutForAction: ShortcutProvider
+ ) -> MarkdownPreviewKeyCommand? {
+ for (command, action) in commandActions {
+ let shortcut = shortcutForAction(action)
+ guard let secondStroke = shortcut.secondStroke,
+ shortcut.firstStroke == pendingFirstStroke,
+ secondStroke.matches(event: event) else { continue }
+ return command
+ }
+ return nil
+ }
+}
diff --git a/Sources/Panels/MarkdownViewerAssets.swift b/Sources/Panels/MarkdownViewerAssets.swift
index 6f1b1b39e42..a69570e2bfd 100644
--- a/Sources/Panels/MarkdownViewerAssets.swift
+++ b/Sources/Panels/MarkdownViewerAssets.swift
@@ -121,6 +121,14 @@ final class MarkdownViewerAssets {
"remoteImageURL": String(
localized: "markdown.web.remoteImageURL",
defaultValue: "Image URL: {url}"
+ ),
+ "searchPromptForward": String(
+ localized: "markdown.web.searchPromptForward",
+ defaultValue: "Search Markdown"
+ ),
+ "searchPromptBackward": String(
+ localized: "markdown.web.searchPromptBackward",
+ defaultValue: "Search Markdown backward"
)
]
guard let data = try? JSONSerialization.data(withJSONObject: strings),
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index 1857497151c..5c278ab81fd 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -25,6 +25,9 @@ struct MarkdownWebRenderer: NSViewRepresentable {
webView.removeFromSuperview()
}
webView.onPointerDown = onRequestPanelFocus
+ webView.onKeyboardShortcut = { [weak coordinator = context.coordinator] event in
+ coordinator?.handleKeyboardShortcut(event) ?? false
+ }
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
applyBackground(to: webView)
@@ -48,6 +51,9 @@ struct MarkdownWebRenderer: NSViewRepresentable {
)
let webView = MarkdownWebView(frame: .zero, configuration: config)
webView.onPointerDown = onRequestPanelFocus
+ webView.onKeyboardShortcut = { [weak coordinator = context.coordinator] event in
+ coordinator?.handleKeyboardShortcut(event) ?? false
+ }
webView.setValue(false, forKey: "drawsBackground")
applyBackground(to: webView)
webView.allowsBackForwardNavigationGestures = false
@@ -73,6 +79,9 @@ struct MarkdownWebRenderer: NSViewRepresentable {
// the panel-owned renderer session kept the same coordinator.
context.coordinator.bind(panelId: panelId, workspaceId: workspaceId, filePath: filePath)
(nsView as? MarkdownWebView)?.onPointerDown = onRequestPanelFocus
+ (nsView as? MarkdownWebView)?.onKeyboardShortcut = { [weak coordinator = context.coordinator] event in
+ coordinator?.handleKeyboardShortcut(event) ?? false
+ }
applyBackground(to: nsView)
applyAppearance(to: nsView, isDark: theme.isDark)
context.coordinator.update(markdown: markdown, theme: theme)
@@ -86,6 +95,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
nsView.navigationDelegate = nil
nsView.uiDelegate = nil
(nsView as? MarkdownWebView)?.onPointerDown = nil
+ (nsView as? MarkdownWebView)?.onKeyboardShortcut = nil
coordinator.cancelImageLoads()
}
@@ -120,6 +130,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
private var isShellLoading = false
private var webContentProcessRecoveryAttempts = 0
private let maxWebContentProcessRecoveryAttempts = 2
+ private var pendingShortcutChord: ShortcutStroke?
private struct ImageLoadResult {
let data: Data
@@ -160,11 +171,13 @@ struct MarkdownWebRenderer: NSViewRepresentable {
webView.navigationDelegate = nil
webView.uiDelegate = nil
webView.onPointerDown = nil
+ webView.onKeyboardShortcut = nil
}
self.webView = nil
isLoaded = false
isShellLoading = false
webContentProcessRecoveryAttempts = 0
+ pendingShortcutChord = nil
cancelImageLoads()
requestedLibs.removeAll()
}
@@ -273,6 +286,42 @@ struct MarkdownWebRenderer: NSViewRepresentable {
webView.evaluateJavaScript(js, completionHandler: nil)
}
+ func handleKeyboardShortcut(_ event: NSEvent) -> Bool {
+ if let pendingShortcutChord {
+ defer { self.pendingShortcutChord = nil }
+ guard let command = MarkdownPreviewKeyboardShortcutResolver.command(
+ for: event,
+ pendingFirstStroke: pendingShortcutChord
+ ) else {
+ return false
+ }
+ performKeyboardCommand(command)
+ return true
+ }
+
+ if let command = MarkdownPreviewKeyboardShortcutResolver.command(for: event) {
+ performKeyboardCommand(command)
+ return true
+ }
+
+ if let prefix = MarkdownPreviewKeyboardShortcutResolver.chordPrefix(for: event) {
+ pendingShortcutChord = prefix
+ return true
+ }
+
+ return false
+ }
+
+ private func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+ guard let webView else { return }
+ let commandLiteral = (try? JSONSerialization.data(withJSONObject: [command.rawValue]))
+ .flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
+ webView.evaluateJavaScript(
+ "window.__cmuxMarkdownPreviewHandleKeyCommand && window.__cmuxMarkdownPreviewHandleKeyCommand(\(commandLiteral)[0]);",
+ completionHandler: nil
+ )
+ }
+
// MARK: Bridge
private func pushMarkdown(_ markdown: String) {
diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift
index 27d2a5ea031..d6564e2c8b1 100644
--- a/Sources/Panels/MarkdownWebSupport.swift
+++ b/Sources/Panels/MarkdownWebSupport.swift
@@ -20,6 +20,7 @@ final class WeakMarkdownScriptMessageHandler: NSObject, WKScriptMessageHandler {
@MainActor
final class MarkdownWebView: WKWebView {
var onPointerDown: (() -> Void)?
+ var onKeyboardShortcut: ((NSEvent) -> Bool)?
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
PaneFirstClickFocusSettings.isEnabled()
@@ -29,6 +30,20 @@ final class MarkdownWebView: WKWebView {
onPointerDown?()
super.mouseDown(with: event)
}
+
+ override func keyDown(with event: NSEvent) {
+ if onKeyboardShortcut?(event) == true {
+ return
+ }
+ super.keyDown(with: event)
+ }
+
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
+ if onKeyboardShortcut?(event) == true {
+ return true
+ }
+ return super.performKeyEquivalent(with: event)
+ }
}
struct MarkdownWebTheme: Equatable {
diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj
index 4c6c9e734fa..6ff6e5be2b4 100644
--- a/cmux.xcodeproj/project.pbxproj
+++ b/cmux.xcodeproj/project.pbxproj
@@ -289,6 +289,7 @@
A50014F5 /* MarkdownWebRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F4 /* MarkdownWebRenderer.swift */; };
A50014F7 /* MarkdownRemoteImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F6 /* MarkdownRemoteImageLoader.swift */; };
A50014F9 /* MarkdownWebSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F8 /* MarkdownWebSupport.swift */; };
+ A50014FB /* MarkdownPreviewKeyboardShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014FA /* MarkdownPreviewKeyboardShortcuts.swift */; };
A5001422 /* FilePreviewPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001423 /* FilePreviewPanel.swift */; };
BB49DF25341706C2A892C27C /* PanelOwnedNativeViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE314D9700F6F1C8D4A2FE11 /* PanelOwnedNativeViewSession.swift */; };
904D6350B3BB50DEDED3F410 /* FilePreviewNativeViewSessions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3615490F822CA0C8527C9 /* FilePreviewNativeViewSessions.swift */; };
@@ -841,6 +842,7 @@
A50014F4 /* MarkdownWebRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebRenderer.swift; sourceTree = ""; };
A50014F6 /* MarkdownRemoteImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownRemoteImageLoader.swift; sourceTree = ""; };
A50014F8 /* MarkdownWebSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebSupport.swift; sourceTree = ""; };
+ A50014FA /* MarkdownPreviewKeyboardShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPreviewKeyboardShortcuts.swift; sourceTree = ""; };
A5001423 /* FilePreviewPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewPanel.swift; sourceTree = ""; };
CE314D9700F6F1C8D4A2FE11 /* PanelOwnedNativeViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelOwnedNativeViewSession.swift; sourceTree = ""; };
81E3615490F822CA0C8527C9 /* FilePreviewNativeViewSessions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/FilePreviewNativeViewSessions.swift; sourceTree = ""; };
@@ -1380,6 +1382,7 @@
A5001418 /* MarkdownPanel.swift */,
A50014F0 /* MarkdownPanelFileLinkResolver.swift */,
A5001419 /* MarkdownPanelView.swift */,
+ A50014FA /* MarkdownPreviewKeyboardShortcuts.swift */,
A50014F4 /* MarkdownWebRenderer.swift */,
A50014F6 /* MarkdownRemoteImageLoader.swift */,
A50014F8 /* MarkdownWebSupport.swift */,
@@ -2117,6 +2120,7 @@
A5001420 /* MarkdownPanel.swift in Sources */,
A50014F2 /* MarkdownPanelFileLinkResolver.swift in Sources */,
A5001421 /* MarkdownPanelView.swift in Sources */,
+ A50014FB /* MarkdownPreviewKeyboardShortcuts.swift in Sources */,
A50014F5 /* MarkdownWebRenderer.swift in Sources */,
A50014F7 /* MarkdownRemoteImageLoader.swift in Sources */,
A50014F9 /* MarkdownWebSupport.swift in Sources */,
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index b54b1d62c1b..3b19783b5e3 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -252,6 +252,197 @@ final class MarkdownPanelTests: XCTestCase {
XCTAssertEqual(discardedPointerDownCount, 0)
}
+ func testMarkdownPreviewKeyboardShortcutsUseVimDefaults() throws {
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("h", keyCode: 4)),
+ .scrollLeft
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("j", keyCode: 38)),
+ .scrollDown
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("k", keyCode: 40)),
+ .scrollUp
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("l", keyCode: 37)),
+ .scrollRight
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("u", keyCode: 32, modifiers: [.control])
+ ),
+ .pageUp
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("d", keyCode: 2, modifiers: [.control])
+ ),
+ .pageDown
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("/", keyCode: 44)),
+ .findForward
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("?", charactersIgnoringModifiers: "/", keyCode: 44, modifiers: [.shift])
+ ),
+ .findBackward
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("n", keyCode: 45)),
+ .findNext
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("N", charactersIgnoringModifiers: "n", keyCode: 45, modifiers: [.shift])
+ ),
+ .findPrevious
+ )
+ }
+
+ func testMarkdownPreviewKeyboardShortcutsCanUseBareCmuxJSONBindings() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-shortcuts-\(UUID().uuidString).json")
+ try """
+ {
+ "shortcuts": {
+ "bindings": {
+ "markdownScrollDown": "g",
+ "markdownFindForward": ["ctrl+b", "/"],
+ "toggleSidebar": "x"
+ }
+ }
+ }
+ """.write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let originalStore = KeyboardShortcutSettings.settingsFileStore
+ KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
+ primaryPath: fileURL.path,
+ fallbackPath: nil,
+ additionalFallbackPaths: [],
+ startWatching: false
+ )
+ defer {
+ KeyboardShortcutSettings.settingsFileStore = originalStore
+ KeyboardShortcutSettings.resetAll()
+ }
+
+ XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .markdownScrollDown).configIdentifier, "g")
+ XCTAssertEqual(
+ KeyboardShortcutSettings.shortcut(for: .markdownFindForward).configIdentifier,
+ "ctrl+b /"
+ )
+ XCTAssertFalse(KeyboardShortcutSettings.isManagedBySettingsFile(.toggleSidebar))
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("g", keyCode: 5)),
+ .scrollDown
+ )
+ let prefix = MarkdownPreviewKeyboardShortcutResolver.chordPrefix(
+ for: try keyDownEvent("b", keyCode: 11, modifiers: [.control])
+ )
+ XCTAssertEqual(prefix, ShortcutStroke(key: "b", command: false, shift: false, option: false, control: true))
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("/", keyCode: 44),
+ pendingFirstStroke: try XCTUnwrap(prefix)
+ ),
+ .findForward
+ )
+ }
+
+ func testMarkdownPreviewKeyCommandHandlersScrollAndSearch() async throws {
+ let frame = NSRect(x: 0, y: 0, width: 720, height: 360)
+ let webView = WKWebView(frame: frame, configuration: WKWebViewConfiguration())
+ let window = NSWindow(contentRect: frame, styleMask: [.borderless], backing: .buffered, defer: false)
+ window.contentView = webView
+ window.orderFrontRegardless()
+ defer {
+ webView.navigationDelegate = nil
+ window.close()
+ }
+
+ let loaded = expectation(description: "markdown shell loaded")
+ let loadDelegate = MarkdownShellLoadDelegate(expectation: loaded)
+ webView.navigationDelegate = loadDelegate
+ webView.loadHTMLString(
+ MarkdownViewerAssets.shared.shellHTML(isDark: true),
+ baseURL: FileManager.default.temporaryDirectory.appendingPathComponent("keyboard.md")
+ )
+ await fulfillment(of: [loaded], timeout: 5)
+ if let error = loadDelegate.error {
+ throw error
+ }
+ defer {
+ webView.stopLoading()
+ webView.removeFromSuperview()
+ }
+
+ try await renderMarkdown(scrollSmokeMarkdown(extraBeforeSection20: false), in: webView)
+ let before = try await evaluateScrollSnapshot(
+ """
+ (function() {
+ var scroller = document.scrollingElement || document.documentElement;
+ return { y: window.scrollY || scroller.scrollTop };
+ })();
+ """,
+ in: webView
+ )
+ _ = try await webView.evaluateJavaScript("window.__cmuxMarkdownPreviewHandleKeyCommand('scrollDown');")
+ let afterScroll = try await evaluateScrollSnapshot(
+ """
+ (function() {
+ var scroller = document.scrollingElement || document.documentElement;
+ return { y: window.scrollY || scroller.scrollTop };
+ })();
+ """,
+ in: webView
+ )
+ XCTAssertGreaterThan(afterScroll["y"] ?? 0, before["y"] ?? 0)
+
+ let searchSnapshot = try await evaluateScrollSnapshot(
+ """
+ (function() {
+ var calls = [];
+ Object.defineProperty(window, 'find', {
+ configurable: true,
+ value: function(query, caseSensitive, backwards, wrap, wholeWord, searchInFrames, showDialog) {
+ calls.push({
+ query: String(query),
+ backwards: backwards ? 1 : 0,
+ wrap: wrap ? 1 : 0
+ });
+ return true;
+ }
+ });
+ var directFound = window.__cmuxMarkdownPreviewSearch('Section 20', false) ? 1 : 0;
+ var nextFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findNext') ? 1 : 0;
+ var previousFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findPrevious') ? 1 : 0;
+ return {
+ calls: calls.length,
+ firstFound: directFound,
+ nextFound: nextFound,
+ previousFound: previousFound,
+ firstBackwards: calls[0] ? calls[0].backwards : -1,
+ lastBackwards: calls[2] ? calls[2].backwards : -1,
+ firstWrap: calls[0] ? calls[0].wrap : -1
+ };
+ })();
+ """,
+ in: webView
+ )
+ XCTAssertEqual(searchSnapshot["calls"] ?? 0, 3)
+ XCTAssertEqual(searchSnapshot["firstFound"] ?? 0, 1)
+ XCTAssertEqual(searchSnapshot["nextFound"] ?? 0, 1)
+ XCTAssertEqual(searchSnapshot["previousFound"] ?? 0, 1)
+ XCTAssertEqual(searchSnapshot["firstBackwards"] ?? -1, 0)
+ XCTAssertEqual(searchSnapshot["lastBackwards"] ?? -1, 1)
+ XCTAssertEqual(searchSnapshot["firstWrap"] ?? -1, 1)
+ }
+
func testMarkdownRendererKeepsRecoveryBudgetAfterShellReload() {
let coordinator = MarkdownWebRenderer.Coordinator()
let webView = MarkdownWebView(frame: .zero, configuration: WKWebViewConfiguration())
@@ -1154,6 +1345,28 @@ final class MarkdownPanelTests: XCTestCase {
_ = try await webView.evaluateJavaScript("window.__cmuxRenderMarkdown(\(literal)[0]);")
}
+ private func keyDownEvent(
+ _ characters: String,
+ charactersIgnoringModifiers: String? = nil,
+ keyCode: UInt16,
+ modifiers: NSEvent.ModifierFlags = []
+ ) throws -> NSEvent {
+ try XCTUnwrap(
+ NSEvent.keyEvent(
+ with: .keyDown,
+ location: .zero,
+ modifierFlags: modifiers,
+ timestamp: ProcessInfo.processInfo.systemUptime,
+ windowNumber: 0,
+ context: nil,
+ characters: characters,
+ charactersIgnoringModifiers: charactersIgnoringModifiers ?? characters,
+ isARepeat: false,
+ keyCode: keyCode
+ )
+ )
+ }
+
private func evaluateScrollSnapshot(_ script: String, in webView: WKWebView) async throws -> [String: Double] {
let result = try await webView.evaluateJavaScript(script)
let raw = try XCTUnwrap(result as? [String: Any])
diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx
index 60077d6421e..526100e6ae9 100644
--- a/web/app/[locale]/docs/configuration/page.tsx
+++ b/web/app/[locale]/docs/configuration/page.tsx
@@ -84,6 +84,8 @@ const settingsFileExample = `{
// "bindings": {
// "toggleSidebar": "cmd+b",
// "toggleFileExplorer": "cmd+opt+b",
+ // "markdownScrollDown": "j",
+ // "markdownFindForward": "/",
// "newTab": ["ctrl+b", "c"],
// "commandPalettePrevious": null
// }
@@ -366,7 +368,8 @@ working-directory = ~/code`}
unbound, and disabled. Example chord:{" "}
["ctrl+b", "c"]. Numbered actions use{" "}
1 as the stored default and still match digits 1 through{" "}
- 9.
+ 9. Bare first strokes such as j and / are accepted
+ for Markdown preview actions because they are only active inside the focused Markdown panel.
The defaults below are the same cmux-owned actions listed on the{" "}
diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx
index 3c1d6a17064..e6321e33f00 100644
--- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx
+++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx
@@ -12,6 +12,7 @@ const shortcutChordExample = `{
"bindings": {
"newSurface": ["ctrl+b", "c"],
"showNotifications": ["ctrl+b", "i"],
+ "markdownFindForward": "/",
"toggleSidebar": "cmd+b",
"toggleFileExplorer": "cmd+opt+b",
"splitRight": "",
diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts
index 4dff87f634b..7e9850ec670 100644
--- a/web/data/cmux-shortcuts.ts
+++ b/web/data/cmux-shortcuts.ts
@@ -122,6 +122,23 @@ export const shortcutCategories: ShortcutCategory[] = [
},
],
},
+ {
+ id: "markdown-preview",
+ titleKey: "markdownPreview",
+ blurbKey: "markdownPreviewBlurb",
+ shortcuts: [
+ { id: "markdownScrollLeft", combos: [["H"]], description: { en: "Scroll Markdown preview left", ja: "Markdown プレビューを左へスクロール" } },
+ { id: "markdownScrollDown", combos: [["J"]], description: { en: "Scroll Markdown preview down", ja: "Markdown プレビューを下へスクロール" } },
+ { id: "markdownScrollUp", combos: [["K"]], description: { en: "Scroll Markdown preview up", ja: "Markdown プレビューを上へスクロール" } },
+ { id: "markdownScrollRight", combos: [["L"]], description: { en: "Scroll Markdown preview right", ja: "Markdown プレビューを右へスクロール" } },
+ { id: "markdownPageUp", combos: [["⌃", "U"]], description: { en: "Move Markdown preview up half a page", ja: "Markdown プレビューを半ページ上へ移動" } },
+ { id: "markdownPageDown", combos: [["⌃", "D"]], description: { en: "Move Markdown preview down half a page", ja: "Markdown プレビューを半ページ下へ移動" } },
+ { id: "markdownFindForward", combos: [["/"]], description: { en: "Search Markdown preview forward", ja: "Markdown プレビューを前方検索" } },
+ { id: "markdownFindBackward", combos: [["⇧", "/"]], description: { en: "Search Markdown preview backward", ja: "Markdown プレビューを後方検索" } },
+ { id: "markdownFindNext", combos: [["N"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
+ { id: "markdownFindPrevious", combos: [["⇧", "N"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
+ ],
+ },
{
id: "split-panes",
titleKey: "splitPanes",
diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json
index 4c7289bf928..75f8335f790 100644
--- a/web/data/cmux.schema.json
+++ b/web/data/cmux.schema.json
@@ -839,6 +839,16 @@
"splitBrowserDown",
"toggleFileExplorer",
"saveFilePreview",
+ "markdownScrollLeft",
+ "markdownScrollDown",
+ "markdownScrollUp",
+ "markdownScrollRight",
+ "markdownPageUp",
+ "markdownPageDown",
+ "markdownFindForward",
+ "markdownFindBackward",
+ "markdownFindNext",
+ "markdownFindPrevious",
"openBrowser",
"focusBrowserAddressBar",
"browserBack",
@@ -923,22 +933,8 @@
"enum": ["", "none", "clear", "unbound", "disabled"]
},
"shortcutFirstStroke": {
- "allOf": [
- {
- "$ref": "#/$defs/shortcutStroke"
- },
- {
- "anyOf": [
- {
- "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)+"
- },
- {
- "pattern": "^(?: |[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>)$"
- }
- ]
- }
- ],
- "description": "First or only shortcut stroke. Must include a modifier unless the key is Space."
+ "$ref": "#/$defs/shortcutStroke",
+ "description": "First or only shortcut stroke. Most actions require a modifier unless the key is Space. Markdown preview actions also accept bare first strokes such as h, j, k, l, /, and n."
},
"shortcutStroke": {
"type": "string",
diff --git a/web/messages/en.json b/web/messages/en.json
index e1516354f28..1172e327e85 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -550,6 +550,8 @@
"workspacesBlurb": "Workspaces live in the sidebar. Each workspace has its own set of panes and surfaces.",
"surfaces": "Surfaces",
"surfacesBlurb": "Surfaces are tabs inside a pane.",
+ "markdownPreview": "Markdown Preview",
+ "markdownPreviewBlurb": "Markdown preview shortcuts are local to the focused Markdown panel, so bare Vim-style keys do not affect terminals.",
"splitPanes": "Split Panes",
"browser": "Browser",
"notifications": "Notifications",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index b28010ba57a..c714e6a446d 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -482,6 +482,8 @@
"workspacesBlurb": "ワークスペースはサイドバーに表示されます。各ワークスペースには独自のペインとサーフェスがあります。",
"surfaces": "サーフェス",
"surfacesBlurb": "サーフェスはペイン内のタブです。",
+ "markdownPreview": "Markdown プレビュー",
+ "markdownPreviewBlurb": "Markdown プレビューのショートカットはフォーカス中の Markdown パネルだけで動作するため、Vim 風の単独キーはターミナルに影響しません。",
"splitPanes": "分割ペイン",
"browser": "ブラウザ",
"notifications": "通知",
From 0924e873e96865fbb6c523fc70a101116bc22445 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Tue, 19 May 2026 19:52:53 -0700
Subject: [PATCH 02/14] Address markdown shortcut review feedback
---
Sources/Panels/MarkdownWebRenderer.swift | 4 +--
cmuxTests/MarkdownPanelTests.swift | 33 ++++++++++++++++++++++++
web/messages/ar.json | 2 ++
web/messages/bs.json | 2 ++
web/messages/da.json | 2 ++
web/messages/de.json | 2 ++
web/messages/es.json | 2 ++
web/messages/fr.json | 2 ++
web/messages/it.json | 2 ++
web/messages/km.json | 2 ++
web/messages/ko.json | 2 ++
web/messages/no.json | 2 ++
web/messages/pl.json | 2 ++
web/messages/pt-BR.json | 2 ++
web/messages/ru.json | 2 ++
web/messages/th.json | 2 ++
web/messages/tr.json | 2 ++
web/messages/uk.json | 2 ++
web/messages/zh-CN.json | 2 ++
web/messages/zh-TW.json | 2 ++
20 files changed, 71 insertions(+), 2 deletions(-)
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index 5c278ab81fd..b36f03c5677 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -288,12 +288,12 @@ struct MarkdownWebRenderer: NSViewRepresentable {
func handleKeyboardShortcut(_ event: NSEvent) -> Bool {
if let pendingShortcutChord {
- defer { self.pendingShortcutChord = nil }
+ self.pendingShortcutChord = nil
guard let command = MarkdownPreviewKeyboardShortcutResolver.command(
for: event,
pendingFirstStroke: pendingShortcutChord
) else {
- return false
+ return !ShortcutStroke.isEscapeCancelEvent(event)
}
performKeyboardCommand(command)
return true
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index 3b19783b5e3..83eac033293 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -354,6 +354,39 @@ final class MarkdownPanelTests: XCTestCase {
)
}
+ func testMarkdownPreviewShortcutChordMissConsumesSecondStroke() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-shortcut-miss-\(UUID().uuidString).json")
+ try """
+ {
+ "shortcuts": {
+ "bindings": {
+ "markdownFindForward": ["ctrl+b", "/"]
+ }
+ }
+ }
+ """.write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let originalStore = KeyboardShortcutSettings.settingsFileStore
+ KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
+ primaryPath: fileURL.path,
+ fallbackPath: nil,
+ additionalFallbackPaths: [],
+ startWatching: false
+ )
+ defer {
+ KeyboardShortcutSettings.settingsFileStore = originalStore
+ KeyboardShortcutSettings.resetAll()
+ }
+
+ let coordinator = MarkdownWebRenderer.Coordinator()
+ XCTAssertTrue(
+ coordinator.handleKeyboardShortcut(try keyDownEvent("b", keyCode: 11, modifiers: [.control]))
+ )
+ XCTAssertTrue(coordinator.handleKeyboardShortcut(try keyDownEvent("j", keyCode: 38)))
+ }
+
func testMarkdownPreviewKeyCommandHandlersScrollAndSearch() async throws {
let frame = NSRect(x: 0, y: 0, width: 720, height: 360)
let webView = WKWebView(frame: frame, configuration: WKWebViewConfiguration())
diff --git a/web/messages/ar.json b/web/messages/ar.json
index 75a7669d032..3b9e2e18a58 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "مساحات العمل موجودة في الشريط الجانبي. كل مساحة عمل لها مجموعتها الخاصة من الألواح والأسطح.",
"surfaces": "الأسطح",
"surfacesBlurb": "الأسطح هي علامات تبويب داخل اللوح.",
+ "markdownPreview": "معاينة Markdown",
+ "markdownPreviewBlurb": "تعمل اختصارات معاينة Markdown داخل لوحة Markdown المركزة فقط، لذلك لا تؤثر مفاتيح Vim المنفردة على الطرفيات.",
"splitPanes": "الألواح المقسمة",
"browser": "المتصفح",
"notifications": "الإشعارات",
diff --git a/web/messages/bs.json b/web/messages/bs.json
index d651461f5f9..6d31967996a 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Radni prostori se nalaze u bočnoj traci. Svaki radni prostor ima vlastiti set panela i površina.",
"surfaces": "Površine",
"surfacesBlurb": "Površine su tabovi unutar panela.",
+ "markdownPreview": "Markdown pregled",
+ "markdownPreviewBlurb": "Prečice za Markdown pregled rade samo u fokusiranom Markdown panelu, pa samostalne Vim tipke ne utiču na terminale.",
"splitPanes": "Podijeljeni paneli",
"browser": "Preglednik",
"notifications": "Notifikacije",
diff --git a/web/messages/da.json b/web/messages/da.json
index dae091d2033..72f5af5e145 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspaces bor i sidebaren. Hvert workspace har sit eget sæt af paneler og surfaces.",
"surfaces": "Surfaces",
"surfacesBlurb": "Surfaces er faner inde i et panel.",
+ "markdownPreview": "Markdown-forhåndsvisning",
+ "markdownPreviewBlurb": "Genveje til Markdown-forhåndsvisning virker kun i det fokuserede Markdown-panel, så enkelte Vim-taster påvirker ikke terminaler.",
"splitPanes": "Opdelte paneler",
"browser": "Browser",
"notifications": "Notifikationer",
diff --git a/web/messages/de.json b/web/messages/de.json
index 1aeb6575e40..23ea4778ba0 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspaces befinden sich in der Seitenleiste. Jeder Workspace hat seine eigenen Bereiche und Oberflächen.",
"surfaces": "Oberflächen",
"surfacesBlurb": "Oberflächen sind Tabs innerhalb eines Bereichs.",
+ "markdownPreview": "Markdown-Vorschau",
+ "markdownPreviewBlurb": "Markdown-Vorschau-Kurzbefehle gelten nur im fokussierten Markdown-Panel, sodass einzelne Vim-Tasten Terminals nicht beeinflussen.",
"splitPanes": "Geteilte Bereiche",
"browser": "Browser",
"notifications": "Benachrichtigungen",
diff --git a/web/messages/es.json b/web/messages/es.json
index c3910d711b0..9b8e3f4cebb 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Los workspaces se encuentran en la barra lateral. Cada workspace tiene sus propios paneles y superficies.",
"surfaces": "Superficies",
"surfacesBlurb": "Las superficies son pestañas dentro de un panel.",
+ "markdownPreview": "Vista previa de Markdown",
+ "markdownPreviewBlurb": "Los atajos de la vista previa de Markdown solo funcionan en el panel de Markdown enfocado, por lo que las teclas Vim sueltas no afectan a las terminales.",
"splitPanes": "Paneles divididos",
"browser": "Navegador",
"notifications": "Notificaciones",
diff --git a/web/messages/fr.json b/web/messages/fr.json
index 6ec2d9e168d..975c2eed45f 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Les espaces de travail se trouvent dans la barre latérale. Chaque espace de travail a ses propres panneaux et surfaces.",
"surfaces": "Surfaces",
"surfacesBlurb": "Les surfaces sont des onglets à l'intérieur d'un panneau.",
+ "markdownPreview": "Aperçu Markdown",
+ "markdownPreviewBlurb": "Les raccourcis de l'aperçu Markdown ne fonctionnent que dans le panneau Markdown actif, donc les touches Vim seules n'affectent pas les terminaux.",
"splitPanes": "Panneaux divisés",
"browser": "Navigateur",
"notifications": "Notifications",
diff --git a/web/messages/it.json b/web/messages/it.json
index 910757b25d1..2413c38f058 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "I workspace si trovano nella barra laterale. Ogni workspace ha i propri pannelli e superfici.",
"surfaces": "Superfici",
"surfacesBlurb": "Le superfici sono tab all'interno di un pannello.",
+ "markdownPreview": "Anteprima Markdown",
+ "markdownPreviewBlurb": "Le scorciatoie dell'anteprima Markdown funzionano solo nel pannello Markdown attivo, quindi i tasti Vim singoli non influenzano i terminali.",
"splitPanes": "Pannelli divisi",
"browser": "Browser",
"notifications": "Notifiche",
diff --git a/web/messages/km.json b/web/messages/km.json
index 5fc55a33cc4..a63faaa1b2b 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspace មាននៅក្នុងរបារចំហៀង។ Workspace នីមួយៗមានសំណុំបន្ទះនិង surface ផ្ទាល់ខ្លួន។",
"surfaces": "Surface",
"surfacesBlurb": "Surface គឺជាផ្ទាំងក្នុងបន្ទះ។",
+ "markdownPreview": "ការមើលជាមុន Markdown",
+ "markdownPreviewBlurb": "ផ្លូវកាត់ការមើលជាមុន Markdown ដំណើរការតែក្នុងផ្ទាំង Markdown ដែលកំពុងផ្ដោត ដូច្នេះគ្រាប់ចុច Vim ដាច់ដោយឡែកមិនប៉ះពាល់ដល់ terminal ទេ។",
"splitPanes": "បន្ទះបំបែក",
"browser": "កម្មវិធីរុករក",
"notifications": "ជូនដំណឹង",
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 3975f0914ac..aa41e62b892 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "워크스페이스는 사이드바에 있습니다. 각 워크스페이스에는 자체 패널과 서피스가 있습니다.",
"surfaces": "서피스",
"surfacesBlurb": "서피스는 패널 내의 탭입니다.",
+ "markdownPreview": "Markdown 미리보기",
+ "markdownPreviewBlurb": "Markdown 미리보기 단축키는 포커스된 Markdown 패널에서만 동작하므로 단일 Vim 키가 터미널에 영향을 주지 않습니다.",
"splitPanes": "분할 패널",
"browser": "브라우저",
"notifications": "알림",
diff --git a/web/messages/no.json b/web/messages/no.json
index e8807e30ab8..898f84137ea 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Arbeidsområder lever i sidefeltet. Hvert arbeidsområde har sitt eget sett med paneler og overflater.",
"surfaces": "Overflater",
"surfacesBlurb": "Overflater er faner inne i et panel.",
+ "markdownPreview": "Markdown-forhåndsvisning",
+ "markdownPreviewBlurb": "Snarveier for Markdown-forhåndsvisning virker bare i det fokuserte Markdown-panelet, så enkle Vim-taster påvirker ikke terminaler.",
"splitPanes": "Delte paneler",
"browser": "Nettleser",
"notifications": "Varsler",
diff --git a/web/messages/pl.json b/web/messages/pl.json
index 1a9cf948043..5815f69d816 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspace'y znajdują się w pasku bocznym. Każdy workspace ma własny zestaw paneli i surface'ów.",
"surfaces": "Surface'y",
"surfacesBlurb": "Surface'y to karty wewnątrz panelu.",
+ "markdownPreview": "Podgląd Markdown",
+ "markdownPreviewBlurb": "Skróty podglądu Markdown działają tylko w aktywnym panelu Markdown, więc pojedyncze klawisze Vim nie wpływają na terminale.",
"splitPanes": "Dzielone panele",
"browser": "Przeglądarka",
"notifications": "Powiadomienia",
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index 1c269873b80..7afc9216081 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspaces ficam na barra lateral. Cada workspace tem seu próprio conjunto de painéis e superfícies.",
"surfaces": "Superfícies",
"surfacesBlurb": "Superfícies são abas dentro de um painel.",
+ "markdownPreview": "Prévia de Markdown",
+ "markdownPreviewBlurb": "Os atalhos da prévia de Markdown funcionam apenas no painel Markdown em foco, então teclas Vim isoladas não afetam os terminais.",
"splitPanes": "Painéis Divididos",
"browser": "Navegador",
"notifications": "Notificações",
diff --git a/web/messages/ru.json b/web/messages/ru.json
index 74674f0b239..bd2b5610ea1 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Рабочие пространства находятся на боковой панели. У каждого свой набор панелей и поверхностей.",
"surfaces": "Поверхности",
"surfacesBlurb": "Поверхности — это вкладки внутри панели.",
+ "markdownPreview": "Предпросмотр Markdown",
+ "markdownPreviewBlurb": "Сочетания предпросмотра Markdown работают только в активной панели Markdown, поэтому одиночные клавиши Vim не влияют на терминалы.",
"splitPanes": "Разделённые панели",
"browser": "Браузер",
"notifications": "Уведомления",
diff --git a/web/messages/th.json b/web/messages/th.json
index e4c5e4d11a4..4d79cfc928d 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Workspace อยู่ในแถบด้านข้าง Workspace แต่ละอันมีชุดพาเนลและ surface ของตัวเอง",
"surfaces": "Surface",
"surfacesBlurb": "Surface คือแท็บภายในพาเนล",
+ "markdownPreview": "ตัวอย่าง Markdown",
+ "markdownPreviewBlurb": "ปุ่มลัดตัวอย่าง Markdown ทำงานเฉพาะในแผง Markdown ที่โฟกัสอยู่ ดังนั้นปุ่ม Vim แบบเดี่ยวจะไม่กระทบเทอร์มินัล",
"splitPanes": "พาเนลแบ่ง",
"browser": "เบราว์เซอร์",
"notifications": "การแจ้งเตือน",
diff --git a/web/messages/tr.json b/web/messages/tr.json
index 2c11ecb9ba2..a3944c68f75 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "Çalışma alanları kenar çubuğunda bulunur. Her çalışma alanının kendi panel ve yüzey seti vardır.",
"surfaces": "Yüzeyler",
"surfacesBlurb": "Yüzeyler, bir panel içindeki sekmelerdir.",
+ "markdownPreview": "Markdown Önizleme",
+ "markdownPreviewBlurb": "Markdown önizleme kısayolları yalnızca odaktaki Markdown panelinde çalışır, bu yüzden tek Vim tuşları terminalleri etkilemez.",
"splitPanes": "Bölünmüş Paneller",
"browser": "Tarayıcı",
"notifications": "Bildirimler",
diff --git a/web/messages/uk.json b/web/messages/uk.json
index 38f150f30fc..22945efe482 100644
--- a/web/messages/uk.json
+++ b/web/messages/uk.json
@@ -417,6 +417,8 @@
"workspacesBlurb": "Робочі простори розташовані в бічній панелі. Кожен робочий простір має власний набір панелей та поверхонь.",
"surfaces": "Поверхні",
"surfacesBlurb": "Поверхні — це вкладки всередині панелі.",
+ "markdownPreview": "Перегляд Markdown",
+ "markdownPreviewBlurb": "Скорочення перегляду Markdown працюють лише у сфокусованій панелі Markdown, тому окремі клавіші Vim не впливають на термінали.",
"splitPanes": "Розділені панелі",
"browser": "Браузер",
"notifications": "Сповіщення",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 8c67839c8ee..0298673b33a 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "工作区位于侧边栏中。每个工作区有自己的面板和 surface。",
"surfaces": "Surface",
"surfacesBlurb": "Surface 是面板内的标签页。",
+ "markdownPreview": "Markdown 预览",
+ "markdownPreviewBlurb": "Markdown 预览快捷键只在当前聚焦的 Markdown 面板中生效,因此单独的 Vim 风格按键不会影响终端。",
"splitPanes": "分屏面板",
"browser": "浏览器",
"notifications": "通知",
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index 0ff43b9ba42..bbe156683b3 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -416,6 +416,8 @@
"workspacesBlurb": "工作區位於側邊欄中。每個工作區擁有自己的窗格和 surface。",
"surfaces": "Surface",
"surfacesBlurb": "Surface 是窗格內的分頁。",
+ "markdownPreview": "Markdown 預覽",
+ "markdownPreviewBlurb": "Markdown 預覽快捷鍵只會在目前聚焦的 Markdown 面板中作用,因此單獨的 Vim 風格按鍵不會影響終端。",
"splitPanes": "分割窗格",
"browser": "瀏覽器",
"notifications": "通知",
From c80a1d0d3c78f7079a9c79d195064e4b700235ba Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Tue, 19 May 2026 20:15:57 -0700
Subject: [PATCH 03/14] Localize markdown preview shortcuts
---
Resources/Localizable.xcstrings | 228 ++++++++++++++++++++++++++++++--
1 file changed, 216 insertions(+), 12 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 89c67e9789e..013321ece2a 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -113627,84 +113627,288 @@
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Left" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 左へスクロール" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 左へスクロール" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向左滚动" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向左捲動" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 왼쪽으로 스크롤" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nach links scrollen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Desplazar a la izquierda" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Faire défiler vers la gauche" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Scorri a sinistra" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rul til venstre" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Przewiń w lewo" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Прокрутить влево" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pomjeri lijevo" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: التمرير لليسار" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rull til venstre" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Rolar para a esquerda" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนไปทางซ้าย" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sola kaydır" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Прокрутити ліворуч" } }
}
},
"shortcut.markdownScrollDown.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Down" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へスクロール" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へスクロール" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向下滚动" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向下捲動" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 아래로 스크롤" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nach unten scrollen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Desplazar hacia abajo" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Faire défiler vers le bas" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Scorri giù" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rul ned" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Przewiń w dół" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Прокрутить вниз" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pomjeri dolje" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: التمرير لأسفل" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rull ned" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Rolar para baixo" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนลง" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Aşağı kaydır" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Прокрутити вниз" } }
}
},
"shortcut.markdownScrollUp.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Up" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へスクロール" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へスクロール" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向上滚动" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向上捲動" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 위로 스크롤" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nach oben scrollen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Desplazar hacia arriba" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Faire défiler vers le haut" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Scorri su" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rul op" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Przewiń w górę" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Прокрутить вверх" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pomjeri gore" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: التمرير لأعلى" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rull opp" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Rolar para cima" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนขึ้น" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Yukarı kaydır" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Прокрутити вгору" } }
}
},
"shortcut.markdownScrollRight.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Scroll Right" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 右へスクロール" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 右へスクロール" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向右滚动" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向右捲動" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 오른쪽으로 스크롤" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nach rechts scrollen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Desplazar a la derecha" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Faire défiler vers la droite" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Scorri a destra" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rul til højre" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Przewiń w prawo" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Прокрутить вправо" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pomjeri desno" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: التمرير لليمين" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Rull til høyre" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Rolar para a direita" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนไปทางขวา" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sağa kaydır" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Прокрутити праворуч" } }
}
},
"shortcut.markdownPageUp.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Page Up" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へページ移動" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 上へページ移動" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向上翻页" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向上翻頁" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 페이지 위로" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Seite nach oben" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Página arriba" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Page précédente" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Pagina su" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Side op" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Strona w górę" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Страница вверх" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Stranica gore" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: صفحة لأعلى" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Side opp" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Página para cima" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนขึ้นหนึ่งหน้า" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sayfa yukarı" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Сторінка вгору" } }
}
},
"shortcut.markdownPageDown.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Page Down" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へページ移動" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 下へページ移動" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向下翻页" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向下翻頁" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 페이지 아래로" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Seite nach unten" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Página abajo" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Page suivante" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Pagina giù" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Side ned" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Strona w dół" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Страница вниз" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Stranica dolje" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: صفحة لأسفل" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Side ned" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Página para baixo" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: เลื่อนลงหนึ่งหน้า" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sayfa aşağı" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Сторінка вниз" } }
}
},
"shortcut.markdownFindForward.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Search Forward" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前方検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前方検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向前搜索" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向前搜尋" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 앞으로 검색" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Vorwärts suchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar hacia adelante" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Rechercher vers l’avant" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Cerca in avanti" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Søg fremad" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Szukaj do przodu" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Искать вперед" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pretraži naprijed" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث للأمام" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Søk fremover" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Buscar para frente" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาไปข้างหน้า" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: İleri ara" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Шукати вперед" } }
}
},
"shortcut.markdownFindBackward.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Search Backward" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 後方検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 後方検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:向后搜索" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:向後搜尋" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 뒤로 검색" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Rückwärts suchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar hacia atrás" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Rechercher vers l’arrière" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Cerca indietro" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Søg tilbage" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Szukaj wstecz" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Искать назад" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pretraži nazad" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث للخلف" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Søk bakover" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Buscar para trás" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาย้อนกลับ" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Geri ara" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Шукати назад" } }
}
},
"shortcut.markdownFindNext.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Next" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 次を検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 次を検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:查找下一个" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:尋找下一個" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 다음 찾기" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nächsten Treffer suchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar siguiente" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Résultat suivant" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Trova successivo" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Find næste" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Znajdź następne" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Найти следующее" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pronađi sljedeće" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث عن التالي" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Finn neste" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Localizar próximo" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาถัดไป" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sonrakini bul" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Знайти наступне" } }
}
},
"shortcut.markdownFindPrevious.label": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Previous" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前を検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前を検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:查找上一个" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:尋找上一個" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 이전 찾기" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Vorherigen Treffer suchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar anterior" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Résultat précédent" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Trova precedente" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Find forrige" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Znajdź poprzednie" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Найти предыдущее" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pronađi prethodno" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث عن السابق" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Finn forrige" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Localizar anterior" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาก่อนหน้า" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Öncekini bul" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Знайти попереднє" } }
}
},
"markdown.web.searchPromptForward": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Search Markdown" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown を検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown を検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "搜索 Markdown" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "搜尋 Markdown" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 검색" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown durchsuchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Buscar en Markdown" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Rechercher dans Markdown" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Cerca in Markdown" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Søg i Markdown" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Szukaj w Markdown" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Искать в Markdown" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Pretraži Markdown" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "البحث في Markdown" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Søk i Markdown" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Buscar em Markdown" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ค้นหาใน Markdown" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown’da ara" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Шукати в Markdown" } }
}
},
"markdown.web.searchPromptBackward": {
"extractionState": "manual",
"localizations": {
"en": { "stringUnit": { "state": "translated", "value": "Search Markdown backward" } },
- "ja": { "stringUnit": { "state": "translated", "value": "Markdown を後方検索" } }
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown を後方検索" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "向后搜索 Markdown" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "向後搜尋 Markdown" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 뒤로 검색" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown rückwärts durchsuchen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Buscar hacia atrás en Markdown" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Rechercher vers l’arrière dans Markdown" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Cerca indietro in Markdown" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Søg bagud i Markdown" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Szukaj wstecz w Markdown" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Искать назад в Markdown" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Pretraži Markdown unazad" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "البحث للخلف في Markdown" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Søk bakover i Markdown" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Buscar para trás em Markdown" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ค้นหาแบบย้อนกลับใน Markdown" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown’da geriye doğru ara" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Шукати назад у Markdown" } }
}
},
"statusMenu.searchAllWindows": {
From c875fd89740c1937eadb80ec855845e7e9d37198 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 20 May 2026 00:37:12 -0700
Subject: [PATCH 04/14] Support markdown preview find aliases
---
README.md | 6 +-
Sources/AppDelegate.swift | 17 +++++
Sources/Panels/MarkdownPanel.swift | 6 ++
.../MarkdownPreviewKeyboardShortcuts.swift | 25 ++++++-
Sources/Panels/MarkdownWebRenderer.swift | 7 ++
Sources/Panels/MarkdownWebSupport.swift | 5 ++
.../AppDelegateShortcutRoutingTests.swift | 73 +++++++++++++++++++
cmuxTests/MarkdownPanelTests.swift | 30 ++++++++
web/data/cmux-shortcuts.ts | 6 +-
9 files changed, 167 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 9cdd28e4d5a..e4d4fe43be6 100644
--- a/README.md
+++ b/README.md
@@ -205,8 +205,10 @@ Markdown preview shortcuts are local to the focused Markdown panel and are custo
|----------|--------|
| H J K L | Scroll left/down/up/right |
| ⌃ U / ⌃ D | Move up/down half a page |
-| / / ⇧ / | Search forward/backward |
-| N / ⇧ N | Find next/previous |
+| / / ⌘ F | Search forward |
+| ⇧ / | Search backward |
+| N / ⌃ N / ⌘ G | Find next |
+| ⇧ N / ⌃ P / ⌥ ⌘ G | Find previous |
### Notifications
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index f6080f393f9..e485343f22e 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -11634,6 +11634,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}
if cmuxCloseFocusedTerminalFindForEscape(event: event, appDelegate: self) { return true }
+ if handleFocusedMarkdownPreviewShortcut(event) { return true }
if matchConfiguredShortcut(event: event, action: .find) {
let shortcutWindow = resolvedShortcutEventWindow(event)
cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: shortcutWindow ?? NSApp.keyWindow); return performFindShortcutInActiveMainWindow(preferredWindow: shortcutWindow)
@@ -14169,6 +14170,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
+ private func handleFocusedMarkdownPreviewShortcut(_ event: NSEvent) -> Bool {
+ let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow
+ if let responder = shortcutWindow?.firstResponder,
+ cmuxOwningGhosttyView(for: responder) != nil {
+ return false
+ }
+ guard let context = preferredRegisteredMainWindowContext(preferredWindow: shortcutWindow),
+ context.keyboardFocusCoordinator.findShortcutTarget(currentResponder: shortcutWindow?.firstResponder) == .mainPanelFind,
+ let workspace = context.tabManager.selectedWorkspace,
+ let focusedPanelId = workspace.focusedPanelId,
+ let markdownPanel = workspace.markdownPanel(for: focusedPanelId) else {
+ return false
+ }
+ return markdownPanel.handlePreviewKeyboardShortcut(event)
+ }
+
private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? {
var candidateManagers: [TabManager] = []
var seenManagers = Set()
diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift
index 48f4b354603..c14aa1680af 100644
--- a/Sources/Panels/MarkdownPanel.swift
+++ b/Sources/Panels/MarkdownPanel.swift
@@ -53,6 +53,12 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
/// layout churn does not recreate the WKWebView and flash existing content.
let rendererSession = MarkdownRendererSession()
+ @discardableResult
+ func handlePreviewKeyboardShortcut(_ event: NSEvent) -> Bool {
+ guard displayMode == .preview else { return false }
+ return rendererSession.handleKeyboardShortcut(event)
+ }
+
// MARK: - File watching
// nonisolated(unsafe) because deinit is not guaranteed to run on the
diff --git a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
index c882e58d853..eeda7a4af0c 100644
--- a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
+++ b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
@@ -29,6 +29,21 @@ enum MarkdownPreviewKeyboardShortcutResolver {
(.findPrevious, .markdownFindPrevious),
]
+ private static let globalFindCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] = [
+ (.findForward, .find),
+ (.findNext, .findNext),
+ (.findPrevious, .findPrevious),
+ ]
+
+ private static let defaultAliasCommandShortcuts: [(MarkdownPreviewKeyCommand, StoredShortcut)] = [
+ (.findNext, StoredShortcut(key: "n", command: false, shift: false, option: false, control: true)),
+ (.findPrevious, StoredShortcut(key: "p", command: false, shift: false, option: false, control: true)),
+ ]
+
+ private static var configuredCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] {
+ commandActions + globalFindCommandActions
+ }
+
static var actions: [KeyboardShortcutSettings.Action] {
commandActions.map(\.1)
}
@@ -46,11 +61,15 @@ enum MarkdownPreviewKeyboardShortcutResolver {
)
}
- for (command, action) in commandActions {
+ for (command, action) in configuredCommandActions {
let shortcut = shortcutForAction(action)
guard !shortcut.hasChord, shortcut.matches(event: event) else { continue }
return command
}
+ for (command, shortcut) in defaultAliasCommandShortcuts {
+ guard shortcut.matches(event: event) else { continue }
+ return command
+ }
return nil
}
@@ -58,7 +77,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
for event: NSEvent,
shortcutForAction: ShortcutProvider = KeyboardShortcutSettings.shortcut(for:)
) -> ShortcutStroke? {
- for (_, action) in commandActions {
+ for (_, action) in configuredCommandActions {
let shortcut = shortcutForAction(action)
guard shortcut.hasChord,
shortcut.firstStroke.matches(event: event) else { continue }
@@ -72,7 +91,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
pendingFirstStroke: ShortcutStroke,
shortcutForAction: ShortcutProvider
) -> MarkdownPreviewKeyCommand? {
- for (command, action) in commandActions {
+ for (command, action) in configuredCommandActions {
let shortcut = shortcutForAction(action)
guard let secondStroke = shortcut.secondStroke,
shortcut.firstStroke == pendingFirstStroke,
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index b36f03c5677..511067a018b 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -118,6 +118,10 @@ struct MarkdownWebRenderer: NSViewRepresentable {
@MainActor
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, WKURLSchemeHandler {
+#if DEBUG
+ static var keyboardCommandObserver: ((MarkdownPreviewKeyCommand) -> Void)?
+#endif
+
var webView: MarkdownWebView?
var panelId: UUID = UUID()
var workspaceId: UUID = UUID()
@@ -313,6 +317,9 @@ struct MarkdownWebRenderer: NSViewRepresentable {
}
private func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+#if DEBUG
+ Self.keyboardCommandObserver?(command)
+#endif
guard let webView else { return }
let commandLiteral = (try? JSONSerialization.data(withJSONObject: [command.rawValue]))
.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift
index d6564e2c8b1..c10f6073a62 100644
--- a/Sources/Panels/MarkdownWebSupport.swift
+++ b/Sources/Panels/MarkdownWebSupport.swift
@@ -103,6 +103,11 @@ final class MarkdownRendererSession {
ownedCoordinator.close()
}
+ @discardableResult
+ func handleKeyboardShortcut(_ event: NSEvent) -> Bool {
+ ownedCoordinator.handleKeyboardShortcut(event)
+ }
+
func renderedHTML(markdown: String? = nil) async -> String? {
await ownedCoordinator.renderedHTML(markdown: markdown)
}
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index 206aa923a6b..7d32e46d4e5 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -122,6 +122,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
override func tearDown() {
#if DEBUG
KeyboardShortcutSettings.shortcutLookupObserver = nil
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = nil
#endif
KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore
AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
@@ -5146,6 +5147,78 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
#endif
}
+ func testFocusedMarkdownPreviewOwnsFindFamilyShortcuts() throws {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ let windowId = appDelegate.createMainWindow()
+ defer { closeWindow(withId: windowId) }
+
+ guard let window = window(withId: windowId),
+ let manager = appDelegate.tabManagerFor(windowId: windowId),
+ let workspace = manager.selectedWorkspace,
+ let sourcePanelId = workspace.focusedPanelId else {
+ XCTFail("Expected test window, manager, workspace, and focused source panel")
+ return
+ }
+
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-find-\(UUID().uuidString).md")
+ try "# Markdown\n\nFind target\n".write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let markdownPanel = try XCTUnwrap(
+ workspace.newMarkdownSplit(
+ from: sourcePanelId,
+ orientation: .horizontal,
+ filePath: fileURL.path,
+ focus: true
+ )
+ )
+ workspace.focusPanel(markdownPanel.id)
+
+ let responder = FocusableTestView(frame: .zero)
+ window.contentView?.addSubview(responder)
+ defer { responder.removeFromSuperview() }
+ XCTAssertTrue(window.makeFirstResponder(responder))
+
+ var observedCommands: [MarkdownPreviewKeyCommand] = []
+#if DEBUG
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = { command in
+ observedCommands.append(command)
+ }
+#else
+ XCTFail("keyboardCommandObserver is only available in DEBUG")
+#endif
+
+ let cases: [(command: MarkdownPreviewKeyCommand, modifiers: NSEvent.ModifierFlags, key: String, keyCode: UInt16)] = [
+ (.findForward, [.command], "f", 3),
+ (.findNext, [.command], "g", 5),
+ (.findPrevious, [.command, .option], "g", 5),
+ (.findNext, [.control], "n", 45),
+ (.findPrevious, [.control], "p", 35),
+ ]
+
+ for testCase in cases {
+ let event = try XCTUnwrap(
+ makeKeyDownEvent(
+ key: testCase.key,
+ modifiers: testCase.modifiers,
+ keyCode: testCase.keyCode,
+ windowNumber: window.windowNumber
+ )
+ )
+#if DEBUG
+ XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
+#else
+ XCTFail("debugHandleCustomShortcut is only available in DEBUG")
+#endif
+ XCTAssertEqual(observedCommands.last, testCase.command)
+ }
+ }
+
// MARK: - Browser find shortcut routing tests
func testBrowserFirstFindShortcutRoutingRecognizesBrowserLocalFindCommandFamily() {
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index 83eac033293..92c30e65877 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -285,6 +285,12 @@ final class MarkdownPanelTests: XCTestCase {
MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("/", keyCode: 44)),
.findForward
)
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("f", keyCode: 3, modifiers: [.command])
+ ),
+ .findForward
+ )
XCTAssertEqual(
MarkdownPreviewKeyboardShortcutResolver.command(
for: try keyDownEvent("?", charactersIgnoringModifiers: "/", keyCode: 44, modifiers: [.shift])
@@ -295,12 +301,36 @@ final class MarkdownPanelTests: XCTestCase {
MarkdownPreviewKeyboardShortcutResolver.command(for: try keyDownEvent("n", keyCode: 45)),
.findNext
)
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("n", keyCode: 45, modifiers: [.control])
+ ),
+ .findNext
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("g", keyCode: 5, modifiers: [.command])
+ ),
+ .findNext
+ )
XCTAssertEqual(
MarkdownPreviewKeyboardShortcutResolver.command(
for: try keyDownEvent("N", charactersIgnoringModifiers: "n", keyCode: 45, modifiers: [.shift])
),
.findPrevious
)
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("p", keyCode: 35, modifiers: [.control])
+ ),
+ .findPrevious
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("g", keyCode: 5, modifiers: [.command, .option])
+ ),
+ .findPrevious
+ )
}
func testMarkdownPreviewKeyboardShortcutsCanUseBareCmuxJSONBindings() throws {
diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts
index 7e9850ec670..bd8430b4799 100644
--- a/web/data/cmux-shortcuts.ts
+++ b/web/data/cmux-shortcuts.ts
@@ -133,10 +133,10 @@ export const shortcutCategories: ShortcutCategory[] = [
{ id: "markdownScrollRight", combos: [["L"]], description: { en: "Scroll Markdown preview right", ja: "Markdown プレビューを右へスクロール" } },
{ id: "markdownPageUp", combos: [["⌃", "U"]], description: { en: "Move Markdown preview up half a page", ja: "Markdown プレビューを半ページ上へ移動" } },
{ id: "markdownPageDown", combos: [["⌃", "D"]], description: { en: "Move Markdown preview down half a page", ja: "Markdown プレビューを半ページ下へ移動" } },
- { id: "markdownFindForward", combos: [["/"]], description: { en: "Search Markdown preview forward", ja: "Markdown プレビューを前方検索" } },
+ { id: "markdownFindForward", combos: [["/"], ["⌘", "F"]], description: { en: "Search Markdown preview forward", ja: "Markdown プレビューを前方検索" } },
{ id: "markdownFindBackward", combos: [["⇧", "/"]], description: { en: "Search Markdown preview backward", ja: "Markdown プレビューを後方検索" } },
- { id: "markdownFindNext", combos: [["N"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
- { id: "markdownFindPrevious", combos: [["⇧", "N"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
+ { id: "markdownFindNext", combos: [["N"], ["⌃", "N"], ["⌘", "G"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
+ { id: "markdownFindPrevious", combos: [["⇧", "N"], ["⌃", "P"], ["⌥", "⌘", "G"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
],
},
{
From 9b9881f88a7264e9f45a3c8aae5494c4efd5f343 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 20 May 2026 01:50:12 -0700
Subject: [PATCH 05/14] Fix markdown preview shortcut routing
---
Resources/markdown-viewer/shell.html | 72 +++++++++++--
Sources/AppDelegate.swift | 3 +-
Sources/Panels/MarkdownPanel.swift | 7 ++
Sources/Panels/MarkdownWebRenderer.swift | 2 +-
Sources/Panels/MarkdownWebSupport.swift | 31 ++++++
Sources/TabManager.swift | 28 ++++-
.../AppDelegateShortcutRoutingTests.swift | 10 ++
cmuxTests/MarkdownPanelTests.swift | 100 +++++++++++++++++-
8 files changed, 237 insertions(+), 16 deletions(-)
diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html
index 6b03649e2c8..80c38fc4bd4 100644
--- a/Resources/markdown-viewer/shell.html
+++ b/Resources/markdown-viewer/shell.html
@@ -1429,16 +1429,11 @@
var scroller = markdownPreviewScroller();
var nextLeft = Math.max(0, (window.scrollX || scroller.scrollLeft || 0) + deltaX);
var nextTop = clampScrollY((window.scrollY || scroller.scrollTop || 0) + deltaY);
- var root = document.documentElement;
- var previousBehavior = root.style.scrollBehavior;
- root.style.scrollBehavior = 'auto';
try {
+ window.scrollTo({ left: nextLeft, top: nextTop, behavior: 'smooth' });
+ } catch (e) {
window.scrollTo(nextLeft, nextTop);
- } finally {
- root.style.scrollBehavior = previousBehavior;
}
- scroller.scrollLeft = nextLeft;
- setMarkdownScrollY(nextTop);
return true;
}
@@ -1450,7 +1445,15 @@
var rect = range.getBoundingClientRect();
if (!rect || (!rect.top && !rect.bottom)) { return; }
var targetY = markdownScrollY() + rect.top - Math.max(24, Math.round(window.innerHeight * 0.25));
- setMarkdownScrollY(targetY);
+ try {
+ window.scrollTo({
+ left: window.scrollX || markdownPreviewScroller().scrollLeft || 0,
+ top: clampScrollY(targetY),
+ behavior: 'smooth'
+ });
+ } catch (e) {
+ setMarkdownScrollY(targetY);
+ }
}
function markdownPreviewPromptSearch(backwards) {
@@ -1507,6 +1510,59 @@
}
};
+ function markdownPreviewShouldHandleKeyboardEvent(ev) {
+ if (!ev || ev.defaultPrevented || ev.isComposing) { return false; }
+ var target = ev.target;
+ if (target && target.closest && target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) {
+ return false;
+ }
+ return true;
+ }
+
+ function markdownPreviewCommandForKeyboardEvent(ev) {
+ if (!markdownPreviewShouldHandleKeyboardEvent(ev)) { return null; }
+ var key = String(ev.key || '').toLowerCase();
+ var hasCommand = !!ev.metaKey;
+ var hasControl = !!ev.ctrlKey;
+ var hasOption = !!ev.altKey;
+ var hasShift = !!ev.shiftKey;
+
+ if (hasCommand && !hasControl && !hasOption && !hasShift && key === 'f') { return 'findForward'; }
+ if (hasCommand && !hasControl && !hasOption && !hasShift && key === 'g') { return 'findNext'; }
+ if (hasCommand && !hasControl && hasOption && !hasShift && key === 'g') { return 'findPrevious'; }
+ if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'n') { return 'findNext'; }
+ if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'p') { return 'findPrevious'; }
+ if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'u') { return 'pageUp'; }
+ if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'd') { return 'pageDown'; }
+ if (!hasCommand && !hasControl && !hasOption && !hasShift) {
+ switch (key) {
+ case 'h': return 'scrollLeft';
+ case 'j': return 'scrollDown';
+ case 'k': return 'scrollUp';
+ case 'l': return 'scrollRight';
+ case '/': return 'findForward';
+ case 'n': return 'findNext';
+ default: break;
+ }
+ }
+ if (!hasCommand && !hasControl && !hasOption && hasShift) {
+ switch (key) {
+ case '?': return 'findBackward';
+ case 'n': return 'findPrevious';
+ default: break;
+ }
+ }
+ return null;
+ }
+
+ document.addEventListener('keydown', function(ev) {
+ var command = markdownPreviewCommandForKeyboardEvent(ev);
+ if (!command) { return; }
+ ev.preventDefault();
+ ev.stopPropagation();
+ window.__cmuxMarkdownPreviewHandleKeyCommand(command);
+ }, true);
+
window.__cmuxRenderMarkdown = function(md) {
try {
headingSlugCounts = Object.create(null);
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index e485343f22e..62019328eb5 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -6224,8 +6224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
case .rightSidebarFileSearch:
result = context.keyboardFocusCoordinator.focusFileSearch()
case .mainPanelFind:
- context.tabManager.startSearch()
- result = context.tabManager.isFindVisible
+ result = context.tabManager.startSearch()
case .none:
result = false
}
diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift
index c14aa1680af..e6d6a12598f 100644
--- a/Sources/Panels/MarkdownPanel.swift
+++ b/Sources/Panels/MarkdownPanel.swift
@@ -59,6 +59,13 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
return rendererSession.handleKeyboardShortcut(event)
}
+ @discardableResult
+ func performPreviewKeyboardCommand(_ command: MarkdownPreviewKeyCommand) -> Bool {
+ guard displayMode == .preview else { return false }
+ rendererSession.performKeyboardCommand(command)
+ return true
+ }
+
// MARK: - File watching
// nonisolated(unsafe) because deinit is not guaranteed to run on the
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index 511067a018b..e1b2aa06518 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -316,7 +316,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
return false
}
- private func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+ func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
#if DEBUG
Self.keyboardCommandObserver?(command)
#endif
diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift
index c10f6073a62..fe9b2404f5a 100644
--- a/Sources/Panels/MarkdownWebSupport.swift
+++ b/Sources/Panels/MarkdownWebSupport.swift
@@ -44,6 +44,33 @@ final class MarkdownWebView: WKWebView {
}
return super.performKeyEquivalent(with: event)
}
+
+ override func doCommand(by selector: Selector) {
+ if Self.isControlNavigationCommand(selector, currentEvent: NSApp.currentEvent),
+ let event = NSApp.currentEvent,
+ onKeyboardShortcut?(event) == true {
+ return
+ }
+ super.doCommand(by: selector)
+ }
+
+ private static func isControlNavigationCommand(
+ _ selector: Selector,
+ currentEvent event: NSEvent?
+ ) -> Bool {
+ guard let event, event.type == .keyDown else { return false }
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ .subtracting([.capsLock, .function, .numericPad])
+ guard flags == [.control] else { return false }
+ switch event.keyCode {
+ case 35:
+ return selector == #selector(NSResponder.moveUp(_:))
+ case 45:
+ return selector == #selector(NSResponder.moveDown(_:))
+ default:
+ return false
+ }
+ }
}
struct MarkdownWebTheme: Equatable {
@@ -108,6 +135,10 @@ final class MarkdownRendererSession {
ownedCoordinator.handleKeyboardShortcut(event)
}
+ func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+ ownedCoordinator.performKeyboardCommand(command)
+ }
+
func renderedHTML(markdown: String? = nil) async -> String? {
await ownedCoordinator.renderedHTML(markdown: markdown)
}
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 61fd397fc00..2182fc0d346 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -1968,7 +1968,8 @@ class TabManager: ObservableObject {
selectedTerminalPanel?.hasSelection() == true
}
- func startSearch() {
+ @discardableResult
+ func startSearch() -> Bool {
if let panel = selectedTerminalPanel {
let hadExistingSearch = panel.searchState != nil
panel.hostedView.preparePanelFocusIntentForActivation(.findField)
@@ -1988,9 +1989,16 @@ class TabManager: ObservableObject {
"firstResponder=\(String(describing: panel.surface.uiWindow?.firstResponder))"
)
#endif
- return
+ return handled
+ }
+ if focusedMarkdownPanel?.performPreviewKeyboardCommand(.findForward) == true {
+ return true
}
- focusedBrowserPanel?.startFind()
+ if let browserPanel = focusedBrowserPanel {
+ browserPanel.startFind()
+ return browserPanel.searchState != nil
+ }
+ return false
}
func searchSelection() {
@@ -2014,6 +2022,10 @@ class TabManager: ObservableObject {
return
}
+ if focusedMarkdownPanel?.performPreviewKeyboardCommand(.findNext) == true {
+ return
+ }
+
focusedBrowserPanel?.findNext()
}
@@ -2023,6 +2035,10 @@ class TabManager: ObservableObject {
return
}
+ if focusedMarkdownPanel?.performPreviewKeyboardCommand(.findPrevious) == true {
+ return
+ }
+
focusedBrowserPanel?.findPrevious()
}
@@ -4871,6 +4887,12 @@ class TabManager: ObservableObject {
return tab.panels[panelId] as? BrowserPanel
}
+ var focusedMarkdownPanel: MarkdownPanel? {
+ guard let tab = selectedWorkspace,
+ let panelId = tab.focusedPanelId else { return nil }
+ return tab.panels[panelId] as? MarkdownPanel
+ }
+
@discardableResult
func zoomInFocusedBrowser() -> Bool {
focusedBrowserPanel?.zoomIn() ?? false
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index 7d32e46d4e5..f483d684836 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -5217,6 +5217,16 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
#endif
XCTAssertEqual(observedCommands.last, testCase.command)
}
+
+ observedCommands.removeAll()
+ XCTAssertTrue(appDelegate.performFindShortcutInActiveMainWindow(preferredWindow: window))
+ XCTAssertEqual(observedCommands.last, .findForward)
+
+ manager.findNext()
+ XCTAssertEqual(observedCommands.last, .findNext)
+
+ manager.findPrevious()
+ XCTAssertEqual(observedCommands.last, .findPrevious)
}
// MARK: - Browser find shortcut routing tests
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index 92c30e65877..a328dc3bc43 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -454,17 +454,58 @@ final class MarkdownPanelTests: XCTestCase {
""",
in: webView
)
+ try await webView.evaluateJavaScript(
+ """
+ (function() {
+ window.__cmuxTestScrollCalls = [];
+ var originalScrollTo = window.scrollTo.bind(window);
+ window.scrollTo = function(first, second) {
+ var call;
+ if (first && typeof first === 'object') {
+ call = {
+ left: Number(first.left || 0),
+ top: Number(first.top || 0),
+ behavior: String(first.behavior || '')
+ };
+ window.__cmuxTestScrollCalls.push(call);
+ var scroller = document.scrollingElement || document.documentElement;
+ if (scroller) {
+ scroller.scrollLeft = call.left;
+ scroller.scrollTop = call.top;
+ }
+ originalScrollTo(call.left, call.top);
+ return;
+ }
+ call = {
+ left: Number(first || 0),
+ top: Number(second || 0),
+ behavior: 'legacy'
+ };
+ window.__cmuxTestScrollCalls.push(call);
+ originalScrollTo(first, second);
+ };
+ })();
+ """
+ )
_ = try await webView.evaluateJavaScript("window.__cmuxMarkdownPreviewHandleKeyCommand('scrollDown');")
let afterScroll = try await evaluateScrollSnapshot(
"""
(function() {
var scroller = document.scrollingElement || document.documentElement;
- return { y: window.scrollY || scroller.scrollTop };
+ var calls = window.__cmuxTestScrollCalls || [];
+ return {
+ y: window.scrollY || scroller.scrollTop,
+ calls: calls.length,
+ targetY: calls[0] ? calls[0].top : 0,
+ smooth: calls[0] && calls[0].behavior === 'smooth' ? 1 : 0
+ };
})();
""",
in: webView
)
- XCTAssertGreaterThan(afterScroll["y"] ?? 0, before["y"] ?? 0)
+ XCTAssertEqual(afterScroll["calls"] ?? 0, 1)
+ XCTAssertGreaterThan(afterScroll["targetY"] ?? 0, before["y"] ?? 0)
+ XCTAssertEqual(afterScroll["smooth"] ?? 0, 1)
let searchSnapshot = try await evaluateScrollSnapshot(
"""
@@ -504,6 +545,61 @@ final class MarkdownPanelTests: XCTestCase {
XCTAssertEqual(searchSnapshot["firstBackwards"] ?? -1, 0)
XCTAssertEqual(searchSnapshot["lastBackwards"] ?? -1, 1)
XCTAssertEqual(searchSnapshot["firstWrap"] ?? -1, 1)
+
+ let domShortcutSnapshot = try await evaluateScrollSnapshot(
+ """
+ (function() {
+ var calls = [];
+ Object.defineProperty(window, 'find', {
+ configurable: true,
+ value: function(query, caseSensitive, backwards, wrap, wholeWord, searchInFrames, showDialog) {
+ calls.push({
+ query: String(query),
+ backwards: backwards ? 1 : 0,
+ wrap: wrap ? 1 : 0
+ });
+ return true;
+ }
+ });
+ window.prompt = function() { return 'Section 20'; };
+ var cmdF = new KeyboardEvent('keydown', {
+ key: 'f',
+ metaKey: true,
+ bubbles: true,
+ cancelable: true
+ });
+ var ctrlN = new KeyboardEvent('keydown', {
+ key: 'n',
+ ctrlKey: true,
+ bubbles: true,
+ cancelable: true
+ });
+ var ctrlP = new KeyboardEvent('keydown', {
+ key: 'p',
+ ctrlKey: true,
+ bubbles: true,
+ cancelable: true
+ });
+ return {
+ cmdFCanceled: document.dispatchEvent(cmdF) ? 0 : 1,
+ ctrlNCanceled: document.dispatchEvent(ctrlN) ? 0 : 1,
+ ctrlPCanceled: document.dispatchEvent(ctrlP) ? 0 : 1,
+ calls: calls.length,
+ firstBackwards: calls[0] ? calls[0].backwards : -1,
+ secondBackwards: calls[1] ? calls[1].backwards : -1,
+ thirdBackwards: calls[2] ? calls[2].backwards : -1
+ };
+ })();
+ """,
+ in: webView
+ )
+ XCTAssertEqual(domShortcutSnapshot["cmdFCanceled"] ?? 0, 1)
+ XCTAssertEqual(domShortcutSnapshot["ctrlNCanceled"] ?? 0, 1)
+ XCTAssertEqual(domShortcutSnapshot["ctrlPCanceled"] ?? 0, 1)
+ XCTAssertEqual(domShortcutSnapshot["calls"] ?? 0, 3)
+ XCTAssertEqual(domShortcutSnapshot["firstBackwards"] ?? -1, 0)
+ XCTAssertEqual(domShortcutSnapshot["secondBackwards"] ?? -1, 0)
+ XCTAssertEqual(domShortcutSnapshot["thirdBackwards"] ?? -1, 1)
}
func testMarkdownRendererKeepsRecoveryBudgetAfterShellReload() {
From 28f6d737e9b4456927f5fec8e9dfd1dc75f33c33 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Wed, 20 May 2026 02:38:55 -0700
Subject: [PATCH 06/14] Fix markdown preview search prompt
---
Resources/Localizable.xcstrings | 17 ++++++++
Sources/Panels/MarkdownWebRenderer.swift | 53 ++++++++++++++++++++++++
cmuxTests/MarkdownPanelTests.swift | 14 ++++++-
3 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 013321ece2a..018c9a4a7e3 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -102672,6 +102672,23 @@
}
}
},
+ "markdown.preview.dialog.title": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Markdown Preview"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Markdown プレビュー"
+ }
+ }
+ }
+ },
"markdown.mode.showPreview": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index e1b2aa06518..d8e911fa118 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -384,6 +384,59 @@ struct MarkdownWebRenderer: NSViewRepresentable {
"""
}
+ // MARK: WKUIDelegate
+
+ func webView(
+ _ webView: WKWebView,
+ runJavaScriptTextInputPanelWithPrompt prompt: String,
+ defaultText: String?,
+ initiatedByFrame frame: WKFrameInfo,
+ completionHandler: @escaping (String?) -> Void
+ ) {
+ let alert = NSAlert()
+ alert.alertStyle = .informational
+ alert.messageText = markdownDialogTitle()
+ alert.informativeText = prompt
+ alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
+ alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
+
+ let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
+ field.stringValue = defaultText ?? ""
+ alert.accessoryView = field
+ alert.window.initialFirstResponder = field
+
+ presentDialog(alert, for: webView) { response in
+ if response == .alertFirstButtonReturn {
+ completionHandler(field.stringValue)
+ } else {
+ completionHandler(nil)
+ }
+ }
+ }
+
+ private func markdownDialogTitle() -> String {
+ let title = URL(fileURLWithPath: filePath).lastPathComponent
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !title.isEmpty {
+ return title
+ }
+ return String(localized: "markdown.preview.dialog.title", defaultValue: "Markdown Preview")
+ }
+
+ private func presentDialog(
+ _ alert: NSAlert,
+ for webView: WKWebView,
+ completion: @escaping (NSApplication.ModalResponse) -> Void
+ ) {
+ if let window = webView.window {
+ alert.beginSheetModal(for: window) { response in
+ completion(response)
+ }
+ } else {
+ completion(alert.runModal())
+ }
+ }
+
// MARK: WKScriptMessageHandler
func userContentController(
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index a328dc3bc43..ba010511e46 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -523,23 +523,33 @@ final class MarkdownPanelTests: XCTestCase {
}
});
var directFound = window.__cmuxMarkdownPreviewSearch('Section 20', false) ? 1 : 0;
+ var promptCalls = 0;
+ window.prompt = function(prompt, defaultValue) {
+ promptCalls += 1;
+ return 'Section 21';
+ };
+ var promptFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findForward') ? 1 : 0;
var nextFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findNext') ? 1 : 0;
var previousFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findPrevious') ? 1 : 0;
return {
calls: calls.length,
firstFound: directFound,
+ promptFound: promptFound,
+ promptCalls: promptCalls,
nextFound: nextFound,
previousFound: previousFound,
firstBackwards: calls[0] ? calls[0].backwards : -1,
- lastBackwards: calls[2] ? calls[2].backwards : -1,
+ lastBackwards: calls[3] ? calls[3].backwards : -1,
firstWrap: calls[0] ? calls[0].wrap : -1
};
})();
""",
in: webView
)
- XCTAssertEqual(searchSnapshot["calls"] ?? 0, 3)
+ XCTAssertEqual(searchSnapshot["calls"] ?? 0, 4)
XCTAssertEqual(searchSnapshot["firstFound"] ?? 0, 1)
+ XCTAssertEqual(searchSnapshot["promptFound"] ?? 0, 1)
+ XCTAssertEqual(searchSnapshot["promptCalls"] ?? 0, 1)
XCTAssertEqual(searchSnapshot["nextFound"] ?? 0, 1)
XCTAssertEqual(searchSnapshot["previousFound"] ?? 0, 1)
XCTAssertEqual(searchSnapshot["firstBackwards"] ?? -1, 0)
From 4674503811c31c7c75ab6b650f279ecc25019402 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 17:05:42 -0700
Subject: [PATCH 07/14] Address markdown shortcut review feedback
---
Resources/Localizable.xcstrings | 31 +++++---
Resources/markdown-viewer/shell.html | 53 --------------
Sources/KeyboardShortcutSettings.swift | 73 +++++++++++--------
.../MarkdownPreviewKeyboardShortcuts.swift | 8 +-
Sources/Panels/MarkdownWebRenderer.swift | 2 +-
cmuxTests/KeyboardShortcutSpaceKeyTests.swift | 16 ++++
cmuxTests/MarkdownPanelTests.swift | 50 +++++++++++--
web/app/[locale]/docs/configuration/page.tsx | 7 +-
web/data/cmux.schema.json | 4 +-
web/messages/ar.json | 6 +-
web/messages/bs.json | 6 +-
web/messages/da.json | 6 +-
web/messages/de.json | 6 +-
web/messages/en.json | 6 +-
web/messages/es.json | 6 +-
web/messages/fr.json | 6 +-
web/messages/it.json | 6 +-
web/messages/ja.json | 6 +-
web/messages/km.json | 6 +-
web/messages/ko.json | 6 +-
web/messages/no.json | 6 +-
web/messages/pl.json | 6 +-
web/messages/pt-BR.json | 6 +-
web/messages/ru.json | 6 +-
web/messages/th.json | 6 +-
web/messages/tr.json | 6 +-
web/messages/uk.json | 6 +-
web/messages/zh-CN.json | 6 +-
web/messages/zh-TW.json | 6 +-
29 files changed, 231 insertions(+), 133 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 6f2f3273377..f774900298c 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -104849,18 +104849,25 @@
"markdown.preview.dialog.title": {
"extractionState": "manual",
"localizations": {
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Markdown Preview"
- }
- },
- "ja": {
- "stringUnit": {
- "state": "translated",
- "value": "Markdown プレビュー"
- }
- }
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown" } }
}
},
"markdown.mode.showPreview": {
diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html
index 80c38fc4bd4..7bda37267ff 100644
--- a/Resources/markdown-viewer/shell.html
+++ b/Resources/markdown-viewer/shell.html
@@ -1510,59 +1510,6 @@
}
};
- function markdownPreviewShouldHandleKeyboardEvent(ev) {
- if (!ev || ev.defaultPrevented || ev.isComposing) { return false; }
- var target = ev.target;
- if (target && target.closest && target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) {
- return false;
- }
- return true;
- }
-
- function markdownPreviewCommandForKeyboardEvent(ev) {
- if (!markdownPreviewShouldHandleKeyboardEvent(ev)) { return null; }
- var key = String(ev.key || '').toLowerCase();
- var hasCommand = !!ev.metaKey;
- var hasControl = !!ev.ctrlKey;
- var hasOption = !!ev.altKey;
- var hasShift = !!ev.shiftKey;
-
- if (hasCommand && !hasControl && !hasOption && !hasShift && key === 'f') { return 'findForward'; }
- if (hasCommand && !hasControl && !hasOption && !hasShift && key === 'g') { return 'findNext'; }
- if (hasCommand && !hasControl && hasOption && !hasShift && key === 'g') { return 'findPrevious'; }
- if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'n') { return 'findNext'; }
- if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'p') { return 'findPrevious'; }
- if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'u') { return 'pageUp'; }
- if (!hasCommand && hasControl && !hasOption && !hasShift && key === 'd') { return 'pageDown'; }
- if (!hasCommand && !hasControl && !hasOption && !hasShift) {
- switch (key) {
- case 'h': return 'scrollLeft';
- case 'j': return 'scrollDown';
- case 'k': return 'scrollUp';
- case 'l': return 'scrollRight';
- case '/': return 'findForward';
- case 'n': return 'findNext';
- default: break;
- }
- }
- if (!hasCommand && !hasControl && !hasOption && hasShift) {
- switch (key) {
- case '?': return 'findBackward';
- case 'n': return 'findPrevious';
- default: break;
- }
- }
- return null;
- }
-
- document.addEventListener('keydown', function(ev) {
- var command = markdownPreviewCommandForKeyboardEvent(ev);
- if (!command) { return; }
- ev.preventDefault();
- ev.stopPropagation();
- window.__cmuxMarkdownPreviewHandleKeyCommand(command);
- }, true);
-
window.__cmuxRenderMarkdown = function(md) {
try {
headingSlugCounts = Object.create(null);
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index 77ed22d6a7c..b202f55de28 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -2231,9 +2231,10 @@ extension ShortcutStroke {
}
}
- guard let key = parseConfigKeyToken(lastRawPart) else { return nil }
+ guard let parsedKey = parseConfigKeyToken(lastRawPart) else { return nil }
+ shift = shift || parsedKey.shift
return ShortcutStroke(
- key: key,
+ key: parsedKey.key,
command: command,
shift: shift,
option: option,
@@ -2261,74 +2262,82 @@ extension ShortcutStroke {
return key
}
- private static func parseConfigKeyToken(_ rawValue: String) -> String? {
+ private static func parseConfigKeyToken(_ rawValue: String) -> (key: String, shift: Bool)? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
- return rawValue == " " ? "space" : nil
+ return rawValue == " " ? ("space", false) : nil
}
let lowered = trimmed.lowercased()
switch lowered {
case "left", "arrowleft", "leftarrow", "←":
- return "←"
+ return ("←", false)
case "right", "arrowright", "rightarrow", "→":
- return "→"
+ return ("→", false)
case "up", "arrowup", "uparrow", "↑":
- return "↑"
+ return ("↑", false)
case "down", "arrowdown", "downarrow", "↓":
- return "↓"
+ return ("↓", false)
case "tab":
- return "\t"
+ return ("\t", false)
case "return", "enter", "↩":
- return "\r"
+ return ("\r", false)
case "space", "spacebar", "":
- return "space"
+ return ("space", false)
case "comma":
- return ","
+ return (",", false)
case "period", "dot":
- return "."
+ return (".", false)
case "slash":
- return "/"
+ return ("/", false)
case "backslash":
- return "\\"
+ return ("\\", false)
case "semicolon":
- return ";"
+ return (";", false)
case "quote", "apostrophe":
- return "'"
+ return ("'", false)
case "backtick", "grave":
- return "`"
+ return ("`", false)
case "minus", "hyphen":
- return "-"
+ return ("-", false)
case "plus", "equals":
- return "="
+ return ("=", false)
case "leftbracket", "openbracket":
- return "["
+ return ("[", false)
case "rightbracket", "closebracket":
- return "]"
+ return ("]", false)
case "volumeup", "mediavolumeup", "media.volumeup":
- return "media.volumeUp"
+ return ("media.volumeUp", false)
case "volumedown", "mediavolumedown", "media.volumedown":
- return "media.volumeDown"
+ return ("media.volumeDown", false)
case "brightnessup", "mediabrightnessup", "media.brightnessup":
- return "media.brightnessUp"
+ return ("media.brightnessUp", false)
case "brightnessdown", "mediabrightnessdown", "media.brightnessdown":
- return "media.brightnessDown"
+ return ("media.brightnessDown", false)
case "mute", "mediamute", "media.mute":
- return "media.mute"
+ return ("media.mute", false)
case "playpause", "mediaplaypause", "media.playpause":
- return "media.playPause"
+ return ("media.playPause", false)
case "nexttrack", "medianext", "media.next", "media.nexttrack":
- return "media.next"
+ return ("media.next", false)
case "previoustrack", "mediaprevious", "media.previous", "media.previoustrack":
- return "media.previous"
+ return ("media.previous", false)
default:
+ if trimmed == "?" {
+ return ("/", true)
+ }
+ if trimmed.count == 1,
+ let scalar = trimmed.unicodeScalars.first,
+ CharacterSet.uppercaseLetters.contains(scalar) {
+ return (lowered, true)
+ }
if lowered.hasPrefix("f"),
let number = Int(lowered.dropFirst()),
(1...20).contains(number) {
- return "f\(number)"
+ return ("f\(number)", false)
}
guard lowered.count == 1 else { return nil }
- return lowered
+ return (lowered, false)
}
}
}
diff --git a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
index eeda7a4af0c..044fff54ecf 100644
--- a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
+++ b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
@@ -40,7 +40,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
(.findPrevious, StoredShortcut(key: "p", command: false, shift: false, option: false, control: true)),
]
- private static var configuredCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] {
+ private static var singleStrokeCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] {
commandActions + globalFindCommandActions
}
@@ -61,7 +61,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
)
}
- for (command, action) in configuredCommandActions {
+ for (command, action) in singleStrokeCommandActions {
let shortcut = shortcutForAction(action)
guard !shortcut.hasChord, shortcut.matches(event: event) else { continue }
return command
@@ -77,7 +77,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
for event: NSEvent,
shortcutForAction: ShortcutProvider = KeyboardShortcutSettings.shortcut(for:)
) -> ShortcutStroke? {
- for (_, action) in configuredCommandActions {
+ for (_, action) in commandActions {
let shortcut = shortcutForAction(action)
guard shortcut.hasChord,
shortcut.firstStroke.matches(event: event) else { continue }
@@ -91,7 +91,7 @@ enum MarkdownPreviewKeyboardShortcutResolver {
pendingFirstStroke: ShortcutStroke,
shortcutForAction: ShortcutProvider
) -> MarkdownPreviewKeyCommand? {
- for (command, action) in configuredCommandActions {
+ for (command, action) in commandActions {
let shortcut = shortcutForAction(action)
guard let secondStroke = shortcut.secondStroke,
shortcut.firstStroke == pendingFirstStroke,
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index d8e911fa118..c294a56e534 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -297,7 +297,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
for: event,
pendingFirstStroke: pendingShortcutChord
) else {
- return !ShortcutStroke.isEscapeCancelEvent(event)
+ return true
}
performKeyboardCommand(command)
return true
diff --git a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
index 7ffd4363b10..4c30c392c52 100644
--- a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
+++ b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
@@ -78,4 +78,20 @@ final class KeyboardShortcutSpaceKeyTests: XCTestCase {
StoredShortcut(key: "space", command: true, shift: true, option: false, control: false)
)
}
+
+ func testShortcutConfigParsingPreservesImplicitShift() throws {
+ XCTAssertEqual(StoredShortcut.parseConfig("cmd+N")?.configIdentifier, "cmd+shift+n")
+ XCTAssertEqual(
+ StoredShortcut.parseConfig("?", allowBareFirstStroke: true)?.configIdentifier,
+ "shift+/"
+ )
+ XCTAssertEqual(
+ StoredShortcut.parseConfig(strokes: ["ctrl+b", "N"], allowBareFirstStroke: true)?.configIdentifier,
+ "ctrl+b shift+n"
+ )
+ XCTAssertEqual(
+ StoredShortcut.parseConfig(strokes: ["ctrl+b", "?"], allowBareFirstStroke: true)?.configIdentifier,
+ "ctrl+b shift+/"
+ )
+ }
}
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index ba321a85269..4fe648974d1 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -333,6 +333,41 @@ final class MarkdownPanelTests: XCTestCase {
)
}
+ func testMarkdownPreviewDoesNotTreatGlobalFindChordAsPreviewPrefix() throws {
+ let globalFindChord = StoredShortcut(
+ key: "b",
+ command: false,
+ shift: false,
+ option: false,
+ control: true,
+ chordKey: "f"
+ )
+ let provider: MarkdownPreviewKeyboardShortcutResolver.ShortcutProvider = { action in
+ action == .find ? globalFindChord : action.defaultShortcut
+ }
+ let prefixStroke = ShortcutStroke(
+ key: "b",
+ command: false,
+ shift: false,
+ option: false,
+ control: true
+ )
+
+ XCTAssertNil(
+ MarkdownPreviewKeyboardShortcutResolver.chordPrefix(
+ for: try keyDownEvent("b", keyCode: 11, modifiers: [.control]),
+ shortcutForAction: provider
+ )
+ )
+ XCTAssertNil(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("f", keyCode: 3),
+ pendingFirstStroke: prefixStroke,
+ shortcutForAction: provider
+ )
+ )
+ }
+
func testMarkdownPreviewKeyboardShortcutsCanUseBareCmuxJSONBindings() throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-markdown-shortcuts-\(UUID().uuidString).json")
@@ -414,6 +449,7 @@ final class MarkdownPanelTests: XCTestCase {
XCTAssertTrue(
coordinator.handleKeyboardShortcut(try keyDownEvent("b", keyCode: 11, modifiers: [.control]))
)
+ XCTAssertTrue(coordinator.handleKeyboardShortcut(try keyDownEvent("x", keyCode: 7)))
XCTAssertTrue(coordinator.handleKeyboardShortcut(try keyDownEvent("j", keyCode: 38)))
}
@@ -603,13 +639,13 @@ final class MarkdownPanelTests: XCTestCase {
""",
in: webView
)
- XCTAssertEqual(domShortcutSnapshot["cmdFCanceled"] ?? 0, 1)
- XCTAssertEqual(domShortcutSnapshot["ctrlNCanceled"] ?? 0, 1)
- XCTAssertEqual(domShortcutSnapshot["ctrlPCanceled"] ?? 0, 1)
- XCTAssertEqual(domShortcutSnapshot["calls"] ?? 0, 3)
- XCTAssertEqual(domShortcutSnapshot["firstBackwards"] ?? -1, 0)
- XCTAssertEqual(domShortcutSnapshot["secondBackwards"] ?? -1, 0)
- XCTAssertEqual(domShortcutSnapshot["thirdBackwards"] ?? -1, 1)
+ XCTAssertEqual(domShortcutSnapshot["cmdFCanceled"] ?? 1, 0)
+ XCTAssertEqual(domShortcutSnapshot["ctrlNCanceled"] ?? 1, 0)
+ XCTAssertEqual(domShortcutSnapshot["ctrlPCanceled"] ?? 1, 0)
+ XCTAssertEqual(domShortcutSnapshot["calls"] ?? -1, 0)
+ XCTAssertEqual(domShortcutSnapshot["firstBackwards"] ?? -1, -1)
+ XCTAssertEqual(domShortcutSnapshot["secondBackwards"] ?? -1, -1)
+ XCTAssertEqual(domShortcutSnapshot["thirdBackwards"] ?? -1, -1)
}
func testMarkdownRendererKeepsRecoveryBudgetAfterShellReload() {
diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx
index 9fbbae5535b..c4cc01ca249 100644
--- a/web/app/[locale]/docs/configuration/page.tsx
+++ b/web/app/[locale]/docs/configuration/page.tsx
@@ -377,8 +377,11 @@ working-directory = ~/code`}
unbound, and disabled. Example chord:{" "}
["ctrl+b", "c"]. Numbered actions use{" "}
1 as the stored default and still match digits 1 through{" "}
- 9. Bare first strokes such as j and / are accepted
- for Markdown preview actions because they are only active inside the focused Markdown panel.
+ 9.{" "}
+ {t.rich("bareMarkdownPreviewStrokes", {
+ j: (chunks) => {chunks},
+ slash: (chunks) => {chunks},
+ })}
The defaults below are the same cmux-owned actions listed on the{" "}
diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json
index 47a8ca021d9..c746cdec747 100644
--- a/web/data/cmux.schema.json
+++ b/web/data/cmux.schema.json
@@ -1021,11 +1021,11 @@
},
"shortcutFirstStroke": {
"$ref": "#/$defs/shortcutStroke",
- "description": "First or only shortcut stroke. Most actions require a modifier unless the key is Space. Markdown preview actions also accept bare first strokes such as h, j, k, l, /, and n."
+ "descriptionKey": "schemaDescriptions.shortcuts.shortcutFirstStroke"
},
"shortcutStroke": {
"type": "string",
- "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$",
+ "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./?\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$",
"description": "One keyboard shortcut stroke using modifier+key syntax. Supported key names include space, Space, , and ."
}
}
diff --git a/web/messages/ar.json b/web/messages/ar.json
index 6fa59085664..0692eb6cc91 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "يتحكم في وقت طلب cmux التأكيد قبل الإنهاء. تنتهي إصدارات DEV فوراً دائماً بغض النظر عن هذا الإعداد. يظل app.warnBeforeQuit القديم مقبولاً كخيار احتياطي منطقي.",
"warnBeforeQuit": "خيار منطقي قديم احتياطي لتأكيد الإنهاء. استخدم app.confirmQuit للإعدادات الجديدة."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "الضغطة الأولى أو الوحيدة للاختصار. تتطلب معظم الإجراءات مفتاح تعديل إلا إذا كان المفتاح Space. تقبل إجراءات معاينة Markdown أيضًا ضغطات أولى منفردة مثل h و j و k و l و / و n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "تقبل إجراءات معاينة Markdown الضغطات الأولى المنفردة مثل j و / لأنها لا تكون نشطة إلا داخل لوحة Markdown المركزة."
},
"customCommands": {
"title": "أوامر مخصصة",
diff --git a/web/messages/bs.json b/web/messages/bs.json
index 0b12d6c0868..38dd48592de 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Određuje kada cmux traži potvrdu prije izlaska. DEV verzije se uvijek odmah zatvaraju bez obzira na ovu postavku. Naslijeđeni app.warnBeforeQuit se i dalje prihvata kao boolean rezervna opcija.",
"warnBeforeQuit": "Naslijeđena boolean rezervna opcija za potvrdu izlaska. Za nove konfiguracije koristite app.confirmQuit."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Prva ili jedina tipka prečice. Većina radnji zahtijeva modifikator osim ako je tipka Space. Radnje Markdown pregleda prihvataju i samostalne prve tipke kao što su h, j, k, l, / i n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Radnje Markdown pregleda prihvataju samostalne prve tipke kao što su j i /, jer su aktivne samo u fokusiranom Markdown panelu."
},
"customCommands": {
"title": "Prilagođene komande",
diff --git a/web/messages/da.json b/web/messages/da.json
index a1afa063beb..9da07189c97 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Styrer hvornår cmux beder om bekræftelse før afslutning. DEV-builds afsluttes altid straks uanset denne indstilling. Den ældre app.warnBeforeQuit accepteres stadig som boolesk fallback.",
"warnBeforeQuit": "Ældre boolesk fallback til afslutningsbekræftelse. Brug app.confirmQuit til nye konfigurationer."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Første eller eneste genvejstast. De fleste handlinger kræver en modifikatortast, medmindre tasten er Space. Markdown-forhåndsvisningshandlinger accepterer også enkelte første tastetryk som h, j, k, l, / og n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown-forhåndsvisningshandlinger accepterer enkelte første tastetryk som j og /, fordi de kun er aktive i det fokuserede Markdown-panel."
},
"customCommands": {
"title": "Brugerdefinerede kommandoer",
diff --git a/web/messages/de.json b/web/messages/de.json
index 7b16e909eb1..1b54ed2422c 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Steuert, wann cmux vor dem Beenden eine Bestätigung anfordert. DEV-Builds beenden sich unabhängig von dieser Einstellung immer sofort. Das ältere app.warnBeforeQuit wird weiterhin als boolescher Fallback akzeptiert.",
"warnBeforeQuit": "Älterer boolescher Fallback für die Beenden-Bestätigung. Verwenden Sie app.confirmQuit für neue Konfigurationen."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Erster oder einziger Kurzbefehl-Tastenanschlag. Die meisten Aktionen erfordern eine Zusatztaste, außer die Taste ist Space. Markdown-Vorschau-Aktionen akzeptieren auch einzelne erste Tasten wie h, j, k, l, / und n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown-Vorschau-Aktionen akzeptieren einzelne erste Tasten wie j und /, weil sie nur im fokussierten Markdown-Panel aktiv sind."
},
"customCommands": {
"title": "Benutzerdefinierte Befehle",
diff --git a/web/messages/en.json b/web/messages/en.json
index 9f503ae4ac6..316a095db37 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -595,8 +595,12 @@
"app": {
"confirmQuit": "Control when cmux asks for confirmation before quitting. DEV builds always quit immediately regardless of this setting. Legacy app.warnBeforeQuit is still accepted as a boolean fallback.",
"warnBeforeQuit": "Legacy boolean fallback for quit confirmation. Use app.confirmQuit for new configs."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "First or only shortcut stroke. Most actions require a modifier unless the key is Space. Markdown preview actions also accept bare first strokes such as h, j, k, l, /, and n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Bare first strokes such as j and / are accepted for Markdown preview actions because they are only active inside the focused Markdown panel."
},
"customCommands": {
"title": "Custom Commands",
diff --git a/web/messages/es.json b/web/messages/es.json
index b70695c3bcf..5a488b92e6d 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Controla cuándo cmux pide confirmación antes de salir. Las compilaciones DEV siempre se cierran inmediatamente sin importar esta opción. El app.warnBeforeQuit heredado aún se acepta como alternativa booleana.",
"warnBeforeQuit": "Alternativa booleana heredada para la confirmación de salida. Usa app.confirmQuit para configuraciones nuevas."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Primera o única pulsación del atajo. La mayoría de acciones requieren un modificador salvo que la tecla sea Space. Las acciones de vista previa de Markdown también aceptan primeras pulsaciones sueltas como h, j, k, l, / y n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Las acciones de vista previa de Markdown aceptan primeras pulsaciones sueltas como j y / porque solo están activas dentro del panel Markdown enfocado."
},
"customCommands": {
"title": "Comandos personalizados",
diff --git a/web/messages/fr.json b/web/messages/fr.json
index 340d6563211..ed8ea694529 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Contrôle quand cmux demande confirmation avant de quitter. Les builds DEV quittent toujours immédiatement, quel que soit ce réglage. L'ancien app.warnBeforeQuit reste accepté comme fallback booléen.",
"warnBeforeQuit": "Fallback booléen hérité pour la confirmation de fermeture. Utilisez app.confirmQuit pour les nouvelles configurations."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Première ou unique touche du raccourci. La plupart des actions exigent un modificateur, sauf si la touche est Space. Les actions d’aperçu Markdown acceptent aussi des premières touches seules comme h, j, k, l, / et n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Les actions d’aperçu Markdown acceptent des premières touches seules comme j et /, car elles ne sont actives que dans le panneau Markdown ciblé."
},
"customCommands": {
"title": "Commandes personnalisées",
diff --git a/web/messages/it.json b/web/messages/it.json
index 8dfc4980fb9..9a2f339cbd9 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Controlla quando cmux chiede conferma prima di uscire. Le build DEV escono sempre immediatamente indipendentemente da questa impostazione. Il vecchio app.warnBeforeQuit è ancora accettato come fallback booleano.",
"warnBeforeQuit": "Fallback booleano legacy per la conferma di uscita. Usa app.confirmQuit per le nuove configurazioni."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Primo o unico tasto della scorciatoia. La maggior parte delle azioni richiede un modificatore, a meno che il tasto sia Space. Le azioni dell’anteprima Markdown accettano anche primi tasti singoli come h, j, k, l, / e n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Le azioni dell’anteprima Markdown accettano primi tasti singoli come j e /, perché sono attive solo nel pannello Markdown in focus."
},
"customCommands": {
"title": "Comandi personalizzati",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 48765e67d7e..09b2f28e32e 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -554,8 +554,12 @@
"app": {
"confirmQuit": "cmux の終了前に確認を求めるタイミングを制御します。DEV ビルドではこの設定に関係なく常にすぐ終了します。従来の app.warnBeforeQuit も boolean フォールバックとして引き続き受け付けます。",
"warnBeforeQuit": "終了確認用の従来の boolean フォールバックです。新しい設定では app.confirmQuit を使ってください。"
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "最初または唯一のショートカットストローク。ほとんどの操作では、キーが Space の場合を除き修飾キーが必要です。Markdown プレビュー操作では h、j、k、l、/、n などの単独の第1ストロークも使えます。"
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown プレビューの操作では、フォーカス中の Markdown パネル内でのみ有効なため、j や / などの単独の第1ストロークを使えます。"
},
"customCommands": {
"title": "カスタムコマンド",
diff --git a/web/messages/km.json b/web/messages/km.json
index 17c6d7180ec..2923dcbf96c 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "កំណត់ពេលដែល cmux ស្នើសុំការបញ្ជាក់មុនពេលចាកចេញ។ កំណែ DEV តែងតែចាកចេញភ្លាមៗ ដោយមិនគិតពីការកំណត់នេះទេ។ app.warnBeforeQuit ចាស់នៅតែត្រូវបានទទួលយកជាជម្រើសបម្រុង boolean។",
"warnBeforeQuit": "ជម្រើសបម្រុង boolean ចាស់សម្រាប់ការបញ្ជាក់មុនចាកចេញ។ ប្រើ app.confirmQuit សម្រាប់ការកំណត់ថ្មី។"
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "គ្រាប់ចុចដំបូង ឬគ្រាប់ចុចតែមួយរបស់ផ្លូវកាត់។ សកម្មភាពភាគច្រើនត្រូវការគ្រាប់ចុចកែប្រែ លើកលែងតែគ្រាប់ចុច Space។ សកម្មភាពមើលជាមុន Markdown ក៏ទទួលយកគ្រាប់ចុចដំបូងដាច់ដោយឡែកដូចជា h, j, k, l, / និង n។"
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "សកម្មភាពមើលជាមុន Markdown ទទួលយកគ្រាប់ចុចដំបូងដាច់ដោយឡែកដូចជា j និង / ព្រោះវាសកម្មតែក្នុងផ្ទាំង Markdown ដែលកំពុងផ្ដោត។"
},
"customCommands": {
"title": "ពាក្យបញ្ជាផ្ទាល់ខ្លួន",
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 3691a004b4d..60b6d59a4f5 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "cmux가 종료 전에 확인을 요청하는 시점을 제어합니다. DEV 빌드는 이 설정과 관계없이 항상 즉시 종료됩니다. 기존 app.warnBeforeQuit도 boolean 대체값으로 계속 허용됩니다.",
"warnBeforeQuit": "종료 확인을 위한 기존 boolean 대체값입니다. 새 구성에는 app.confirmQuit을 사용하세요."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "첫 번째 또는 유일한 단축키 입력입니다. 키가 Space가 아닌 경우 대부분의 동작에는 보조 키가 필요합니다. Markdown 미리보기 동작은 h, j, k, l, /, n 같은 단일 첫 번째 키 입력도 허용합니다."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown 미리보기 동작은 포커스된 Markdown 패널 안에서만 활성화되므로 j 및 / 같은 단일 첫 번째 키 입력을 사용할 수 있습니다."
},
"customCommands": {
"title": "사용자 정의 명령어",
diff --git a/web/messages/no.json b/web/messages/no.json
index 068133d00a6..aaa5694c84a 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Styrer når cmux ber om bekreftelse før avslutning. DEV-bygg avslutter alltid umiddelbart uansett denne innstillingen. Den eldre app.warnBeforeQuit godtas fortsatt som boolsk fallback.",
"warnBeforeQuit": "Eldre boolsk fallback for avslutningsbekreftelse. Bruk app.confirmQuit for nye konfigurasjoner."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Første eller eneste snarveistast. De fleste handlinger krever en modifikatortast med mindre tasten er Space. Handlinger for Markdown-forhåndsvisning godtar også enkle første tastetrykk som h, j, k, l, / og n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Handlinger for Markdown-forhåndsvisning godtar enkle første tastetrykk som j og /, fordi de bare er aktive i det fokuserte Markdown-panelet."
},
"customCommands": {
"title": "Egendefinerte kommandoer",
diff --git a/web/messages/pl.json b/web/messages/pl.json
index 88cfc39632f..0524fc69da9 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Określa, kiedy cmux prosi o potwierdzenie przed zamknięciem. Buildy DEV zawsze zamykają się natychmiast niezależnie od tego ustawienia. Starsze app.warnBeforeQuit jest nadal akceptowane jako zapasowa wartość logiczna.",
"warnBeforeQuit": "Starsza zapasowa wartość logiczna dla potwierdzenia zamknięcia. W nowych konfiguracjach używaj app.confirmQuit."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Pierwszy lub jedyny klawisz skrótu. Większość akcji wymaga modyfikatora, chyba że klawiszem jest Space. Akcje podglądu Markdown akceptują też pojedyncze pierwsze klawisze, takie jak h, j, k, l, / i n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Akcje podglądu Markdown akceptują pojedyncze pierwsze klawisze, takie jak j i /, ponieważ działają tylko w aktywnym panelu Markdown."
},
"customCommands": {
"title": "Niestandardowe polecenia",
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index c67e1654164..3d5f2e58499 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Controla quando o cmux pede confirmação antes de sair. Builds DEV sempre saem imediatamente, independentemente desta configuração. O app.warnBeforeQuit legado ainda é aceito como fallback booleano.",
"warnBeforeQuit": "Fallback booleano legado para confirmação de saída. Use app.confirmQuit em novas configurações."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Primeiro ou único toque do atalho. A maioria das ações exige um modificador, exceto quando a tecla é Space. As ações da prévia de Markdown também aceitam primeiros toques isolados, como h, j, k, l, / e n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "As ações da prévia de Markdown aceitam primeiros toques isolados, como j e /, porque só ficam ativas no painel Markdown em foco."
},
"customCommands": {
"title": "Comandos personalizados",
diff --git a/web/messages/ru.json b/web/messages/ru.json
index 19b5d21b02c..61970d69641 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Управляет тем, когда cmux запрашивает подтверждение перед выходом. DEV-сборки всегда завершаются сразу независимо от этой настройки. Устаревший app.warnBeforeQuit по-прежнему принимается как булевый запасной вариант.",
"warnBeforeQuit": "Устаревший булевый запасной вариант для подтверждения выхода. Для новых конфигураций используйте app.confirmQuit."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Первое или единственное нажатие сочетания клавиш. Большинство действий требует модификатор, кроме клавиши Space. Действия предпросмотра Markdown также принимают одиночные первые нажатия, например h, j, k, l, / и n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Действия предпросмотра Markdown принимают одиночные первые нажатия, например j и /, потому что они активны только в сфокусированной панели Markdown."
},
"customCommands": {
"title": "Пользовательские команды",
diff --git a/web/messages/th.json b/web/messages/th.json
index 55953dbd2d8..e8e7827b8b7 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "ควบคุมว่า cmux จะขอการยืนยันก่อนออกเมื่อใด บิลด์ DEV จะออกทันทีเสมอไม่ว่าค่านี้จะเป็นอย่างไร ค่า app.warnBeforeQuit แบบเดิมยังคงใช้เป็นตัวเลือกสำรองแบบบูลีนได้",
"warnBeforeQuit": "ตัวเลือกสำรองแบบบูลีนเดิมสำหรับการยืนยันก่อนออก ใช้ app.confirmQuit สำหรับการกำหนดค่าใหม่"
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "คีย์แรกหรือคีย์เดียวของปุ่มลัด การทำงานส่วนใหญ่ต้องใช้คีย์ปรับแต่ง เว้นแต่คีย์นั้นคือ Space การทำงานของตัวอย่าง Markdown ยังรับคีย์แรกแบบเดี่ยว เช่น h, j, k, l, / และ n"
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "การทำงานของตัวอย่าง Markdown รับคีย์แรกแบบเดี่ยว เช่น j และ / เพราะทำงานเฉพาะในแผง Markdown ที่โฟกัสอยู่"
},
"customCommands": {
"title": "คำสั่งที่กำหนดเอง",
diff --git a/web/messages/tr.json b/web/messages/tr.json
index 0db16b6aa66..3f9be4e0bd0 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "cmux'un çıkmadan önce ne zaman onay isteyeceğini denetler. DEV derlemeleri bu ayardan bağımsız olarak her zaman hemen çıkar. Eski app.warnBeforeQuit boolean yedeği olarak kabul edilmeye devam eder.",
"warnBeforeQuit": "Çıkış onayı için eski boolean yedeği. Yeni yapılandırmalar için app.confirmQuit kullanın."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Kısayolun ilk veya tek tuşu. Tuş Space olmadığı sürece çoğu eylem bir değiştirici gerektirir. Markdown önizleme eylemleri h, j, k, l, / ve n gibi tek başına ilk tuşları da kabul eder."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown önizleme eylemleri j ve / gibi tek başına ilk tuşları kabul eder, çünkü yalnızca odaktaki Markdown panelinde etkindir."
},
"customCommands": {
"title": "Özel Komutlar",
diff --git a/web/messages/uk.json b/web/messages/uk.json
index ce793c333fa..b7d50f43ae4 100644
--- a/web/messages/uk.json
+++ b/web/messages/uk.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "Керує тим, коли cmux запитує підтвердження перед виходом. DEV-збірки завжди завершуються одразу незалежно від цього налаштування. Застарілий app.warnBeforeQuit і надалі приймається як булевий запасний варіант.",
"warnBeforeQuit": "Застарілий булевий запасний варіант для підтвердження виходу. Для нових конфігурацій використовуйте app.confirmQuit."
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "Перше або єдине натискання скорочення. Більшість дій потребує модифікатор, якщо клавіша не Space. Дії перегляду Markdown також приймають окремі перші натискання, наприклад h, j, k, l, / і n."
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Дії перегляду Markdown приймають окремі перші натискання, наприклад j і /, бо вони активні лише у сфокусованій панелі Markdown."
},
"customCommands": {
"title": "Користувацькі команди",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index d691084d47b..2c896cc4d8d 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "控制 cmux 在退出前何时请求确认。无论此设置如何,DEV 构建都会立即退出。旧的 app.warnBeforeQuit 仍作为布尔回退值接受。",
"warnBeforeQuit": "用于退出确认的旧布尔回退值。新配置请使用 app.confirmQuit。"
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "第一个或唯一的快捷键按键。除 Space 键外,大多数操作都需要修饰键。Markdown 预览操作还接受 h、j、k、l、/、n 等单独的第一按键。"
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown 预览操作接受 j 和 / 等单独的第一按键,因为它们只在当前聚焦的 Markdown 面板中生效。"
},
"customCommands": {
"title": "自定义命令",
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index eedc1081b7a..2acb1c36736 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -494,8 +494,12 @@
"app": {
"confirmQuit": "控制 cmux 在退出前何時要求確認。無論此設定為何,DEV 建置都會立即退出。舊的 app.warnBeforeQuit 仍會作為布林後備值接受。",
"warnBeforeQuit": "用於退出確認的舊版布林後備值。新設定請使用 app.confirmQuit。"
+ },
+ "shortcuts": {
+ "shortcutFirstStroke": "第一個或唯一的快捷鍵按鍵。除了 Space 鍵之外,大多數操作都需要修飾鍵。Markdown 預覽操作也接受 h、j、k、l、/、n 等單獨的第一按鍵。"
}
- }
+ },
+ "bareMarkdownPreviewStrokes": "Markdown 預覽操作可接受 j 和 / 等單獨的第一按鍵,因為它們只會在目前聚焦的 Markdown 面板中作用。"
},
"customCommands": {
"title": "自訂指令",
From a664427a3c944d0bc2432f322a09e3725b40f81f Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 17:45:05 -0700
Subject: [PATCH 08/14] Make markdown find aliases configurable
---
Resources/Localizable.xcstrings | 48 ++++++++++++
Sources/KeyboardShortcutContext.swift | 4 +-
Sources/KeyboardShortcutSettings.swift | 16 +++-
.../MarkdownPreviewKeyboardShortcuts.swift | 11 +--
cmuxTests/MarkdownPanelTests.swift | 45 ++++++++++++
web/data/cmux-shortcuts.ts | 8 +-
web/data/cmux.schema.json | 73 ++++++++++++++++++-
web/messages/ar.json | 3 +-
web/messages/bs.json | 3 +-
web/messages/da.json | 3 +-
web/messages/de.json | 3 +-
web/messages/en.json | 3 +-
web/messages/es.json | 3 +-
web/messages/fr.json | 3 +-
web/messages/it.json | 3 +-
web/messages/ja.json | 3 +-
web/messages/km.json | 3 +-
web/messages/ko.json | 3 +-
web/messages/no.json | 3 +-
web/messages/pl.json | 3 +-
web/messages/pt-BR.json | 3 +-
web/messages/ru.json | 3 +-
web/messages/th.json | 3 +-
web/messages/tr.json | 3 +-
web/messages/uk.json | 3 +-
web/messages/zh-CN.json | 3 +-
web/messages/zh-TW.json | 3 +-
27 files changed, 229 insertions(+), 36 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index f774900298c..e2bfa75b94b 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -116085,6 +116085,54 @@
"uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Знайти попереднє" } }
}
},
+ "shortcut.markdownFindNextAlternate.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Next (Control-N)" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 次を検索(Control-N)" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:查找下一个(Control-N)" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:尋找下一個(Control-N)" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 다음 찾기(Control-N)" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Nächsten Treffer suchen (Control-N)" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar siguiente (Control-N)" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Résultat suivant (Control-N)" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Trova successivo (Control-N)" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Find næste (Control-N)" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Znajdź następne (Control-N)" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Найти следующее (Control-N)" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pronađi sljedeće (Control-N)" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث عن التالي (Control-N)" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Finn neste (Control-N)" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Localizar próximo (Control-N)" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาถัดไป (Control-N)" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Sonrakini bul (Control-N)" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Знайти наступне (Control-N)" } }
+ }
+ },
+ "shortcut.markdownFindPreviousAlternate.label": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": { "stringUnit": { "state": "translated", "value": "Markdown Preview: Find Previous (Control-P)" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "Markdown プレビュー: 前を検索(Control-P)" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "Markdown 预览:查找上一个(Control-P)" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "Markdown 預覽:尋找上一個(Control-P)" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "Markdown 미리보기: 이전 찾기(Control-P)" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Markdown-Vorschau: Vorherigen Treffer suchen (Control-P)" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Vista previa de Markdown: Buscar anterior (Control-P)" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aperçu Markdown : Résultat précédent (Control-P)" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Anteprima Markdown: Trova precedente (Control-P)" } },
+ "da": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Find forrige (Control-P)" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Podgląd Markdown: Znajdź poprzednie (Control-P)" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Предпросмотр Markdown: Найти предыдущее (Control-P)" } },
+ "bs": { "stringUnit": { "state": "translated", "value": "Markdown pregled: Pronađi prethodno (Control-P)" } },
+ "ar": { "stringUnit": { "state": "translated", "value": "معاينة Markdown: البحث عن السابق (Control-P)" } },
+ "nb": { "stringUnit": { "state": "translated", "value": "Markdown-forhåndsvisning: Finn forrige (Control-P)" } },
+ "pt-BR": { "stringUnit": { "state": "translated", "value": "Prévia de Markdown: Localizar anterior (Control-P)" } },
+ "th": { "stringUnit": { "state": "translated", "value": "ตัวอย่าง Markdown: ค้นหาก่อนหน้า (Control-P)" } },
+ "tr": { "stringUnit": { "state": "translated", "value": "Markdown Önizleme: Öncekini bul (Control-P)" } },
+ "uk": { "stringUnit": { "state": "translated", "value": "Перегляд Markdown: Знайти попереднє (Control-P)" } }
+ }
+ },
"markdown.web.searchPromptForward": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift
index b5b81e1d55a..0d7a47ad0d0 100644
--- a/Sources/KeyboardShortcutContext.swift
+++ b/Sources/KeyboardShortcutContext.swift
@@ -61,7 +61,9 @@ extension KeyboardShortcutSettings.Action {
.markdownFindForward,
.markdownFindBackward,
.markdownFindNext,
- .markdownFindPrevious:
+ .markdownFindPrevious,
+ .markdownFindNextAlternate,
+ .markdownFindPreviousAlternate:
return .markdownPanel
case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock:
return .rightSidebarFocus
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index b202f55de28..d207c50ef40 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -133,6 +133,8 @@ enum KeyboardShortcutSettings {
case markdownFindBackward
case markdownFindNext
case markdownFindPrevious
+ case markdownFindNextAlternate
+ case markdownFindPreviousAlternate
case openBrowser
case focusBrowserAddressBar
case browserBack
@@ -221,6 +223,8 @@ enum KeyboardShortcutSettings {
case .markdownFindBackward: return String(localized: "shortcut.markdownFindBackward.label", defaultValue: "Markdown Preview: Search Backward")
case .markdownFindNext: return String(localized: "shortcut.markdownFindNext.label", defaultValue: "Markdown Preview: Find Next")
case .markdownFindPrevious: return String(localized: "shortcut.markdownFindPrevious.label", defaultValue: "Markdown Preview: Find Previous")
+ case .markdownFindNextAlternate: return String(localized: "shortcut.markdownFindNextAlternate.label", defaultValue: "Markdown Preview: Find Next (Control-N)")
+ case .markdownFindPreviousAlternate: return String(localized: "shortcut.markdownFindPreviousAlternate.label", defaultValue: "Markdown Preview: Find Previous (Control-P)")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar")
case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back")
@@ -389,6 +393,10 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "n", command: false, shift: false, option: false, control: false)
case .markdownFindPrevious:
return StoredShortcut(key: "n", command: false, shift: true, option: false, control: false)
+ case .markdownFindNextAlternate:
+ return StoredShortcut(key: "n", command: false, shift: false, option: false, control: true)
+ case .markdownFindPreviousAlternate:
+ return StoredShortcut(key: "p", command: false, shift: false, option: false, control: true)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .focusBrowserAddressBar:
@@ -452,7 +460,9 @@ enum KeyboardShortcutSettings {
.markdownFindForward,
.markdownFindBackward,
.markdownFindNext,
- .markdownFindPrevious:
+ .markdownFindPrevious,
+ .markdownFindNextAlternate,
+ .markdownFindPreviousAlternate:
return true
default:
return false
@@ -470,7 +480,9 @@ enum KeyboardShortcutSettings {
.markdownFindForward,
.markdownFindBackward,
.markdownFindNext,
- .markdownFindPrevious:
+ .markdownFindPrevious,
+ .markdownFindNextAlternate,
+ .markdownFindPreviousAlternate:
return true
default:
return false
diff --git a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
index 044fff54ecf..e1f6a682bf6 100644
--- a/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
+++ b/Sources/Panels/MarkdownPreviewKeyboardShortcuts.swift
@@ -27,6 +27,8 @@ enum MarkdownPreviewKeyboardShortcutResolver {
(.findBackward, .markdownFindBackward),
(.findNext, .markdownFindNext),
(.findPrevious, .markdownFindPrevious),
+ (.findNext, .markdownFindNextAlternate),
+ (.findPrevious, .markdownFindPreviousAlternate),
]
private static let globalFindCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] = [
@@ -35,11 +37,6 @@ enum MarkdownPreviewKeyboardShortcutResolver {
(.findPrevious, .findPrevious),
]
- private static let defaultAliasCommandShortcuts: [(MarkdownPreviewKeyCommand, StoredShortcut)] = [
- (.findNext, StoredShortcut(key: "n", command: false, shift: false, option: false, control: true)),
- (.findPrevious, StoredShortcut(key: "p", command: false, shift: false, option: false, control: true)),
- ]
-
private static var singleStrokeCommandActions: [(MarkdownPreviewKeyCommand, KeyboardShortcutSettings.Action)] {
commandActions + globalFindCommandActions
}
@@ -66,10 +63,6 @@ enum MarkdownPreviewKeyboardShortcutResolver {
guard !shortcut.hasChord, shortcut.matches(event: event) else { continue }
return command
}
- for (command, shortcut) in defaultAliasCommandShortcuts {
- guard shortcut.matches(event: event) else { continue }
- return command
- }
return nil
}
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index 4fe648974d1..8a652feee73 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -368,6 +368,51 @@ final class MarkdownPanelTests: XCTestCase {
)
}
+ func testMarkdownPreviewControlAliasesAreSettingsBacked() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-alias-shortcuts-\(UUID().uuidString).json")
+ try """
+ {
+ "shortcuts": {
+ "bindings": {
+ "markdownFindNextAlternate": "ctrl+j",
+ "markdownFindPreviousAlternate": null
+ }
+ }
+ }
+ """.write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let originalStore = KeyboardShortcutSettings.settingsFileStore
+ KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
+ primaryPath: fileURL.path,
+ fallbackPath: nil,
+ additionalFallbackPaths: [],
+ startWatching: false
+ )
+ defer {
+ KeyboardShortcutSettings.settingsFileStore = originalStore
+ KeyboardShortcutSettings.resetAll()
+ }
+
+ XCTAssertNil(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("n", keyCode: 45, modifiers: [.control])
+ )
+ )
+ XCTAssertEqual(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("j", keyCode: 38, modifiers: [.control])
+ ),
+ .findNext
+ )
+ XCTAssertNil(
+ MarkdownPreviewKeyboardShortcutResolver.command(
+ for: try keyDownEvent("p", keyCode: 35, modifiers: [.control])
+ )
+ )
+ }
+
func testMarkdownPreviewKeyboardShortcutsCanUseBareCmuxJSONBindings() throws {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-markdown-shortcuts-\(UUID().uuidString).json")
diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts
index bd8430b4799..38fb8f82534 100644
--- a/web/data/cmux-shortcuts.ts
+++ b/web/data/cmux-shortcuts.ts
@@ -133,10 +133,12 @@ export const shortcutCategories: ShortcutCategory[] = [
{ id: "markdownScrollRight", combos: [["L"]], description: { en: "Scroll Markdown preview right", ja: "Markdown プレビューを右へスクロール" } },
{ id: "markdownPageUp", combos: [["⌃", "U"]], description: { en: "Move Markdown preview up half a page", ja: "Markdown プレビューを半ページ上へ移動" } },
{ id: "markdownPageDown", combos: [["⌃", "D"]], description: { en: "Move Markdown preview down half a page", ja: "Markdown プレビューを半ページ下へ移動" } },
- { id: "markdownFindForward", combos: [["/"], ["⌘", "F"]], description: { en: "Search Markdown preview forward", ja: "Markdown プレビューを前方検索" } },
+ { id: "markdownFindForward", combos: [["/"]], description: { en: "Search Markdown preview forward", ja: "Markdown プレビューを前方検索" } },
{ id: "markdownFindBackward", combos: [["⇧", "/"]], description: { en: "Search Markdown preview backward", ja: "Markdown プレビューを後方検索" } },
- { id: "markdownFindNext", combos: [["N"], ["⌃", "N"], ["⌘", "G"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
- { id: "markdownFindPrevious", combos: [["⇧", "N"], ["⌃", "P"], ["⌥", "⌘", "G"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
+ { id: "markdownFindNext", combos: [["N"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
+ { id: "markdownFindPrevious", combos: [["⇧", "N"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
+ { id: "markdownFindNextAlternate", combos: [["⌃", "N"]], description: { en: "Find next match in Markdown preview", ja: "Markdown プレビューで次を検索" } },
+ { id: "markdownFindPreviousAlternate", combos: [["⌃", "P"]], description: { en: "Find previous match in Markdown preview", ja: "Markdown プレビューで前を検索" } },
],
},
{
diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json
index c746cdec747..3dbe8ded194 100644
--- a/web/data/cmux.schema.json
+++ b/web/data/cmux.schema.json
@@ -936,6 +936,8 @@
"markdownFindBackward",
"markdownFindNext",
"markdownFindPrevious",
+ "markdownFindNextAlternate",
+ "markdownFindPreviousAlternate",
"openBrowser",
"focusBrowserAddressBar",
"browserBack",
@@ -955,6 +957,20 @@
"toggleReactGrab"
]
},
+ "properties": {
+ "markdownScrollLeft": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownScrollDown": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownScrollUp": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownScrollRight": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownPageUp": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownPageDown": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindForward": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindBackward": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindNext": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindPrevious": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindNextAlternate": { "$ref": "#/$defs/markdownShortcutBindingNullable" },
+ "markdownFindPreviousAlternate": { "$ref": "#/$defs/markdownShortcutBindingNullable" }
+ },
"additionalProperties": {
"$ref": "#/$defs/shortcutBindingNullable"
}
@@ -1015,14 +1031,69 @@
}
]
},
+ "markdownShortcutBinding": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/unboundShortcutBinding",
+ "description": "Unbind this shortcut. Accepted values are an empty string, none, clear, unbound, or disabled."
+ },
+ {
+ "$ref": "#/$defs/markdownShortcutFirstStroke",
+ "description": "Single-stroke Markdown preview shortcut, for example j or ctrl+n."
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 2,
+ "prefixItems": [
+ {
+ "$ref": "#/$defs/markdownShortcutFirstStroke"
+ },
+ {
+ "$ref": "#/$defs/shortcutStroke"
+ }
+ ],
+ "description": "Chorded Markdown preview shortcut. Example: [\"ctrl+b\", \"/\"]."
+ }
+ ]
+ },
+ "markdownShortcutBindingNullable": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/markdownShortcutBinding"
+ },
+ {
+ "type": "null",
+ "description": "Unbind this shortcut."
+ }
+ ]
+ },
"unboundShortcutBinding": {
"type": "string",
"enum": ["", "none", "clear", "unbound", "disabled"]
},
"shortcutFirstStroke": {
- "$ref": "#/$defs/shortcutStroke",
+ "allOf": [
+ {
+ "$ref": "#/$defs/shortcutStroke"
+ },
+ {
+ "anyOf": [
+ {
+ "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)+"
+ },
+ {
+ "pattern": "^(?: |[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>)$"
+ }
+ ]
+ }
+ ],
"descriptionKey": "schemaDescriptions.shortcuts.shortcutFirstStroke"
},
+ "markdownShortcutFirstStroke": {
+ "$ref": "#/$defs/shortcutStroke",
+ "descriptionKey": "schemaDescriptions.shortcuts.markdownShortcutFirstStroke"
+ },
"shortcutStroke": {
"type": "string",
"pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./?\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$",
diff --git a/web/messages/ar.json b/web/messages/ar.json
index 0692eb6cc91..59e2e8e652b 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "خيار منطقي قديم احتياطي لتأكيد الإنهاء. استخدم app.confirmQuit للإعدادات الجديدة."
},
"shortcuts": {
- "shortcutFirstStroke": "الضغطة الأولى أو الوحيدة للاختصار. تتطلب معظم الإجراءات مفتاح تعديل إلا إذا كان المفتاح Space. تقبل إجراءات معاينة Markdown أيضًا ضغطات أولى منفردة مثل h و j و k و l و / و n."
+ "shortcutFirstStroke": "الضغطة الأولى أو الوحيدة للاختصار. يجب أن تتضمن مفتاح تعديل إلا إذا كان المفتاح Space.",
+ "markdownShortcutFirstStroke": "الضغطة الأولى أو الوحيدة لاختصار معاينة Markdown. تقبل إجراءات معاينة Markdown ضغطات أولى منفردة مثل h و j و k و l و / و n لأنها محلية للوحة Markdown المركزة."
}
},
"bareMarkdownPreviewStrokes": "تقبل إجراءات معاينة Markdown الضغطات الأولى المنفردة مثل j و / لأنها لا تكون نشطة إلا داخل لوحة Markdown المركزة."
diff --git a/web/messages/bs.json b/web/messages/bs.json
index 38dd48592de..bdc976102bc 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Naslijeđena boolean rezervna opcija za potvrdu izlaska. Za nove konfiguracije koristite app.confirmQuit."
},
"shortcuts": {
- "shortcutFirstStroke": "Prva ili jedina tipka prečice. Većina radnji zahtijeva modifikator osim ako je tipka Space. Radnje Markdown pregleda prihvataju i samostalne prve tipke kao što su h, j, k, l, / i n."
+ "shortcutFirstStroke": "Prva ili jedina tipka prečice. Mora sadržavati modifikator osim ako je tipka Space.",
+ "markdownShortcutFirstStroke": "Prva ili jedina tipka prečice Markdown pregleda. Radnje Markdown pregleda prihvataju samostalne prve tipke kao što su h, j, k, l, / i n jer su lokalne za fokusirani Markdown panel."
}
},
"bareMarkdownPreviewStrokes": "Radnje Markdown pregleda prihvataju samostalne prve tipke kao što su j i /, jer su aktivne samo u fokusiranom Markdown panelu."
diff --git a/web/messages/da.json b/web/messages/da.json
index 9da07189c97..0357e04a06e 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Ældre boolesk fallback til afslutningsbekræftelse. Brug app.confirmQuit til nye konfigurationer."
},
"shortcuts": {
- "shortcutFirstStroke": "Første eller eneste genvejstast. De fleste handlinger kræver en modifikatortast, medmindre tasten er Space. Markdown-forhåndsvisningshandlinger accepterer også enkelte første tastetryk som h, j, k, l, / og n."
+ "shortcutFirstStroke": "Første eller eneste genvejstast. Skal indeholde en modifikatortast, medmindre tasten er Space.",
+ "markdownShortcutFirstStroke": "Første eller eneste genvejstast til Markdown-forhåndsvisning. Markdown-forhåndsvisningshandlinger accepterer enkelte første tastetryk som h, j, k, l, / og n, fordi de er lokale i det fokuserede Markdown-panel."
}
},
"bareMarkdownPreviewStrokes": "Markdown-forhåndsvisningshandlinger accepterer enkelte første tastetryk som j og /, fordi de kun er aktive i det fokuserede Markdown-panel."
diff --git a/web/messages/de.json b/web/messages/de.json
index 1b54ed2422c..edeff48b2a4 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Älterer boolescher Fallback für die Beenden-Bestätigung. Verwenden Sie app.confirmQuit für neue Konfigurationen."
},
"shortcuts": {
- "shortcutFirstStroke": "Erster oder einziger Kurzbefehl-Tastenanschlag. Die meisten Aktionen erfordern eine Zusatztaste, außer die Taste ist Space. Markdown-Vorschau-Aktionen akzeptieren auch einzelne erste Tasten wie h, j, k, l, / und n."
+ "shortcutFirstStroke": "Erster oder einziger Kurzbefehl-Tastenanschlag. Muss eine Zusatztaste enthalten, außer die Taste ist Space.",
+ "markdownShortcutFirstStroke": "Erster oder einziger Markdown-Vorschau-Kurzbefehl. Markdown-Vorschau-Aktionen akzeptieren einzelne erste Tasten wie h, j, k, l, / und n, weil sie nur im fokussierten Markdown-Panel gelten."
}
},
"bareMarkdownPreviewStrokes": "Markdown-Vorschau-Aktionen akzeptieren einzelne erste Tasten wie j und /, weil sie nur im fokussierten Markdown-Panel aktiv sind."
diff --git a/web/messages/en.json b/web/messages/en.json
index 316a095db37..426ada49de1 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -597,7 +597,8 @@
"warnBeforeQuit": "Legacy boolean fallback for quit confirmation. Use app.confirmQuit for new configs."
},
"shortcuts": {
- "shortcutFirstStroke": "First or only shortcut stroke. Most actions require a modifier unless the key is Space. Markdown preview actions also accept bare first strokes such as h, j, k, l, /, and n."
+ "shortcutFirstStroke": "First or only shortcut stroke. Must include a modifier unless the key is Space.",
+ "markdownShortcutFirstStroke": "First or only Markdown preview shortcut stroke. Markdown preview actions accept bare first strokes such as h, j, k, l, /, and n because they are local to the focused Markdown panel."
}
},
"bareMarkdownPreviewStrokes": "Bare first strokes such as j and / are accepted for Markdown preview actions because they are only active inside the focused Markdown panel."
diff --git a/web/messages/es.json b/web/messages/es.json
index 5a488b92e6d..84dff23dbc1 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Alternativa booleana heredada para la confirmación de salida. Usa app.confirmQuit para configuraciones nuevas."
},
"shortcuts": {
- "shortcutFirstStroke": "Primera o única pulsación del atajo. La mayoría de acciones requieren un modificador salvo que la tecla sea Space. Las acciones de vista previa de Markdown también aceptan primeras pulsaciones sueltas como h, j, k, l, / y n."
+ "shortcutFirstStroke": "Primera o única pulsación del atajo. Debe incluir un modificador salvo que la tecla sea Space.",
+ "markdownShortcutFirstStroke": "Primera o única pulsación del atajo de vista previa de Markdown. Las acciones de vista previa de Markdown aceptan primeras pulsaciones sueltas como h, j, k, l, / y n porque son locales al panel Markdown enfocado."
}
},
"bareMarkdownPreviewStrokes": "Las acciones de vista previa de Markdown aceptan primeras pulsaciones sueltas como j y / porque solo están activas dentro del panel Markdown enfocado."
diff --git a/web/messages/fr.json b/web/messages/fr.json
index ed8ea694529..be2be5962bb 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Fallback booléen hérité pour la confirmation de fermeture. Utilisez app.confirmQuit pour les nouvelles configurations."
},
"shortcuts": {
- "shortcutFirstStroke": "Première ou unique touche du raccourci. La plupart des actions exigent un modificateur, sauf si la touche est Space. Les actions d’aperçu Markdown acceptent aussi des premières touches seules comme h, j, k, l, / et n."
+ "shortcutFirstStroke": "Première ou unique touche du raccourci. Doit inclure un modificateur, sauf si la touche est Space.",
+ "markdownShortcutFirstStroke": "Première ou unique touche du raccourci d’aperçu Markdown. Les actions d’aperçu Markdown acceptent des premières touches seules comme h, j, k, l, / et n, car elles sont locales au panneau Markdown ciblé."
}
},
"bareMarkdownPreviewStrokes": "Les actions d’aperçu Markdown acceptent des premières touches seules comme j et /, car elles ne sont actives que dans le panneau Markdown ciblé."
diff --git a/web/messages/it.json b/web/messages/it.json
index 9a2f339cbd9..0d843bc6655 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Fallback booleano legacy per la conferma di uscita. Usa app.confirmQuit per le nuove configurazioni."
},
"shortcuts": {
- "shortcutFirstStroke": "Primo o unico tasto della scorciatoia. La maggior parte delle azioni richiede un modificatore, a meno che il tasto sia Space. Le azioni dell’anteprima Markdown accettano anche primi tasti singoli come h, j, k, l, / e n."
+ "shortcutFirstStroke": "Primo o unico tasto della scorciatoia. Deve includere un modificatore, a meno che il tasto sia Space.",
+ "markdownShortcutFirstStroke": "Primo o unico tasto della scorciatoia dell’anteprima Markdown. Le azioni dell’anteprima Markdown accettano primi tasti singoli come h, j, k, l, / e n perché sono locali al pannello Markdown in focus."
}
},
"bareMarkdownPreviewStrokes": "Le azioni dell’anteprima Markdown accettano primi tasti singoli come j e /, perché sono attive solo nel pannello Markdown in focus."
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 09b2f28e32e..f776492dcc5 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -556,7 +556,8 @@
"warnBeforeQuit": "終了確認用の従来の boolean フォールバックです。新しい設定では app.confirmQuit を使ってください。"
},
"shortcuts": {
- "shortcutFirstStroke": "最初または唯一のショートカットストローク。ほとんどの操作では、キーが Space の場合を除き修飾キーが必要です。Markdown プレビュー操作では h、j、k、l、/、n などの単独の第1ストロークも使えます。"
+ "shortcutFirstStroke": "最初または唯一のショートカットストローク。キーが Space の場合を除き、修飾キーが必要です。",
+ "markdownShortcutFirstStroke": "Markdown プレビューショートカットの最初または唯一のストローク。Markdown プレビュー操作はフォーカス中の Markdown パネルだけで動作するため、h、j、k、l、/、n などの単独の第1ストロークも使えます。"
}
},
"bareMarkdownPreviewStrokes": "Markdown プレビューの操作では、フォーカス中の Markdown パネル内でのみ有効なため、j や / などの単独の第1ストロークを使えます。"
diff --git a/web/messages/km.json b/web/messages/km.json
index 2923dcbf96c..841eba536c1 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "ជម្រើសបម្រុង boolean ចាស់សម្រាប់ការបញ្ជាក់មុនចាកចេញ។ ប្រើ app.confirmQuit សម្រាប់ការកំណត់ថ្មី។"
},
"shortcuts": {
- "shortcutFirstStroke": "គ្រាប់ចុចដំបូង ឬគ្រាប់ចុចតែមួយរបស់ផ្លូវកាត់។ សកម្មភាពភាគច្រើនត្រូវការគ្រាប់ចុចកែប្រែ លើកលែងតែគ្រាប់ចុច Space។ សកម្មភាពមើលជាមុន Markdown ក៏ទទួលយកគ្រាប់ចុចដំបូងដាច់ដោយឡែកដូចជា h, j, k, l, / និង n។"
+ "shortcutFirstStroke": "គ្រាប់ចុចដំបូង ឬគ្រាប់ចុចតែមួយរបស់ផ្លូវកាត់។ ត្រូវតែមានគ្រាប់ចុចកែប្រែ លើកលែងតែគ្រាប់ចុច Space។",
+ "markdownShortcutFirstStroke": "គ្រាប់ចុចដំបូង ឬគ្រាប់ចុចតែមួយរបស់ផ្លូវកាត់មើលជាមុន Markdown។ សកម្មភាពមើលជាមុន Markdown ទទួលយកគ្រាប់ចុចដំបូងដាច់ដោយឡែកដូចជា h, j, k, l, / និង n ព្រោះវាស្ថិតក្នុងផ្ទាំង Markdown ដែលកំពុងផ្ដោត។"
}
},
"bareMarkdownPreviewStrokes": "សកម្មភាពមើលជាមុន Markdown ទទួលយកគ្រាប់ចុចដំបូងដាច់ដោយឡែកដូចជា j និង / ព្រោះវាសកម្មតែក្នុងផ្ទាំង Markdown ដែលកំពុងផ្ដោត។"
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 60b6d59a4f5..470429bd32d 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "종료 확인을 위한 기존 boolean 대체값입니다. 새 구성에는 app.confirmQuit을 사용하세요."
},
"shortcuts": {
- "shortcutFirstStroke": "첫 번째 또는 유일한 단축키 입력입니다. 키가 Space가 아닌 경우 대부분의 동작에는 보조 키가 필요합니다. Markdown 미리보기 동작은 h, j, k, l, /, n 같은 단일 첫 번째 키 입력도 허용합니다."
+ "shortcutFirstStroke": "첫 번째 또는 유일한 단축키 입력입니다. 키가 Space가 아닌 경우 보조 키가 필요합니다.",
+ "markdownShortcutFirstStroke": "Markdown 미리보기 단축키의 첫 번째 또는 유일한 입력입니다. Markdown 미리보기 동작은 포커스된 Markdown 패널에만 적용되므로 h, j, k, l, /, n 같은 단일 첫 번째 키 입력도 허용합니다."
}
},
"bareMarkdownPreviewStrokes": "Markdown 미리보기 동작은 포커스된 Markdown 패널 안에서만 활성화되므로 j 및 / 같은 단일 첫 번째 키 입력을 사용할 수 있습니다."
diff --git a/web/messages/no.json b/web/messages/no.json
index aaa5694c84a..0219f720855 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Eldre boolsk fallback for avslutningsbekreftelse. Bruk app.confirmQuit for nye konfigurasjoner."
},
"shortcuts": {
- "shortcutFirstStroke": "Første eller eneste snarveistast. De fleste handlinger krever en modifikatortast med mindre tasten er Space. Handlinger for Markdown-forhåndsvisning godtar også enkle første tastetrykk som h, j, k, l, / og n."
+ "shortcutFirstStroke": "Første eller eneste snarveistast. Må inneholde en modifikatortast med mindre tasten er Space.",
+ "markdownShortcutFirstStroke": "Første eller eneste snarveistast for Markdown-forhåndsvisning. Handlinger for Markdown-forhåndsvisning godtar enkle første tastetrykk som h, j, k, l, / og n fordi de er lokale i det fokuserte Markdown-panelet."
}
},
"bareMarkdownPreviewStrokes": "Handlinger for Markdown-forhåndsvisning godtar enkle første tastetrykk som j og /, fordi de bare er aktive i det fokuserte Markdown-panelet."
diff --git a/web/messages/pl.json b/web/messages/pl.json
index 0524fc69da9..5ea487aded1 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Starsza zapasowa wartość logiczna dla potwierdzenia zamknięcia. W nowych konfiguracjach używaj app.confirmQuit."
},
"shortcuts": {
- "shortcutFirstStroke": "Pierwszy lub jedyny klawisz skrótu. Większość akcji wymaga modyfikatora, chyba że klawiszem jest Space. Akcje podglądu Markdown akceptują też pojedyncze pierwsze klawisze, takie jak h, j, k, l, / i n."
+ "shortcutFirstStroke": "Pierwszy lub jedyny klawisz skrótu. Musi zawierać modyfikator, chyba że klawiszem jest Space.",
+ "markdownShortcutFirstStroke": "Pierwszy lub jedyny klawisz skrótu podglądu Markdown. Akcje podglądu Markdown akceptują pojedyncze pierwsze klawisze, takie jak h, j, k, l, / i n, ponieważ działają lokalnie w aktywnym panelu Markdown."
}
},
"bareMarkdownPreviewStrokes": "Akcje podglądu Markdown akceptują pojedyncze pierwsze klawisze, takie jak j i /, ponieważ działają tylko w aktywnym panelu Markdown."
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index 3d5f2e58499..0ba1cd27147 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Fallback booleano legado para confirmação de saída. Use app.confirmQuit em novas configurações."
},
"shortcuts": {
- "shortcutFirstStroke": "Primeiro ou único toque do atalho. A maioria das ações exige um modificador, exceto quando a tecla é Space. As ações da prévia de Markdown também aceitam primeiros toques isolados, como h, j, k, l, / e n."
+ "shortcutFirstStroke": "Primeiro ou único toque do atalho. Deve incluir um modificador, exceto quando a tecla é Space.",
+ "markdownShortcutFirstStroke": "Primeiro ou único toque do atalho da prévia de Markdown. As ações da prévia de Markdown aceitam primeiros toques isolados, como h, j, k, l, / e n, porque são locais ao painel Markdown em foco."
}
},
"bareMarkdownPreviewStrokes": "As ações da prévia de Markdown aceitam primeiros toques isolados, como j e /, porque só ficam ativas no painel Markdown em foco."
diff --git a/web/messages/ru.json b/web/messages/ru.json
index 61970d69641..6dbd2e64077 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Устаревший булевый запасной вариант для подтверждения выхода. Для новых конфигураций используйте app.confirmQuit."
},
"shortcuts": {
- "shortcutFirstStroke": "Первое или единственное нажатие сочетания клавиш. Большинство действий требует модификатор, кроме клавиши Space. Действия предпросмотра Markdown также принимают одиночные первые нажатия, например h, j, k, l, / и n."
+ "shortcutFirstStroke": "Первое или единственное нажатие сочетания клавиш. Должно включать модификатор, кроме клавиши Space.",
+ "markdownShortcutFirstStroke": "Первое или единственное нажатие сочетания предпросмотра Markdown. Действия предпросмотра Markdown принимают одиночные первые нажатия, например h, j, k, l, / и n, потому что они локальны для сфокусированной панели Markdown."
}
},
"bareMarkdownPreviewStrokes": "Действия предпросмотра Markdown принимают одиночные первые нажатия, например j и /, потому что они активны только в сфокусированной панели Markdown."
diff --git a/web/messages/th.json b/web/messages/th.json
index e8e7827b8b7..7624618c4ea 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "ตัวเลือกสำรองแบบบูลีนเดิมสำหรับการยืนยันก่อนออก ใช้ app.confirmQuit สำหรับการกำหนดค่าใหม่"
},
"shortcuts": {
- "shortcutFirstStroke": "คีย์แรกหรือคีย์เดียวของปุ่มลัด การทำงานส่วนใหญ่ต้องใช้คีย์ปรับแต่ง เว้นแต่คีย์นั้นคือ Space การทำงานของตัวอย่าง Markdown ยังรับคีย์แรกแบบเดี่ยว เช่น h, j, k, l, / และ n"
+ "shortcutFirstStroke": "คีย์แรกหรือคีย์เดียวของปุ่มลัด ต้องมีคีย์ปรับแต่ง เว้นแต่คีย์นั้นคือ Space",
+ "markdownShortcutFirstStroke": "คีย์แรกหรือคีย์เดียวของปุ่มลัดตัวอย่าง Markdown การทำงานของตัวอย่าง Markdown รับคีย์แรกแบบเดี่ยว เช่น h, j, k, l, / และ n เพราะจำกัดอยู่ในแผง Markdown ที่โฟกัสอยู่"
}
},
"bareMarkdownPreviewStrokes": "การทำงานของตัวอย่าง Markdown รับคีย์แรกแบบเดี่ยว เช่น j และ / เพราะทำงานเฉพาะในแผง Markdown ที่โฟกัสอยู่"
diff --git a/web/messages/tr.json b/web/messages/tr.json
index 3f9be4e0bd0..1d2ce3adf6d 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Çıkış onayı için eski boolean yedeği. Yeni yapılandırmalar için app.confirmQuit kullanın."
},
"shortcuts": {
- "shortcutFirstStroke": "Kısayolun ilk veya tek tuşu. Tuş Space olmadığı sürece çoğu eylem bir değiştirici gerektirir. Markdown önizleme eylemleri h, j, k, l, / ve n gibi tek başına ilk tuşları da kabul eder."
+ "shortcutFirstStroke": "Kısayolun ilk veya tek tuşu. Tuş Space olmadığı sürece bir değiştirici içermelidir.",
+ "markdownShortcutFirstStroke": "Markdown önizleme kısayolunun ilk veya tek tuşu. Markdown önizleme eylemleri h, j, k, l, / ve n gibi tek başına ilk tuşları kabul eder, çünkü odaktaki Markdown paneline yereldir."
}
},
"bareMarkdownPreviewStrokes": "Markdown önizleme eylemleri j ve / gibi tek başına ilk tuşları kabul eder, çünkü yalnızca odaktaki Markdown panelinde etkindir."
diff --git a/web/messages/uk.json b/web/messages/uk.json
index b7d50f43ae4..7a5bdca7b63 100644
--- a/web/messages/uk.json
+++ b/web/messages/uk.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "Застарілий булевий запасний варіант для підтвердження виходу. Для нових конфігурацій використовуйте app.confirmQuit."
},
"shortcuts": {
- "shortcutFirstStroke": "Перше або єдине натискання скорочення. Більшість дій потребує модифікатор, якщо клавіша не Space. Дії перегляду Markdown також приймають окремі перші натискання, наприклад h, j, k, l, / і n."
+ "shortcutFirstStroke": "Перше або єдине натискання скорочення. Має містити модифікатор, якщо клавіша не Space.",
+ "markdownShortcutFirstStroke": "Перше або єдине натискання скорочення перегляду Markdown. Дії перегляду Markdown приймають окремі перші натискання, наприклад h, j, k, l, / і n, бо вони локальні для сфокусованої панелі Markdown."
}
},
"bareMarkdownPreviewStrokes": "Дії перегляду Markdown приймають окремі перші натискання, наприклад j і /, бо вони активні лише у сфокусованій панелі Markdown."
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index 2c896cc4d8d..1aed3fc2db8 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "用于退出确认的旧布尔回退值。新配置请使用 app.confirmQuit。"
},
"shortcuts": {
- "shortcutFirstStroke": "第一个或唯一的快捷键按键。除 Space 键外,大多数操作都需要修饰键。Markdown 预览操作还接受 h、j、k、l、/、n 等单独的第一按键。"
+ "shortcutFirstStroke": "第一个或唯一的快捷键按键。除 Space 键外,必须包含修饰键。",
+ "markdownShortcutFirstStroke": "Markdown 预览快捷键的第一个或唯一按键。Markdown 预览操作只在当前聚焦的 Markdown 面板中生效,因此接受 h、j、k、l、/、n 等单独的第一按键。"
}
},
"bareMarkdownPreviewStrokes": "Markdown 预览操作接受 j 和 / 等单独的第一按键,因为它们只在当前聚焦的 Markdown 面板中生效。"
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index 2acb1c36736..3d09e05eb23 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -496,7 +496,8 @@
"warnBeforeQuit": "用於退出確認的舊版布林後備值。新設定請使用 app.confirmQuit。"
},
"shortcuts": {
- "shortcutFirstStroke": "第一個或唯一的快捷鍵按鍵。除了 Space 鍵之外,大多數操作都需要修飾鍵。Markdown 預覽操作也接受 h、j、k、l、/、n 等單獨的第一按鍵。"
+ "shortcutFirstStroke": "第一個或唯一的快捷鍵按鍵。除了 Space 鍵之外,必須包含修飾鍵。",
+ "markdownShortcutFirstStroke": "Markdown 預覽快捷鍵的第一個或唯一按鍵。Markdown 預覽操作只會在目前聚焦的 Markdown 面板中作用,因此也接受 h、j、k、l、/、n 等單獨的第一按鍵。"
}
},
"bareMarkdownPreviewStrokes": "Markdown 預覽操作可接受 j 和 / 等單獨的第一按鍵,因為它們只會在目前聚焦的 Markdown 面板中作用。"
From 130424678171e8ce17b45e2cd7b268b27a58ae53 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 18:27:43 -0700
Subject: [PATCH 09/14] Preserve shortcut config case compatibility
---
Sources/KeyboardShortcutSettings.swift | 29 ++++++++++++++-----
cmuxTests/KeyboardShortcutSpaceKeyTests.swift | 9 +++++-
2 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index d207c50ef40..9a7f257f7ef 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -2213,7 +2213,10 @@ struct StoredShortcut: Codable, Equatable, Hashable {
}
extension ShortcutStroke {
- static func parseConfig(_ rawValue: String) -> ShortcutStroke? {
+ static func parseConfig(
+ _ rawValue: String,
+ inferShiftFromBareKeyToken: Bool = false
+ ) -> ShortcutStroke? {
guard !rawValue.isEmpty else { return nil }
let rawParts = rawValue.split(separator: "+", omittingEmptySubsequences: false)
@@ -2243,8 +2246,14 @@ extension ShortcutStroke {
}
}
- guard let parsedKey = parseConfigKeyToken(lastRawPart) else { return nil }
- shift = shift || parsedKey.shift
+ let hasExplicitModifier = command || shift || option || control
+ guard let parsedKey = parseConfigKeyToken(
+ lastRawPart,
+ resolveShiftedToken: shift || (!hasExplicitModifier && inferShiftFromBareKeyToken)
+ ) else { return nil }
+ if !hasExplicitModifier && inferShiftFromBareKeyToken {
+ shift = shift || parsedKey.shift
+ }
return ShortcutStroke(
key: parsedKey.key,
command: command,
@@ -2274,7 +2283,10 @@ extension ShortcutStroke {
return key
}
- private static func parseConfigKeyToken(_ rawValue: String) -> (key: String, shift: Bool)? {
+ private static func parseConfigKeyToken(
+ _ rawValue: String,
+ resolveShiftedToken: Bool
+ ) -> (key: String, shift: Bool)? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return rawValue == " " ? ("space", false) : nil
@@ -2335,10 +2347,11 @@ extension ShortcutStroke {
case "previoustrack", "mediaprevious", "media.previous", "media.previoustrack":
return ("media.previous", false)
default:
- if trimmed == "?" {
+ if resolveShiftedToken, trimmed == "?" {
return ("/", true)
}
- if trimmed.count == 1,
+ if resolveShiftedToken,
+ trimmed.count == 1,
let scalar = trimmed.unicodeScalars.first,
CharacterSet.uppercaseLetters.contains(scalar) {
return (lowered, true)
@@ -2367,7 +2380,9 @@ extension StoredShortcut {
if strokes.count == 1, let rawValue = strokes.first, isUnboundConfigToken(rawValue) {
return .unbound
}
- let parsedStrokes = strokes.compactMap(ShortcutStroke.parseConfig(_:))
+ let parsedStrokes = strokes.compactMap {
+ ShortcutStroke.parseConfig($0, inferShiftFromBareKeyToken: allowBareFirstStroke)
+ }
guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else {
return nil
}
diff --git a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
index 4c30c392c52..09b6ac51aa7 100644
--- a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
+++ b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift
@@ -80,7 +80,14 @@ final class KeyboardShortcutSpaceKeyTests: XCTestCase {
}
func testShortcutConfigParsingPreservesImplicitShift() throws {
- XCTAssertEqual(StoredShortcut.parseConfig("cmd+N")?.configIdentifier, "cmd+shift+n")
+ XCTAssertEqual(StoredShortcut.parseConfig("cmd+N")?.configIdentifier, "cmd+n")
+ XCTAssertEqual(StoredShortcut.parseConfig("cmd+B")?.configIdentifier, "cmd+b")
+ XCTAssertEqual(StoredShortcut.parseConfig("cmd+?")?.configIdentifier, "cmd+?")
+ XCTAssertNil(StoredShortcut.parseConfig("N"))
+ XCTAssertEqual(
+ StoredShortcut.parseConfig("N", allowBareFirstStroke: true)?.configIdentifier,
+ "shift+n"
+ )
XCTAssertEqual(
StoredShortcut.parseConfig("?", allowBareFirstStroke: true)?.configIdentifier,
"shift+/"
From 2c728852442ed5ad028232e2c7a05b42a457ed90 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 18:54:59 -0700
Subject: [PATCH 10/14] Restrict markdown shortcuts to main windows
---
Sources/AppDelegate.swift | 21 ++++-
.../AppDelegateShortcutRoutingTests.swift | 81 +++++++++++++++++++
2 files changed, 99 insertions(+), 3 deletions(-)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 467f564ee00..52cf9570e41 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -14482,13 +14482,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func handleFocusedMarkdownPreviewShortcut(_ event: NSEvent) -> Bool {
- let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow
- if let responder = shortcutWindow?.firstResponder,
+ guard let shortcutWindow = markdownPreviewShortcutWindow(for: event) else {
+ return false
+ }
+ if let responder = shortcutWindow.firstResponder,
cmuxOwningGhosttyView(for: responder) != nil {
return false
}
guard let context = preferredRegisteredMainWindowContext(preferredWindow: shortcutWindow),
- context.keyboardFocusCoordinator.findShortcutTarget(currentResponder: shortcutWindow?.firstResponder) == .mainPanelFind,
+ context.keyboardFocusCoordinator.findShortcutTarget(currentResponder: shortcutWindow.firstResponder) == .mainPanelFind,
let workspace = context.tabManager.selectedWorkspace,
let focusedPanelId = workspace.focusedPanelId,
let markdownPanel = workspace.markdownPanel(for: focusedPanelId) else {
@@ -14497,6 +14499,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return markdownPanel.handlePreviewKeyboardShortcut(event)
}
+ private func markdownPreviewShortcutWindow(for event: NSEvent) -> NSWindow? {
+ if let eventWindow = resolvedShortcutEventWindow(event) {
+ return isMainTerminalWindow(eventWindow) ? eventWindow : nil
+ }
+ if let keyWindow = NSApp.keyWindow {
+ return isMainTerminalWindow(keyWindow) ? keyWindow : nil
+ }
+ if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) {
+ return mainWindow
+ }
+ return nil
+ }
+
private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? {
var candidateManagers: [TabManager] = []
var seenManagers = Set()
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index ba3ebb14cd1..06a7b5ebb2f 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -5278,6 +5278,87 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(observedCommands.last, .findPrevious)
}
+ func testFocusedMarkdownPreviewDoesNotOwnAuxiliaryWindowTyping() throws {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ let windowId = appDelegate.createMainWindow()
+ defer { closeWindow(withId: windowId) }
+
+ guard let manager = appDelegate.tabManagerFor(windowId: windowId),
+ let workspace = manager.selectedWorkspace,
+ let sourcePanelId = workspace.focusedPanelId else {
+ XCTFail("Expected test manager, workspace, and focused source panel")
+ return
+ }
+
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-auxiliary-\(UUID().uuidString).md")
+ try "# Markdown\n\nFind target\n".write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let markdownPanel = try XCTUnwrap(
+ workspace.newMarkdownSplit(
+ from: sourcePanelId,
+ orientation: .horizontal,
+ filePath: fileURL.path,
+ focus: true
+ )
+ )
+ workspace.focusPanel(markdownPanel.id)
+
+ let auxiliaryWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+ auxiliaryWindow.isReleasedWhenClosed = false
+ auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
+
+ let auxiliaryResponder = FocusableTestView(frame: NSRect(x: 0, y: 0, width: 100, height: 24))
+ let contentView = NSView(frame: auxiliaryWindow.contentRect(forFrameRect: auxiliaryWindow.frame))
+ contentView.addSubview(auxiliaryResponder)
+ auxiliaryWindow.contentView = contentView
+ auxiliaryWindow.makeKeyAndOrderFront(nil)
+ XCTAssertTrue(auxiliaryWindow.makeFirstResponder(auxiliaryResponder))
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ defer {
+ if auxiliaryWindow.isVisible {
+ auxiliaryWindow.performClose(nil)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+ }
+ }
+
+ var observedCommands: [MarkdownPreviewKeyCommand] = []
+#if DEBUG
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = { command in
+ observedCommands.append(command)
+ }
+#else
+ XCTFail("keyboardCommandObserver is only available in DEBUG")
+#endif
+
+ let event = try XCTUnwrap(
+ makeKeyDownEvent(
+ key: "j",
+ modifiers: [],
+ keyCode: 38,
+ windowNumber: auxiliaryWindow.windowNumber
+ )
+ )
+#if DEBUG
+ XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
+#else
+ XCTFail("debugHandleCustomShortcut is only available in DEBUG")
+#endif
+ XCTAssertTrue(observedCommands.isEmpty, "Markdown preview shortcuts should not consume auxiliary-window typing")
+ XCTAssertTrue(auxiliaryWindow.firstResponder === auxiliaryResponder)
+ }
+
// MARK: - Browser find shortcut routing tests
func testBrowserFirstFindShortcutRoutingRecognizesBrowserLocalFindCommandFamily() {
From 08d93287cd17c1bd5e11995267e4e1ec57a055be Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 19:29:18 -0700
Subject: [PATCH 11/14] Avoid markdown shortcuts in text inputs
---
Sources/AppDelegate.swift | 26 +++++++-
.../AppDelegateShortcutRoutingTests.swift | 64 +++++++++++++++++++
2 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 52cf9570e41..36d1fee8bb7 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -14485,8 +14485,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard let shortcutWindow = markdownPreviewShortcutWindow(for: event) else {
return false
}
- if let responder = shortcutWindow.firstResponder,
- cmuxOwningGhosttyView(for: responder) != nil {
+ if shouldBypassFocusedMarkdownPreviewShortcut(
+ responder: shortcutWindow.firstResponder,
+ in: shortcutWindow
+ ) {
return false
}
guard let context = preferredRegisteredMainWindowContext(preferredWindow: shortcutWindow),
@@ -14499,6 +14501,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return markdownPanel.handlePreviewKeyboardShortcut(event)
}
+ private func shouldBypassFocusedMarkdownPreviewShortcut(
+ responder: NSResponder?,
+ in window: NSWindow
+ ) -> Bool {
+ if isCommandPaletteEffectivelyVisible(in: window) {
+ return true
+ }
+ guard let responder else { return false }
+ if cmuxOwningGhosttyView(for: responder) != nil {
+ return true
+ }
+ if responder is NSText || responder is NSTextField {
+ return true
+ }
+ if isRightSidebarFocusResponder(responder, in: window) {
+ return true
+ }
+ return false
+ }
+
private func markdownPreviewShortcutWindow(for event: NSEvent) -> NSWindow? {
if let eventWindow = resolvedShortcutEventWindow(event) {
return isMainTerminalWindow(eventWindow) ? eventWindow : nil
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index 06a7b5ebb2f..ebde410d186 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -5359,6 +5359,70 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(auxiliaryWindow.firstResponder === auxiliaryResponder)
}
+ func testFocusedMarkdownPreviewDoesNotOwnMainWindowTextInput() throws {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ let windowId = appDelegate.createMainWindow()
+ defer { closeWindow(withId: windowId) }
+
+ guard let window = window(withId: windowId),
+ let manager = appDelegate.tabManagerFor(windowId: windowId),
+ let workspace = manager.selectedWorkspace,
+ let sourcePanelId = workspace.focusedPanelId else {
+ XCTFail("Expected test window, manager, workspace, and focused source panel")
+ return
+ }
+
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-main-text-input-\(UUID().uuidString).md")
+ try "# Markdown\n\nFind target\n".write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let markdownPanel = try XCTUnwrap(
+ workspace.newMarkdownSplit(
+ from: sourcePanelId,
+ orientation: .horizontal,
+ filePath: fileURL.path,
+ focus: true
+ )
+ )
+ workspace.focusPanel(markdownPanel.id)
+
+ let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 180, height: 32))
+ textView.isEditable = true
+ window.contentView?.addSubview(textView)
+ defer { textView.removeFromSuperview() }
+ XCTAssertTrue(window.makeFirstResponder(textView))
+
+ var observedCommands: [MarkdownPreviewKeyCommand] = []
+#if DEBUG
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = { command in
+ observedCommands.append(command)
+ }
+#else
+ XCTFail("keyboardCommandObserver is only available in DEBUG")
+#endif
+
+ let event = try XCTUnwrap(
+ makeKeyDownEvent(
+ key: "j",
+ modifiers: [],
+ keyCode: 38,
+ windowNumber: window.windowNumber
+ )
+ )
+#if DEBUG
+ XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
+#else
+ XCTFail("debugHandleCustomShortcut is only available in DEBUG")
+#endif
+ XCTAssertTrue(observedCommands.isEmpty, "Markdown preview shortcuts should not consume main-window text input")
+ XCTAssertTrue(window.firstResponder === textView)
+ }
+
// MARK: - Browser find shortcut routing tests
func testBrowserFirstFindShortcutRoutingRecognizesBrowserLocalFindCommandFamily() {
From 58c4ce026b0cfdde9dbcae257762197e465648c0 Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 19:53:36 -0700
Subject: [PATCH 12/14] Handle markdown shortcut conflict edges
---
Sources/AppDelegate.swift | 3 +
Sources/KeyboardShortcutSettings.swift | 20 +++++
.../AppDelegateShortcutRoutingTests.swift | 76 +++++++++++++++++++
cmuxTests/KeyboardShortcutContextTests.swift | 59 ++++++++++++++
4 files changed, 158 insertions(+)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 36d1fee8bb7..868dbc920c1 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -14482,6 +14482,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func handleFocusedMarkdownPreviewShortcut(_ event: NSEvent) -> Bool {
+ guard activeConfiguredShortcutChordPrefixForCurrentEvent == nil else {
+ return false
+ }
guard let shortcutWindow = markdownPreviewShortcutWindow(for: event) else {
return false
}
diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift
index 9a7f257f7ef..dec821cd3b9 100644
--- a/Sources/KeyboardShortcutSettings.swift
+++ b/Sources/KeyboardShortcutSettings.swift
@@ -504,6 +504,9 @@ enum KeyboardShortcutSettings {
proposedAction: Action,
configuredShortcut: StoredShortcut
) -> Bool {
+ if Self.allowsSharedMarkdownCommandPaletteNavigationShortcut(self, proposedAction) {
+ return false
+ }
guard shortcutContext.overlaps(proposedAction.shortcutContext) else {
return false
}
@@ -515,6 +518,23 @@ enum KeyboardShortcutSettings {
)
}
+ private static func allowsSharedMarkdownCommandPaletteNavigationShortcut(
+ _ lhs: Action,
+ _ rhs: Action
+ ) -> Bool {
+ let pair = Set([lhs, rhs])
+ let markdownFindAliases: Set = [
+ .markdownFindNextAlternate,
+ .markdownFindPreviousAlternate,
+ ]
+ let commandPaletteNavigation: Set = [
+ .commandPaletteNext,
+ .commandPalettePrevious,
+ ]
+ return !pair.isDisjoint(with: markdownFindAliases) &&
+ !pair.isDisjoint(with: commandPaletteNavigation)
+ }
+
func normalizedRecordedShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution {
if shortcut.isUnbound {
return .accepted(.unbound)
diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift
index ebde410d186..7f8a3cea40d 100644
--- a/cmuxTests/AppDelegateShortcutRoutingTests.swift
+++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift
@@ -5423,6 +5423,82 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(window.firstResponder === textView)
}
+ func testFocusedMarkdownPreviewDoesNotConsumePendingAppShortcutChord() throws {
+ guard let appDelegate = AppDelegate.shared else {
+ XCTFail("Expected AppDelegate.shared")
+ return
+ }
+
+ KeyboardShortcutSettings.setShortcut(
+ StoredShortcut(
+ key: "b",
+ command: false,
+ shift: false,
+ option: false,
+ control: true,
+ chordKey: "n"
+ ),
+ for: .newTab
+ )
+
+ let windowId = appDelegate.createMainWindow()
+ defer { closeWindow(withId: windowId) }
+
+ guard let window = window(withId: windowId),
+ let manager = appDelegate.tabManagerFor(windowId: windowId),
+ let workspace = manager.selectedWorkspace,
+ let sourcePanelId = workspace.focusedPanelId else {
+ XCTFail("Expected test window, manager, workspace, and focused source panel")
+ return
+ }
+
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-markdown-pending-app-chord-\(UUID().uuidString).md")
+ try "# Markdown\n\nFind target\n".write(to: fileURL, atomically: true, encoding: .utf8)
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let markdownPanel = try XCTUnwrap(
+ workspace.newMarkdownSplit(
+ from: sourcePanelId,
+ orientation: .horizontal,
+ filePath: fileURL.path,
+ focus: true
+ )
+ )
+ workspace.focusPanel(markdownPanel.id)
+
+ let responder = FocusableTestView(frame: .zero)
+ window.contentView?.addSubview(responder)
+ defer { responder.removeFromSuperview() }
+ XCTAssertTrue(window.makeFirstResponder(responder))
+
+ var observedCommands: [MarkdownPreviewKeyCommand] = []
+#if DEBUG
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = { command in
+ observedCommands.append(command)
+ }
+#else
+ XCTFail("keyboardCommandObserver is only available in DEBUG")
+#endif
+
+ let workspaceCountBefore = manager.tabs.count
+ let prefixEvent = try XCTUnwrap(
+ makeKeyDownEvent(key: "b", modifiers: [.control], keyCode: 11, windowNumber: window.windowNumber)
+ )
+ let secondEvent = try XCTUnwrap(
+ makeKeyDownEvent(key: "n", modifiers: [], keyCode: 45, windowNumber: window.windowNumber)
+ )
+
+#if DEBUG
+ XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent))
+ XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: secondEvent))
+#else
+ XCTFail("debugHandleCustomShortcut is only available in DEBUG")
+#endif
+ XCTAssertEqual(manager.tabs.count, workspaceCountBefore + 1)
+ XCTAssertTrue(observedCommands.isEmpty, "Pending app chords should resolve before Markdown preview shortcuts")
+ }
+
// MARK: - Browser find shortcut routing tests
func testBrowserFirstFindShortcutRoutingRecognizesBrowserLocalFindCommandFamily() {
diff --git a/cmuxTests/KeyboardShortcutContextTests.swift b/cmuxTests/KeyboardShortcutContextTests.swift
index 6c3d29e2deb..cf902130c08 100644
--- a/cmuxTests/KeyboardShortcutContextTests.swift
+++ b/cmuxTests/KeyboardShortcutContextTests.swift
@@ -141,6 +141,65 @@ final class KeyboardShortcutContextTests: XCTestCase {
XCTAssertEqual(KeyboardShortcutSettings.Action.toggleReactGrab.shortcutContext, .application)
}
+ func testMarkdownControlFindAliasesDoNotConflictWithCommandPaletteNavigation() throws {
+ let originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore
+ let directoryURL = try makeTemporaryDirectory()
+ defer {
+ KeyboardShortcutSettings.resetAll()
+ KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore
+ try? FileManager.default.removeItem(at: directoryURL)
+ }
+
+ let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false)
+ try writeSettingsFile("{}", to: settingsFileURL)
+ KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore(
+ primaryPath: settingsFileURL.path,
+ fallbackPath: nil,
+ startWatching: false
+ )
+ KeyboardShortcutSettings.resetAll()
+
+ let controlN = KeyboardShortcutSettings.Action.markdownFindNextAlternate.defaultShortcut
+ let controlP = KeyboardShortcutSettings.Action.markdownFindPreviousAlternate.defaultShortcut
+
+ XCTAssertEqual(controlN, KeyboardShortcutSettings.Action.commandPaletteNext.defaultShortcut)
+ XCTAssertEqual(controlP, KeyboardShortcutSettings.Action.commandPalettePrevious.defaultShortcut)
+ XCTAssertEqual(
+ KeyboardShortcutSettings.Action.markdownFindNextAlternate.normalizedRecordedShortcutResult(controlN),
+ .accepted(controlN)
+ )
+ XCTAssertEqual(
+ KeyboardShortcutSettings.Action.markdownFindPreviousAlternate.normalizedRecordedShortcutResult(controlP),
+ .accepted(controlP)
+ )
+ XCTAssertFalse(
+ KeyboardShortcutSettings.Action.commandPaletteNext.conflicts(
+ with: controlN,
+ proposedAction: .markdownFindNextAlternate,
+ configuredShortcut: KeyboardShortcutSettings.Action.commandPaletteNext.defaultShortcut
+ )
+ )
+ XCTAssertFalse(
+ KeyboardShortcutSettings.Action.commandPalettePrevious.conflicts(
+ with: controlP,
+ proposedAction: .markdownFindPreviousAlternate,
+ configuredShortcut: KeyboardShortcutSettings.Action.commandPalettePrevious.defaultShortcut
+ )
+ )
+ }
+
+ func testMarkdownShortcutsStillConflictWithNormalApplicationShortcuts() {
+ let commandN = KeyboardShortcutSettings.Action.newTab.defaultShortcut
+
+ XCTAssertTrue(
+ KeyboardShortcutSettings.Action.newTab.conflicts(
+ with: commandN,
+ proposedAction: .markdownFindNextAlternate,
+ configuredShortcut: commandN
+ )
+ )
+ }
+
func testShortcutSettingsFilePreservesConfiguredShortcutWithoutGlobalConflictLookup() throws {
let directoryURL = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directoryURL) }
From 341d2b06e169f08115de4c95f11e66541a3eb0cb Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 20:35:00 -0700
Subject: [PATCH 13/14] Prevent markdown shortcut context collisions
---
Sources/KeyboardShortcutContext.swift | 4 ++++
cmuxTests/KeyboardShortcutContextTests.swift | 21 ++++++++++++++++++++
2 files changed, 25 insertions(+)
diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift
index 0d7a47ad0d0..7eb9b9701ec 100644
--- a/Sources/KeyboardShortcutContext.swift
+++ b/Sources/KeyboardShortcutContext.swift
@@ -46,6 +46,10 @@ extension KeyboardShortcutSettings.Action {
if self == .application || other == .application {
return true
}
+ if (self == .markdownPanel && other == .nonBrowserPanel) ||
+ (self == .nonBrowserPanel && other == .markdownPanel) {
+ return true
+ }
return self == other
}
}
diff --git a/cmuxTests/KeyboardShortcutContextTests.swift b/cmuxTests/KeyboardShortcutContextTests.swift
index cf902130c08..a2cc7da8c1d 100644
--- a/cmuxTests/KeyboardShortcutContextTests.swift
+++ b/cmuxTests/KeyboardShortcutContextTests.swift
@@ -141,6 +141,27 @@ final class KeyboardShortcutContextTests: XCTestCase {
XCTAssertEqual(KeyboardShortcutSettings.Action.toggleReactGrab.shortcutContext, .application)
}
+ func testMarkdownShortcutsConflictWithNonBrowserShortcuts() {
+ let commandR = KeyboardShortcutSettings.Action.renameTab.defaultShortcut
+
+ XCTAssertEqual(KeyboardShortcutSettings.Action.markdownFindNext.shortcutContext, .markdownPanel)
+ XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.shortcutContext, .nonBrowserPanel)
+ XCTAssertTrue(
+ KeyboardShortcutSettings.Action.renameTab.conflicts(
+ with: commandR,
+ proposedAction: .markdownFindNext,
+ configuredShortcut: commandR
+ )
+ )
+ XCTAssertTrue(
+ KeyboardShortcutSettings.Action.markdownFindNext.conflicts(
+ with: commandR,
+ proposedAction: .renameTab,
+ configuredShortcut: commandR
+ )
+ )
+ }
+
func testMarkdownControlFindAliasesDoNotConflictWithCommandPaletteNavigation() throws {
let originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore
let directoryURL = try makeTemporaryDirectory()
From e4c3439af4177670983efd5c4a49165bf8886c5e Mon Sep 17 00:00:00 2001
From: Lawrence Chen
Date: Fri, 22 May 2026 22:02:45 -0700
Subject: [PATCH 14/14] Tighten markdown preview shortcut dispatch
---
Resources/markdown-viewer/shell.html | 3 +-
Sources/Panels/MarkdownPanel.swift | 3 +-
Sources/Panels/MarkdownWebRenderer.swift | 32 +++++++++++++----
Sources/Panels/MarkdownWebSupport.swift | 5 +--
cmuxTests/MarkdownPanelTests.swift | 44 ++++++++++++++++++++++++
5 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/Resources/markdown-viewer/shell.html b/Resources/markdown-viewer/shell.html
index 7bda37267ff..f03d8885f25 100644
--- a/Resources/markdown-viewer/shell.html
+++ b/Resources/markdown-viewer/shell.html
@@ -1461,7 +1461,8 @@
var fallback = backwards ? 'Search Markdown backward' : 'Search Markdown';
var query = window.prompt(cmuxString(promptKey, fallback), markdownPreviewSearchQuery);
if (query == null) { return false; }
- markdownPreviewSearchQuery = String(query);
+ query = String(query);
+ if (query) { markdownPreviewSearchQuery = query; }
return markdownPreviewSearch(markdownPreviewSearchQuery, backwards);
}
diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift
index e6d6a12598f..34324341910 100644
--- a/Sources/Panels/MarkdownPanel.swift
+++ b/Sources/Panels/MarkdownPanel.swift
@@ -62,8 +62,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
@discardableResult
func performPreviewKeyboardCommand(_ command: MarkdownPreviewKeyCommand) -> Bool {
guard displayMode == .preview else { return false }
- rendererSession.performKeyboardCommand(command)
- return true
+ return rendererSession.performKeyboardCommand(command)
}
// MARK: - File watching
diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift
index c294a56e534..d0514203b1a 100644
--- a/Sources/Panels/MarkdownWebRenderer.swift
+++ b/Sources/Panels/MarkdownWebRenderer.swift
@@ -291,6 +291,11 @@ struct MarkdownWebRenderer: NSViewRepresentable {
}
func handleKeyboardShortcut(_ event: NSEvent) -> Bool {
+ guard canPerformKeyboardCommand else {
+ pendingShortcutChord = nil
+ return false
+ }
+
if let pendingShortcutChord {
self.pendingShortcutChord = nil
guard let command = MarkdownPreviewKeyboardShortcutResolver.command(
@@ -299,13 +304,11 @@ struct MarkdownWebRenderer: NSViewRepresentable {
) else {
return true
}
- performKeyboardCommand(command)
- return true
+ return performKeyboardCommand(command)
}
if let command = MarkdownPreviewKeyboardShortcutResolver.command(for: event) {
- performKeyboardCommand(command)
- return true
+ return performKeyboardCommand(command)
}
if let prefix = MarkdownPreviewKeyboardShortcutResolver.chordPrefix(for: event) {
@@ -316,17 +319,34 @@ struct MarkdownWebRenderer: NSViewRepresentable {
return false
}
- func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+ private var canPerformKeyboardCommand: Bool {
+#if DEBUG
+ if Self.keyboardCommandObserver != nil {
+ return true
+ }
+#endif
+ return webView != nil && isLoaded
+ }
+
+ @discardableResult
+ func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) -> Bool {
#if DEBUG
Self.keyboardCommandObserver?(command)
#endif
- guard let webView else { return }
+ guard let webView, isLoaded else {
+#if DEBUG
+ return Self.keyboardCommandObserver != nil
+#else
+ return false
+#endif
+ }
let commandLiteral = (try? JSONSerialization.data(withJSONObject: [command.rawValue]))
.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
webView.evaluateJavaScript(
"window.__cmuxMarkdownPreviewHandleKeyCommand && window.__cmuxMarkdownPreviewHandleKeyCommand(\(commandLiteral)[0]);",
completionHandler: nil
)
+ return true
}
// MARK: Bridge
diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift
index fe9b2404f5a..192d0831301 100644
--- a/Sources/Panels/MarkdownWebSupport.swift
+++ b/Sources/Panels/MarkdownWebSupport.swift
@@ -51,7 +51,7 @@ final class MarkdownWebView: WKWebView {
onKeyboardShortcut?(event) == true {
return
}
- super.doCommand(by: selector)
+ nextResponder?.doCommand(by: selector)
}
private static func isControlNavigationCommand(
@@ -135,7 +135,8 @@ final class MarkdownRendererSession {
ownedCoordinator.handleKeyboardShortcut(event)
}
- func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) {
+ @discardableResult
+ func performKeyboardCommand(_ command: MarkdownPreviewKeyCommand) -> Bool {
ownedCoordinator.performKeyboardCommand(command)
}
diff --git a/cmuxTests/MarkdownPanelTests.swift b/cmuxTests/MarkdownPanelTests.swift
index 8a652feee73..04bb39ec253 100644
--- a/cmuxTests/MarkdownPanelTests.swift
+++ b/cmuxTests/MarkdownPanelTests.swift
@@ -491,6 +491,11 @@ final class MarkdownPanelTests: XCTestCase {
}
let coordinator = MarkdownWebRenderer.Coordinator()
+#if DEBUG
+ let originalObserver = MarkdownWebRenderer.Coordinator.keyboardCommandObserver
+ MarkdownWebRenderer.Coordinator.keyboardCommandObserver = { _ in }
+ defer { MarkdownWebRenderer.Coordinator.keyboardCommandObserver = originalObserver }
+#endif
XCTAssertTrue(
coordinator.handleKeyboardShortcut(try keyDownEvent("b", keyCode: 11, modifiers: [.control]))
)
@@ -498,6 +503,13 @@ final class MarkdownPanelTests: XCTestCase {
XCTAssertTrue(coordinator.handleKeyboardShortcut(try keyDownEvent("j", keyCode: 38)))
}
+ func testMarkdownPreviewShortcutsFallThroughBeforeShellLoads() throws {
+ let coordinator = MarkdownWebRenderer.Coordinator()
+
+ XCTAssertFalse(coordinator.handleKeyboardShortcut(try keyDownEvent("j", keyCode: 38)))
+ XCTAssertFalse(coordinator.performKeyboardCommand(.scrollDown))
+ }
+
func testMarkdownPreviewKeyCommandHandlersScrollAndSearch() async throws {
let frame = NSRect(x: 0, y: 0, width: 720, height: 360)
let webView = WKWebView(frame: frame, configuration: WKWebViewConfiguration())
@@ -637,6 +649,38 @@ final class MarkdownPanelTests: XCTestCase {
XCTAssertEqual(searchSnapshot["lastBackwards"] ?? -1, 1)
XCTAssertEqual(searchSnapshot["firstWrap"] ?? -1, 1)
+ let emptyPromptSnapshot = try await evaluateScrollSnapshot(
+ """
+ (function() {
+ var calls = [];
+ Object.defineProperty(window, 'find', {
+ configurable: true,
+ value: function(query, caseSensitive, backwards, wrap, wholeWord, searchInFrames, showDialog) {
+ calls.push(String(query));
+ return true;
+ }
+ });
+ window.__cmuxMarkdownPreviewSearch('Section 20', false);
+ window.prompt = function() { return ''; };
+ var emptyPromptFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findForward') ? 1 : 0;
+ var nextFound = window.__cmuxMarkdownPreviewHandleKeyCommand('findNext') ? 1 : 0;
+ return {
+ calls: calls.length,
+ emptyPromptFound: emptyPromptFound,
+ nextFound: nextFound,
+ emptyPromptQueryPreserved: calls[1] === 'Section 20' ? 1 : 0,
+ nextQueryPreserved: calls[2] === 'Section 20' ? 1 : 0
+ };
+ })();
+ """,
+ in: webView
+ )
+ XCTAssertEqual(emptyPromptSnapshot["calls"] ?? 0, 3)
+ XCTAssertEqual(emptyPromptSnapshot["emptyPromptFound"] ?? 0, 1)
+ XCTAssertEqual(emptyPromptSnapshot["nextFound"] ?? 0, 1)
+ XCTAssertEqual(emptyPromptSnapshot["emptyPromptQueryPreserved"] ?? 0, 1)
+ XCTAssertEqual(emptyPromptSnapshot["nextQueryPreserved"] ?? 0, 1)
+
let domShortcutSnapshot = try await evaluateScrollSnapshot(
"""
(function() {