Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ 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 |
| / / ⌘ F | Search forward |
| ⇧ / | Search backward |
| N / ⌃ N / ⌘ G | Find next |
| ⇧ N / ⌃ P / ⌥ ⌘ G | Find previous |

### Notifications

| Shortcut | Action |
Expand Down
360 changes: 360 additions & 0 deletions Resources/Localizable.xcstrings

Large diffs are not rendered by default.

100 changes: 97 additions & 3 deletions Resources/markdown-viewer/shell.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1417,6 +1419,98 @@
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);
try {
window.scrollTo({ left: nextLeft, top: nextTop, behavior: 'smooth' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Per-keystroke scrolling uses smooth animation, which can drop/flatten repeated scroll steps when keys are pressed quickly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Resources/markdown-viewer/shell.html, line 1433:

<comment>Per-keystroke scrolling uses smooth animation, which can drop/flatten repeated scroll steps when keys are pressed quickly.</comment>

<file context>
@@ -1429,16 +1429,11 @@
-    var previousBehavior = root.style.scrollBehavior;
-    root.style.scrollBehavior = 'auto';
     try {
+      window.scrollTo({ left: nextLeft, top: nextTop, behavior: 'smooth' });
+    } catch (e) {
       window.scrollTo(nextLeft, nextTop);
</file context>
Suggested change
window.scrollTo({ left: nextLeft, top: nextTop, behavior: 'smooth' });
window.scrollTo({ left: nextLeft, top: nextTop, behavior: 'auto' });

} catch (e) {
window.scrollTo(nextLeft, 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));
try {
window.scrollTo({
left: window.scrollX || markdownPreviewScroller().scrollLeft || 0,
top: clampScrollY(targetY),
behavior: 'smooth'
});
} catch (e) {
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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (query == null) { return false; }
query = String(query);
if (query) { markdownPreviewSearchQuery = query; }
return markdownPreviewSearch(markdownPreviewSearchQuery, backwards);
Comment on lines +1462 to +1466

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Search query lost on empty-string submission — markdownPreviewSearchQuery is overwritten with the empty string before markdownPreviewSearch validates it. If the user opens the prompt (pressing / or ?) and then submits without typing anything, the module-level variable is set to "" and the previous query is gone. Any subsequent n / N keystroke will call markdownPreviewSearch("", …), hit the if (!value) guard immediately, and do nothing until a fresh search is initiated. The fix is to only commit the new query when it is non-empty.

Suggested change
var query = window.prompt(cmuxString(promptKey, fallback), markdownPreviewSearchQuery);
if (query == null) { return false; }
markdownPreviewSearchQuery = String(query);
return markdownPreviewSearch(markdownPreviewSearchQuery, backwards);
var query = window.prompt(cmuxString(promptKey, fallback), markdownPreviewSearchQuery);
if (query == null) { return false; }
var trimmedQuery = String(query);
if (trimmedQuery) { markdownPreviewSearchQuery = trimmedQuery; }
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);
Expand Down
61 changes: 60 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11261,7 +11261,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 }
Comment thread
cursor[bot] marked this conversation as resolved.
return KeyboardShortcutSettings.shortcut(for: action).hasChord
}
}
Expand Down Expand Up @@ -11883,6 +11885,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)
Expand Down Expand Up @@ -14478,6 +14481,62 @@ 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
}
if shouldBypassFocusedMarkdownPreviewShortcut(
responder: shortcutWindow.firstResponder,
in: shortcutWindow
) {
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 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
}
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<ObjectIdentifier>()
Expand Down
20 changes: 20 additions & 0 deletions Sources/KeyboardShortcutContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension KeyboardShortcutSettings.Action {
case application
case nonBrowserPanel
case browserPanel
case markdownPanel
case rightSidebarFocus

var isAlwaysAvailable: Bool {
Expand All @@ -30,6 +31,8 @@ extension KeyboardShortcutSettings.Action {
return !focusedBrowserPanel && !rightSidebarFocused
case .browserPanel:
return focusedBrowserPanel
case .markdownPanel:
return false
case .rightSidebarFocus:
return rightSidebarFocused
}
Expand All @@ -43,12 +46,29 @@ extension KeyboardShortcutSettings.Action {
if self == .application || other == .application {
return true
}
if (self == .markdownPanel && other == .nonBrowserPanel) ||
(self == .nonBrowserPanel && other == .markdownPanel) {
return true
}
return self == other
}
}

var shortcutContext: ShortcutContext {
switch self {
case .markdownScrollLeft,
.markdownScrollDown,
.markdownScrollUp,
.markdownScrollRight,
.markdownPageUp,
.markdownPageDown,
.markdownFindForward,
.markdownFindBackward,
.markdownFindNext,
.markdownFindPrevious,
.markdownFindNextAlternate,
.markdownFindPreviousAlternate:
return .markdownPanel
case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock:
return .rightSidebarFocus
case .renameTab, .renameWorkspace:
Expand Down
8 changes: 7 additions & 1 deletion Sources/KeyboardShortcutRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -41,6 +42,7 @@ struct KeyboardShortcutRecorder: View {
isRecording: $isRecording,
hasPendingRejection: hasPendingRejection,
displayString: displayString,
requireFirstStrokeModifier: requireFirstStrokeModifier,
transformRecordedShortcut: transformRecordedShortcut,
onRecordingChanged: onRecordingChanged,
onRecorderFeedbackChanged: onRecorderFeedbackChanged
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -179,6 +184,7 @@ final class ShortcutRecorderNSButton: NSButton {
}
}
var displayString: (StoredShortcut) -> String = { $0.displayString }
var requireFirstStrokeModifier = true
var transformRecordedShortcut: (StoredShortcut) -> KeyboardShortcutSettings.RecordedShortcutResolution = {
.accepted($0)
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading