Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ extension ShortcutAction {
case .focusTextBoxInput: return ShortcutStroke(key: "a", command: true, shift: true)
case .attachTextBoxFile: return ShortcutStroke(key: "a", command: true, shift: true, option: true)
case .toggleRightSidebar: return ShortcutStroke(key: "b", command: true, option: true)
case .openDiffViewer: return ShortcutStroke(key: "d", command: true, shift: true, control: true)
case .saveFilePreview: return ShortcutStroke(key: "s", command: true)
case .openBrowser: return ShortcutStroke(key: "l", command: true, shift: true)
case .focusBrowserAddressBar: return ShortcutStroke(key: "l", command: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
case toggleRightSidebar = "toggleFileExplorer"

// MARK: Browser & Find
case openDiffViewer
case saveFilePreview
case openBrowser
case focusBrowserAddressBar
Expand Down Expand Up @@ -145,7 +146,7 @@ extension ShortcutAction {
.toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown,
.toggleRightSidebar:
return .panes
case .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack,
case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack,
.browserForward, .browserReload, .browserZoomIn, .browserZoomOut,
.browserZoomReset, .markdownZoomIn, .markdownZoomOut, .markdownZoomReset,
.find, .findInDirectory, .findNext, .findPrevious,
Expand Down Expand Up @@ -216,6 +217,7 @@ extension ShortcutAction {
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .toggleRightSidebar: return "Toggle Right Sidebar"
case .openDiffViewer: return "Open Diff Viewer"
case .saveFilePreview: return "Save File Preview"
case .openBrowser: return "Open Browser"
case .focusBrowserAddressBar: return "Focus Address Bar"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ extension Array where Element == CuratedSettingEntry {
.init(section: .globalHotkey, id: "shortcut", title: "Show/Hide All Windows", synonyms: "global hotkey shortcut recorder key command option control"),

// Keyboard shortcuts
.init(section: .keyboardShortcuts, id: "shortcuts", title: "Keyboard Shortcuts", synonyms: "shortcuts.bindings hotkeys keybindings key bindings commands keyboard accelerators chords cmux json"),
.init(section: .keyboardShortcuts, id: "shortcuts", title: "Keyboard Shortcuts", synonyms: "shortcuts.bindings hotkeys keybindings key bindings commands keyboard accelerators chords cmux json open diff viewer changes review git unstaged"),
.init(section: .keyboardShortcuts, id: "shortcut-chords", title: "Shortcut Chords", synonyms: "tmux prefix ctrl-b control-b multi key sequence chord cmux json"),
.init(section: .keyboardShortcuts, id: "reset-defaults", title: "Reset Default Shortcuts", synonyms: "reset restore default defaults built in builtin shortcuts hotkeys keybindings commands"),

Expand Down
125 changes: 125 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,131 @@
}
}
},
"shortcut.openDiffViewer.label": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "فتح عارض الاختلافات"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Otvori preglednik razlika"
}
Comment thread
austinywang marked this conversation as resolved.
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Åbn diffvisning"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Diff-Viewer öffnen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Diff Viewer"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Abrir visor de diferencias"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Ouvrir la visionneuse de diff"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Apri visualizzatore diff"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "差分ビューアを開く"
}
},
"km": {
"stringUnit": {
"state": "translated",
"value": "Open Diff Viewer"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "Diff 뷰어 열기"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Åpne diffvisning"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Otwórz podgląd różnic"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Abrir visualizador de diff"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Открыть просмотр diff"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "เปิดตัวดู diff"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Diff Görüntüleyiciyi Aç"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Відкрити переглядач diff"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "打开差异查看器"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "開啟差異檢視器"
}
}
}
},
"command.markdownZoomIn.title": {
"extractionState": "manual",
"localizations": {
Expand Down
126 changes: 126 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
method_exchangeImplementations(originalMethod, swizzledMethod)
}()

/// Live `cmux diff` viewer subprocesses, keyed by pid, retained until they exit.
/// Declared outside `#if DEBUG` because process retention is production behavior.
private var diffViewerProcesses: [Int32: Process] = [:]

#if DEBUG
private var didSetupJumpUnreadUITest = false
private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)?
Expand Down Expand Up @@ -972,6 +976,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let isFirstResponder: Bool
}
var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)?
/// Test seam: when set, ``openDiffViewerForFocusedWorkspace(for:)`` invokes this
/// instead of spawning the bundled `cmux diff` CLI, so shortcut-dispatch tests can
/// assert routing without launching a subprocess.
var debugOpenDiffViewerHandler: (() -> Void)?
var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool?
// Keep debug-only windows alive when tests intentionally inject key mismatches.
private var debugDetachedContextWindows: [NSWindow] = []
Expand Down Expand Up @@ -5918,6 +5926,114 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}?.tabManager
}

