Merge pull request #321 from manaflow-ai/pr-316-head

Follow-up: sync customizable workspace shortcuts across UI
This commit is contained in:
Lawrence Chen 2026-02-22 17:04:07 -08:00 committed by GitHub
commit 1bc3edf75f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 397 additions and 80 deletions

View file

@ -1967,6 +1967,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) {
tabManager?.closeCurrentWorkspaceWithConfirmation()
return true
}
// Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace)
if flags == [.command],
let manager = tabManager,
@ -3305,6 +3310,9 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate {
stateHintItem.title = snapshot.stateHintTitle
applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem)
applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem)
jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications
markAllReadItem.isEnabled = snapshot.hasUnreadNotifications
clearAllItem.isEnabled = snapshot.hasNotifications
@ -3319,6 +3327,16 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate {
}
}
private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) {
guard let keyEquivalent = shortcut.menuItemKeyEquivalent else {
item.keyEquivalent = ""
item.keyEquivalentModifierMask = []
return
}
item.keyEquivalent = keyEquivalent
item.keyEquivalentModifierMask = shortcut.modifierFlags
}
private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) {
for item in notificationItems {
menu.removeItem(item)

View file

@ -2544,7 +2544,7 @@ private struct TabItemView: View {
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
}
.buttonStyle(.plain)
.help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))")
.help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace"))
.frame(width: 16, height: 16, alignment: .center)
.opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0)
.allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint)
@ -2772,6 +2772,8 @@ private struct TabItemView: View {
let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace"
let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read"
let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread"
let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace)
let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace)
Button(pinLabel) {
for id in targetIds {
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
@ -2781,8 +2783,15 @@ private struct TabItemView: View {
syncSelectionAfterMutation()
}
Button("Rename Workspace…") {
promptRename()
if let key = renameWorkspaceShortcut.keyEquivalent {
Button("Rename Workspace…") {
promptRename()
}
.keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers)
} else {
Button("Rename Workspace…") {
promptRename()
}
}
if tab.hasCustomTitle {
@ -2811,10 +2820,18 @@ private struct TabItemView: View {
Divider()
Button(closeLabel) {
closeTabs(targetIds, allowPinned: true)
if let key = closeWorkspaceShortcut.keyEquivalent {
Button(closeLabel) {
closeTabs(targetIds, allowPinned: true)
}
.keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers)
.disabled(targetIds.isEmpty)
} else {
Button(closeLabel) {
closeTabs(targetIds, allowPinned: true)
}
.disabled(targetIds.isEmpty)
}
.disabled(targetIds.isEmpty)
Button("Close Other Workspaces") {
closeOtherTabs(targetIds)

View file

@ -18,6 +18,7 @@ enum KeyboardShortcutSettings {
case nextSidebarTab
case prevSidebarTab
case renameWorkspace
case closeWorkspace
case newSurface
// Panes / splits
@ -50,6 +51,7 @@ enum KeyboardShortcutSettings {
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
case .focusLeft: return "Focus Pane Left"
case .focusRight: return "Focus Pane Right"
@ -76,6 +78,7 @@ enum KeyboardShortcutSettings {
case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft"
case .focusRight: return "shortcut.focusRight"
case .focusUp: return "shortcut.focusUp"
@ -113,6 +116,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
case .renameWorkspace:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
case .focusLeft:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusRight:
@ -196,6 +201,7 @@ enum KeyboardShortcutSettings {
static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) }
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }
@ -250,6 +256,65 @@ struct StoredShortcut: Codable, Equatable {
return flags
}
var keyEquivalent: KeyEquivalent? {
switch key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
default:
let lowered = key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
var eventModifiers: EventModifiers {
var modifiers: EventModifiers = []
if command {
modifiers.insert(.command)
}
if shift {
modifiers.insert(.shift)
}
if option {
modifiers.insert(.option)
}
if control {
modifiers.insert(.control)
}
return modifiers
}
var menuItemKeyEquivalent: String? {
switch key {
case "":
guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "\t":
return "\t"
default:
let lowered = key.lowercased()
guard lowered.count == 1 else { return nil }
return lowered
}
}
static func from(event: NSEvent) -> StoredShortcut? {
guard let key = storedKey(from: event) else { return nil }

View file

@ -5,6 +5,7 @@ struct NotificationsPage: View {
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
@FocusState private var focusedNotificationId: UUID?
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
var body: some View {
VStack(spacing: 0) {
@ -73,6 +74,8 @@ struct NotificationsPage: View {
Spacer()
if !notificationStore.notifications.isEmpty {
jumpToUnreadButton
Button("Clear All") {
notificationStore.clearAll()
}
@ -97,11 +100,76 @@ struct NotificationsPage: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var jumpToUnreadButton: some View {
if let key = jumpToUnreadShortcut.keyEquivalent {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.disabled(!hasUnreadNotifications)
} else {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.disabled(!hasUnreadNotifications)
}
}
private var jumpToUnreadShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var hasUnreadNotifications: Bool {
notificationStore.notifications.contains(where: { !$0.isRead })
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func tabTitle(for tabId: UUID) -> String? {
AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title
}
}
private struct ShortcutAnnotation: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(nsColor: .controlBackgroundColor))
)
}
}
private struct NotificationRow: View {
let notification: TerminalNotification
let tabTitle: String?

View file

@ -500,7 +500,7 @@ struct BrowserPanelView: View {
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help("Toggle Developer Tools")
.help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools"))
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}

View file

@ -177,6 +177,8 @@ extension WorkspaceContentView {
struct EmptyPanelView: View {
@ObservedObject var workspace: Workspace
let paneId: PaneID
@AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data()
private struct ShortcutHint: View {
let text: String
@ -211,6 +213,49 @@ struct EmptyPanelView: View {
_ = workspace.newBrowserSurface(inPane: paneId)
}
private var newSurfaceShortcut: StoredShortcut {
decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut)
}
private var openBrowserShortcut: StoredShortcut {
decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
@ViewBuilder
private func emptyPaneActionButton(
title: String,
systemImage: String,
shortcut: StoredShortcut,
action: @escaping () -> Void
) -> some View {
if let key = shortcut.keyEquivalent {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
} else {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
}
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal.fill")
@ -222,27 +267,19 @@ struct EmptyPanelView: View {
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button {
createTerminal()
} label: {
HStack(spacing: 10) {
Label("Terminal", systemImage: "terminal.fill")
ShortcutHint(text: "⌘T")
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("t", modifiers: [.command])
emptyPaneActionButton(
title: "Terminal",
systemImage: "terminal.fill",
shortcut: newSurfaceShortcut,
action: createTerminal
)
Button {
createBrowser()
} label: {
HStack(spacing: 10) {
Label("Browser", systemImage: "globe")
ShortcutHint(text: "⌘⇧L")
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("l", modifiers: [.command, .shift])
emptyPaneActionButton(
title: "Browser",
systemImage: "globe",
shortcut: openBrowserShortcut,
action: createBrowser
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

View file

@ -13,6 +13,15 @@ struct cmuxApp: App {
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
@ -21,6 +30,8 @@ struct cmuxApp: App {
private var showBrowserJavaScriptConsoleShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
@ -257,11 +268,11 @@ struct cmuxApp: App {
Divider()
}
Button("Show Notifications") {
splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) {
showNotificationsPopover()
}
Button("Jump to Latest Unread") {
splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) {
appDelegate.jumpToLatestUnread()
}
.disabled(!snapshot.hasUnreadNotifications)
@ -337,12 +348,11 @@ struct cmuxApp: App {
// New tab commands
CommandGroup(replacing: .newItem) {
Button("New Window") {
splitCommandButton(title: "New Window", shortcut: newWindowMenuShortcut) {
appDelegate.openNewMainWindow(nil)
}
.keyboardShortcut("n", modifiers: [.command, .shift])
Button("New Workspace") {
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
}
}
@ -359,10 +369,9 @@ struct cmuxApp: App {
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
// is the last workspace, it closes the window.
Button("Close Workspace") {
splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) {
closeTabOrWindow()
}
.keyboardShortcut("w", modifiers: [.command, .shift])
Button("Reopen Closed Browser Panel") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
@ -408,17 +417,17 @@ struct cmuxApp: App {
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") {
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
sidebarState.toggle()
}
Divider()
Button("Next Surface") {
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
}
Button("Previous Surface") {
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
}
@ -470,15 +479,15 @@ struct cmuxApp: App {
BrowserHistoryStore.shared.clearHistory()
}
Button("Next Workspace") {
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
}
Button("Previous Workspace") {
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
}
Button("Rename Workspace…") {
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
_ = AppDelegate.shared?.promptRenameSelectedWorkspace()
}
@ -515,11 +524,11 @@ struct cmuxApp: App {
Divider()
Button("Jump to Latest Unread") {
splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) {
AppDelegate.shared?.jumpToLatestUnread()
}
Button("Show Notifications") {
splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) {
showNotificationsPopover()
}
}
@ -578,6 +587,54 @@ struct cmuxApp: App {
decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut)
}
private var toggleSidebarMenuShortcut: StoredShortcut {
decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut)
}
private var newWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut)
}
private var newWindowMenuShortcut: StoredShortcut {
decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut)
}
private var showNotificationsMenuShortcut: StoredShortcut {
decodeShortcut(
from: showNotificationsShortcutData,
fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut
)
}
private var jumpToUnreadMenuShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var nextSurfaceMenuShortcut: StoredShortcut {
decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut)
}
private var prevSurfaceMenuShortcut: StoredShortcut {
decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut)
}
private var nextWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: nextWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
)
}
private var prevWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: prevWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
)
}
private var splitDownMenuShortcut: StoredShortcut {
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
}
@ -610,6 +667,20 @@ struct cmuxApp: App {
)
}
private var renameWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: renameWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
)
}
private var closeWorkspaceMenuShortcut: StoredShortcut {
decodeShortcut(
from: closeWorkspaceShortcutData,
fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
)
}
private var notificationMenuSnapshot: NotificationMenuSnapshot {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
@ -651,50 +722,14 @@ struct cmuxApp: App {
@ViewBuilder
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
if let key = keyEquivalent(for: shortcut) {
if let key = shortcut.keyEquivalent {
Button(title, action: action)
.keyboardShortcut(key, modifiers: eventModifiers(for: shortcut))
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
} else {
Button(title, action: action)
}
}
private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? {
switch shortcut.key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
default:
let lowered = shortcut.key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers {
var modifiers: EventModifiers = []
if shortcut.command {
modifiers.insert(.command)
}
if shortcut.shift {
modifiers.insert(.shift)
}
if shortcut.option {
modifiers.insert(.option)
}
if shortcut.control {
modifiers.insert(.control)
}
return modifiers
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {

View file

@ -1,6 +1,7 @@
import XCTest
import AppKit
import WebKit
import SwiftUI
import ObjectiveC.runtime
#if canImport(cmux_DEV)
@ -331,6 +332,82 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
XCTAssertFalse(shortcut.control)
}
func testRenameWorkspaceShortcutConvertsToMenuShortcut() {
let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
XCTAssertNotNil(shortcut.keyEquivalent)
XCTAssertTrue(shortcut.eventModifiers.contains(.command))
XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
XCTAssertFalse(shortcut.eventModifiers.contains(.option))
XCTAssertFalse(shortcut.eventModifiers.contains(.control))
}
func testCloseWorkspaceShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace")
let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
XCTAssertEqual(shortcut.key, "w")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testCloseWorkspaceShortcutConvertsToMenuShortcut() {
let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
XCTAssertNotNil(shortcut.keyEquivalent)
XCTAssertTrue(shortcut.eventModifiers.contains(.command))
XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
XCTAssertFalse(shortcut.eventModifiers.contains(.option))
XCTAssertFalse(shortcut.eventModifiers.contains(.control))
}
func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab")
XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab")
let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
XCTAssertEqual(nextShortcut.key, "]")
XCTAssertTrue(nextShortcut.command)
XCTAssertFalse(nextShortcut.shift)
XCTAssertFalse(nextShortcut.option)
XCTAssertTrue(nextShortcut.control)
let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
XCTAssertEqual(prevShortcut.key, "[")
XCTAssertTrue(prevShortcut.command)
XCTAssertFalse(prevShortcut.shift)
XCTAssertFalse(prevShortcut.option)
XCTAssertTrue(prevShortcut.control)
}
func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() {
let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
XCTAssertNotNil(nextShortcut.keyEquivalent)
XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]")
XCTAssertTrue(nextShortcut.eventModifiers.contains(.command))
XCTAssertTrue(nextShortcut.eventModifiers.contains(.control))
let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
XCTAssertNotNil(prevShortcut.keyEquivalent)
XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[")
XCTAssertTrue(prevShortcut.eventModifiers.contains(.command))
XCTAssertTrue(prevShortcut.eventModifiers.contains(.control))
}
func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() {
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertEqual(
StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent,
"\t"
)
}
func testShortcutDefaultsKeysRemainUnique() {
let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey)
XCTAssertEqual(Set(keys).count, keys.count)