/// Opens the diff viewer for the focused workspace of `tabManager` by spawning the
/// bundled `cmux diff` CLI. This is the single shared diff-open path: both the
/// command-palette entry and the Open Diff Viewer keyboard shortcut funnel through
/// here so neither duplicates diff-open logic. Returns `false` (caller beeps) when
/// there is no focused workspace or the bundled CLI is missing.
@discardableResult
func openDiffViewerForFocusedWorkspace(for tabManager: TabManager?) -> Bool {
#if DEBUG
if let debugOpenDiffViewerHandler {
debugOpenDiffViewerHandler()
return true
}
#endif
guard let workspace = tabManager?.selectedWorkspace,
let cliURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"),
FileManager.default.isExecutableFile(atPath: cliURL.path) else {
return false
}
let socketPath = TerminalController.shared.activeSocketPath(
preferredPath: SocketControlSettings.socketPath()
)
let cwd = workspace.resolvedWorkingDirectory()
?? FileManager.default.homeDirectoryForCurrentUser.path
return launchDiffViewerProcess(
cliURL: cliURL,
socketPath: socketPath,
cwd: cwd,
workspaceId: workspace.id,
surfaceId: workspace.focusedPanelId
)
}

@discardableResult
private func launchDiffViewerProcess(
cliURL: URL,
socketPath: String,
cwd: String,
workspaceId: UUID,
surfaceId: UUID?
) -> Bool {
let process = Process()
process.executableURL = cliURL
var arguments = [
"--socket", socketPath,
"diff",
"--unstaged",
"--cwd", cwd,
"--workspace", workspaceId.uuidString,
"--focus", "true",
]
if let surfaceId {
arguments.append(contentsOf: ["--surface", surfaceId.uuidString])
}
process.arguments = arguments
process.currentDirectoryURL = URL(fileURLWithPath: cwd, isDirectory: true)
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_BUNDLED_CLI_PATH"] = cliURL.path
environment["CMUX_WORKSPACE_ID"] = workspaceId.uuidString
if let surfaceId {
environment["CMUX_SURFACE_ID"] = surfaceId.uuidString
}
environment.removeValue(forKey: "CMUX_SOCKET")
process.environment = environment
process.standardInput = FileHandle.nullDevice

let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
let outputCollector = ProcessOutputCollector(stdout: stdoutPipe, stderr: stderrPipe)
outputCollector.start()
process.terminationHandler = { terminatedProcess in
let output = outputCollector.finish()
let processIdentifier = terminatedProcess.processIdentifier
let terminationStatus = terminatedProcess.terminationStatus
Task { @MainActor in
AppDelegate.shared?.diffViewerProcesses.removeValue(forKey: processIdentifier)
guard terminationStatus != 0 else { return }
#if DEBUG
// Log only non-sensitive metadata: the child's stdout/stderr can echo
// repo paths and file contents, so report a byte count, not the text.
cmuxDebugLog("openDiffViewer exited status=\(terminationStatus) outputBytes=\(output.utf8.count)")
#endif
NSSound.beep()
}
}

do {
try process.run()
let processIdentifier = process.processIdentifier
diffViewerProcesses[processIdentifier] = process
if !process.isRunning {
diffViewerProcesses.removeValue(forKey: processIdentifier)
}
#if DEBUG
cmuxDebugLog("openDiffViewer pid=\(process.processIdentifier)")
#endif
return true
} catch {
outputCollector.cancel()
#if DEBUG
cmuxDebugLog("openDiffViewer failed errorType=\(type(of: error))")
#endif
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return false
}
}

func allMainWindowTabManagersForDebug() -> [TabManager] {
Array(mainWindowContexts.values).compactMap { context in
resolvedWindow(for: context) == nil ? nil : context.tabManager
Expand Down Expand Up @@ -12773,6 +12889,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

if matchConfiguredShortcut(event: event, action: .openDiffViewer) {
// Shares the command palette's diff-open path; targets the event window's
// focused workspace and beeps if it can't be opened (matching the palette).
let manager = activeTabManagerForCommands(preferredWindow: mainWindowForShortcutEvent(event))
if !openDiffViewerForFocusedWorkspace(for: manager) {
NSSound.beep()
}
return true
}

if matchConfiguredShortcut(event: event, action: .toggleRightSidebar) {
// Escape AppKit's performKeyEquivalent animation context. Without
// deferring the toggle, NSAnimationContext implicitly animates the
Expand Down
Loading
Loading