diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index fa65d6dcf094..9d5130bef861 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,8 +2,8 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34499 CLI/cmux.swift -17954 Sources/AppDelegate.swift -16427 Sources/ContentView.swift +18023 Sources/AppDelegate.swift +16501 Sources/ContentView.swift 14270 Sources/TerminalController.swift 13172 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -17,15 +17,15 @@ 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift 6255 cmuxTests/GhosttyConfigTests.swift -6166 Sources/TabManager.swift +6171 Sources/TabManager.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5809 Sources/TextBoxInput.swift 5573 cmuxTests/BrowserConfigTests.swift 4483 Sources/cmuxApp.swift 4482 Sources/Panels/FilePreviewPanel.swift 4367 cmuxTests/BrowserPanelTests.swift -4283 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 4121 Sources/BrowserWindowPortal.swift +3964 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -43,10 +43,10 @@ 2328 cmuxTests/CJKIMEInputTests.swift 2242 Sources/TerminalNotificationStore.swift 2229 Sources/TerminalWindowPortal.swift +2153 Sources/KeyboardShortcutSettingsFileStore.swift 2126 cmuxTests/CmuxConfigTests.swift 2085 cmuxTests/ShortcutAndCommandPaletteTests.swift 2078 Sources/SessionPersistence.swift -2011 Sources/KeyboardShortcutSettingsFileStore.swift 1944 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift @@ -126,6 +126,7 @@ 797 Sources/ClosedItemHistory.swift 782 CLI/CMUXCLI+AgentHookDefinitions.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +774 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 773 Sources/App/MenuBarExtraController.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift @@ -173,7 +174,7 @@ 637 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift 635 cmuxUITests/RightSidebarChromeHeightUITests.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift -620 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +627 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift 617 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -186,13 +187,13 @@ 602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +596 Sources/HostSettingsActions.swift 596 cmuxTests/CmuxEventBusTests.swift 594 cmuxTests/PortalTabDragRoutingTests.swift 592 Sources/TextBoxSubmitActions.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift 590 Sources/Panels/BrowserNavigationDelegate.swift 589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift -588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift index 8a2144512798..46c9a8d21d51 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift @@ -35,5 +35,17 @@ public struct KeyboardShortcutsCatalogSection: SettingCatalogSection { defaultValue: [:] ) + /// User-assigned shortcuts for Command Palette commands, keyed by command + /// id (e.g. `palette.openFolderInVSCode`). Single-stroke only. Unlike + /// ``bindings`` (which overrides built-in cmux actions), these let a user + /// bind a shortcut to any palette command that ships without one. The app + /// target dispatches them on the focused window; the Settings UI's + /// "Custom Commands" section records and conflict-checks them. + /// ``StoredShortcut/unbound`` for a command represents an explicit clear. + public let commands = JSONKey<[String: StoredShortcut]>( + id: "shortcuts.commands", + defaultValue: [:] + ) + public init() {} } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift index 5c2aa1bf6f9d..51182bbad662 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift @@ -111,6 +111,42 @@ public actor JSONConfigStore { } } + /// Sets or removes a single entry in a string-keyed map at `key`, preserving + /// every *other* entry's raw on-disk form. + /// + /// Unlike ``set(_:for:)`` — which replaces the whole map with a typed, + /// re-encoded value and therefore drops any sibling this process cannot + /// decode (e.g. a shortcut written in string form `"cmd+n"` when the typed + /// value is the object form) — this reads the map as raw JSON, mutates only + /// `entryKey`, and writes it back. Pass `value: nil` to remove the entry. + /// The map (and its parents) are pruned when the last entry is removed. + /// + /// The value is taken as the typed (`Sendable`) `Value` and encoded *inside* + /// the actor (matching ``set(_:for:)``) so no non-`Sendable` `Any` crosses + /// the actor boundary; only the sibling entries are handled as raw JSON, and + /// they never leave this method. + /// + /// - Throws: Errors from `FileManager` or `JSONSerialization` writing the file. + public func setMapEntry( + _ value: Value?, + forKey entryKey: String, + in key: JSONKey<[String: Value]> + ) throws { + try mutateRoot { root in + var map = (key.path.lookup(in: root) as? [String: Any]) ?? [:] + if let value { + map[entryKey] = value.encodeForJSON() + } else { + map.removeValue(forKey: entryKey) + } + if map.isEmpty { + key.path.remove(in: &root) + } else { + key.path.assign(map, in: &root) + } + } + } + /// Returns an `AsyncStream` that yields the current value and every later change. /// /// - First element is yielded as soon as the consumer starts iterating. diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift index ddb98e8ddb8c..bb3b1f7af330 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/JSONConfigStoreTests.swift @@ -41,6 +41,38 @@ struct JSONConfigStoreTests { #expect(parsed?["automation"] == nil) } + @Test func setMapEntryPreservesSiblingRawEntries() async throws { + let (store, fileURL, catalog) = makeStore() + // A user hand-wrote a string-form command shortcut (the documented + // format), which the typed `[String: StoredShortcut]` decode cannot + // read. A full `set(_:)` of the map would drop it; `setMapEntry` must + // preserve it while adding a different command's binding. + let json = #"{"shortcuts":{"commands":{"palette.newWindow":"cmd+ctrl+n"}}}"# + try Data(json.utf8).write(to: fileURL) + + let objectForm = StoredShortcut(first: ShortcutStroke(key: "y", command: true, option: true, control: true)) + try await store.setMapEntry(objectForm, forKey: "palette.openFolder", in: catalog.shortcuts.commands) + + let data = try Data(contentsOf: fileURL) + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let commands = (parsed?["shortcuts"] as? [String: Any])?["commands"] as? [String: Any] + #expect(commands?["palette.newWindow"] as? String == "cmd+ctrl+n") + #expect(commands?["palette.openFolder"] is [String: Any]) + } + + @Test func setMapEntryRemovesEntryAndPrunesEmptyMap() async throws { + let (store, fileURL, catalog) = makeStore() + let json = #"{"shortcuts":{"commands":{"palette.newWindow":"cmd+ctrl+n"}}}"# + try Data(json.utf8).write(to: fileURL) + + try await store.setMapEntry(StoredShortcut?.none, forKey: "palette.newWindow", in: catalog.shortcuts.commands) + + let data = try Data(contentsOf: fileURL) + let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let shortcuts = parsed?["shortcuts"] as? [String: Any] + #expect(shortcuts?["commands"] == nil) + } + @Test func toleratesJSONCComments() async throws { let (store, fileURL, _) = makeStore() let json = """ diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index e797a819e973..b0b7c925944e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -1,3 +1,4 @@ +import CmuxSettings import Foundation /// Host-supplied callbacks the package's section views invoke for @@ -147,6 +148,50 @@ public protocol SettingsHostActions: AnyObject { /// `async` because the availability check probes a real bind. func applyMobilePairingPort(_ port: Int) async -> MobilePairingPortApplyResult + /// The full catalog of Command Palette commands a user can bind a custom + /// keyboard shortcut to, in a stable display order. Backed by the host's + /// live command contributions, so it lives here rather than in the catalog. + /// The **Custom Commands** keyboard-shortcut section reads this to resolve a + /// bound command id back to a title and to seed the picker's default list. + /// Returns an empty array for hosts without a live palette (previews/tests). + func commandShortcutCatalog() -> [CommandShortcutCatalogEntry] + + /// Ranks the command-shortcut catalog for `query` using the **same ranking + /// engine as the Command Palette**, returning at most `limit` entries best + /// first. An empty query yields the default catalog order (capped to + /// `limit`). Returns an empty array for hosts without a live palette. + func searchCommandShortcutCatalog(query: String, limit: Int) -> [CommandShortcutCatalogEntry] + + /// The user's currently-bound command shortcuts (`shortcuts.commands`), + /// keyed by command id, parsed by the **host's** lenient config reader so a + /// value written in *any* documented form (string `"cmd+n"`, object, or an + /// unbind marker) resolves correctly. The Settings **Custom Commands** + /// section uses this for the bound list and conflict detection rather than + /// the package's object-only typed decode, which would silently drop a + /// string-form binding. Returns an empty map for hosts without a config + /// reader (previews/tests). + func commandShortcuts() -> [String: StoredShortcut] + + /// The effective (override-or-default) shortcut for every built-in cmux + /// action, keyed by action id, resolved by the **host's** lenient reader. + /// The Custom Commands conflict check uses this instead of the package's + /// `shortcuts.bindings` typed decode, which is object-only and all-or-nothing + /// — a single string-form override there would blank the whole map and let a + /// command silently shadow a rebound built-in. Empty for hosts without a + /// config reader (previews/tests). + func effectiveActionShortcuts() -> [String: StoredShortcut] + + /// The shortcuts of user-defined cmux config actions (cmux.json `actions` + /// with a `shortcut`), as `(displayLabel, shortcut)` pairs. The runtime key + /// router dispatches these *before* custom command shortcuts, so the Custom + /// Commands conflict check must include them — otherwise a command could be + /// bound to a keystroke a configured action already owns and never fire. The + /// stored shortcut's first stroke is compared, which also catches chord + /// prefixes. Returned as a list rather than a title-keyed map because action + /// titles are free-form and may collide. Empty for hosts without a config + /// store (previews/tests). + func configuredActionShortcuts() -> [(label: String, shortcut: StoredShortcut)] + /// Shows the Sleepy Mode screensaver as a non-locking preview (any key/click /// exits, no Touch ID). The host owns the overlay window. func sleepyModePreview() @@ -199,18 +244,41 @@ public extension SettingsHostActions { (1...65535).contains(port) ? .savedForLater(port: port) : .invalid(requestedPort: port) } + /// Default: empty catalog, for hosts without a live Command Palette. + func commandShortcutCatalog() -> [CommandShortcutCatalogEntry] { [] } + + /// Default: no bound command shortcuts, for hosts without a config reader. + func commandShortcuts() -> [String: StoredShortcut] { [:] } + + /// Default: no built-in action shortcuts, for hosts without a config reader. + func effectiveActionShortcuts() -> [String: StoredShortcut] { [:] } + + /// Default: no configured action shortcuts, for hosts without a config store. + func configuredActionShortcuts() -> [(label: String, shortcut: StoredShortcut)] { [] } + + /// Default: a simple case-insensitive substring filter over the catalog so + /// preview/test hosts still render a usable picker without the app's engine. + func searchCommandShortcutCatalog(query: String, limit: Int) -> [CommandShortcutCatalogEntry] { + defaultCommandShortcutCatalogSearch(in: commandShortcutCatalog(), query: query, limit: limit) + } + + /// Default sidebar font size + range, for hosts without a Ghostty config. func sidebarFontSize() -> SettingsFontSize { SettingsFontSize(points: 12.5, minimum: 10, maximum: 20, defaultValue: 12.5) } + /// Default: reports success without persisting, for hosts with no config writer. func setSidebarFontSize(_ points: Double) async -> Bool { true } + /// Default workspace tab-bar font size + range, for hosts without a config. func surfaceTabBarFontSize() -> SettingsFontSize { SettingsFontSize(points: 11, minimum: 8, maximum: 14, defaultValue: 11) } + /// Default: reports success without persisting, for hosts with no config writer. func setSurfaceTabBarFontSize(_ points: Double) async -> Bool { true } + /// Default point-size formatter, trimming trailing zeros (e.g. `12`, `13.5`). func formattedFontSize(_ points: Double) -> String { let scaled = (points * 100).rounded() let whole = Int(scaled / 100) @@ -243,3 +311,27 @@ public final class NoopSettingsHostActions: SettingsHostActions { public func previewNotificationSound(value: String, customFilePath: String) {} public func browserHistoryEntryCount() -> Int? { nil } } + +/// Case-insensitive substring filter used by the default +/// ``SettingsHostActions/searchCommandShortcutCatalog(query:limit:)`` so +/// preview/test hosts render a usable picker without the app's ranking engine. +/// File-private so it stays out of the public protocol surface. +private func defaultCommandShortcutCatalogSearch( + in catalog: [CommandShortcutCatalogEntry], + query: String, + limit: Int +) -> [CommandShortcutCatalogEntry] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered: [CommandShortcutCatalogEntry] + if trimmed.isEmpty { + filtered = catalog + } else { + let needle = trimmed.lowercased() + filtered = catalog.filter { entry in + entry.title.lowercased().contains(needle) + || entry.subtitle.lowercased().contains(needle) + || entry.keywords.contains { $0.lowercased().contains(needle) } + } + } + return limit >= 0 ? Array(filtered.prefix(limit)) : filtered +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutConflictChecker.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutConflictChecker.swift new file mode 100644 index 000000000000..2f52a117cc38 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutConflictChecker.swift @@ -0,0 +1,61 @@ +import CmuxSettings +import Foundation + +/// Detects whether a proposed single-stroke command shortcut collides with an +/// existing built-in action binding, a configured cmux action, or another +/// command binding. +/// +/// A value type constructed with the current bindings (rather than a free +/// function) so the conflict surface is owned, testable, and not ambient module +/// API. Built-in actions are checked with numbered-digit family semantics so +/// binding `⌃5` is blocked by the `⌃1…9` workspace selector; the proposed +/// command stroke is never itself a numbered family. +@MainActor +struct CommandShortcutConflictChecker { + /// Built-in action overrides from `shortcuts.bindings`. + let actionBindings: [String: StoredShortcut] + /// User-defined cmux config action shortcuts (cmux.json `actions`) as + /// `(displayLabel, shortcut)` pairs. Dispatched before custom command + /// shortcuts at runtime, so a command must not be allowed to bind a keystroke + /// one of these owns. A list, not a map: action titles may collide. + let configuredActionShortcuts: [(label: String, shortcut: StoredShortcut)] + /// Other commands' bindings from `shortcuts.commands`. + let commandShortcuts: [String: StoredShortcut] + /// Resolves a command id to its display title for the banner. + let title: (String) -> String + + /// The display label of the first conflicting binding — a built-in action's + /// display name, a configured action's label, or another command's title — + /// or `nil` when `stroke` is free. Pass the command id being rebound as + /// `excludingCommandId` so a command's own existing binding is not treated + /// as a self-conflict. Comparisons use the first stroke, which also catches a + /// chord prefix (binding a command to a chord's first stroke would arm the + /// chord and swallow the key). + func conflictLabel(stroke: StoredShortcut, excludingCommandId: String?) -> String? { + for action in ShortcutAction.allCases { + let effective = actionBindings[action.rawValue] ?? action.defaultShortcut + guard let effective, !effective.isUnbound else { continue } + if numberedAwareStrokesConflict( + stroke.first, + numbered: false, + effective.first, + numbered: action.usesNumberedDigitMatching + ) { + return action.displayName + } + } + for entry in configuredActionShortcuts { + guard !entry.shortcut.isUnbound else { continue } + if numberedAwareStrokesConflict(stroke.first, numbered: false, entry.shortcut.first, numbered: false) { + return entry.label + } + } + for (commandId, existing) in commandShortcuts where commandId != excludingCommandId { + guard !existing.isUnbound else { continue } + if numberedAwareStrokesConflict(stroke.first, numbered: false, existing.first, numbered: false) { + return title(commandId) + } + } + return nil + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutPickerSheet.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutPickerSheet.swift new file mode 100644 index 000000000000..bee5a21d6d3e --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CommandShortcutPickerSheet.swift @@ -0,0 +1,248 @@ +import CmuxFoundation +import CmuxSettings +import SwiftUI + +/// The "Add Shortcut" sheet for the **Custom Commands** card. +/// +/// Two-phase flow matching the feature request: first a fuzzy-searchable command +/// picker (ranked by the host with the Command Palette's own engine), then a +/// single-stroke recorder for the chosen command. Recording a keystroke already +/// used by a built-in action or another command is blocked with a banner; the +/// user can record a different keystroke or go back to pick another command. +@MainActor +struct CommandShortcutPickerSheet: View { + /// Ranks the catalog for a query using the host's Command Palette engine. + let search: (String) -> [CommandShortcutCatalogEntry] + /// Commands that already have a binding (shown with a checkmark so the user + /// can tell which they have customized; re-picking one rebinds it). + let alreadyBoundCommandIds: Set + /// Conflict probe: given a proposed stroke (and the command id to exclude + /// when rebinding), returns the colliding binding's label, or `nil`. + let conflictLabel: (StoredShortcut, String?) -> String? + /// Commits a binding and closes the sheet. + let onAssign: (String, StoredShortcut) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var query: String = "" + @State private var results: [CommandShortcutCatalogEntry] = [] + @State private var pendingCommand: CommandShortcutCatalogEntry? + @State private var conflictMessage: String? + @State private var bareKeyRejected = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + if let pendingCommand { + recordView(for: pendingCommand) + } else { + pickerView + } + } + .frame(width: 460, height: 420) + .onAppear { results = search("") } + } + + // MARK: - Header + + @ViewBuilder + private var header: some View { + HStack { + if pendingCommand != nil { + Button { + resetToPicker() + } label: { + Label( + String(localized: "common.back", defaultValue: "Back"), + systemImage: "chevron.left" + ) + .labelStyle(.titleAndIcon) + } + .buttonStyle(.borderless) + .accessibilityIdentifier("CommandShortcutPickerBackButton") + } + Text( + pendingCommand == nil + ? String(localized: "settings.customCommands.picker.title", defaultValue: "Add Command Shortcut") + : String(localized: "settings.customCommands.record.title", defaultValue: "Record Shortcut") + ) + .cmuxFont(.headline) + Spacer() + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + .accessibilityIdentifier("CommandShortcutPickerCancelButton") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Picker phase + + @ViewBuilder + private var pickerView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField( + String(localized: "settings.customCommands.picker.searchPlaceholder", defaultValue: "Search commands…"), + text: $query + ) + .textFieldStyle(.plain) + .accessibilityIdentifier("CommandShortcutPickerSearchField") + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background { + RoundedRectangle(cornerRadius: 7).fill(Color.secondary.opacity(0.12)) + } + .padding(.horizontal, 16) + .padding(.top, 12) + + if results.isEmpty { + Spacer() + Text(String(localized: "settings.customCommands.picker.noResults", defaultValue: "No matching commands.")) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + Spacer() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(results) { entry in + pickerRow(entry) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + } + } + .onChange(of: query) { _, newValue in + results = search(newValue) + } + } + + @ViewBuilder + private func pickerRow(_ entry: CommandShortcutCatalogEntry) -> some View { + Button { + pendingCommand = entry + conflictMessage = nil + bareKeyRejected = false + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(entry.title) + Text(entry.subtitle) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if alreadyBoundCommandIds.contains(entry.commandId) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.secondary) + .help(String(localized: "settings.customCommands.picker.alreadyBound", defaultValue: "Already has a shortcut")) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("CommandShortcutPickerRow") + } + + // MARK: - Record phase + + @ViewBuilder + private func recordView(for entry: CommandShortcutCatalogEntry) -> some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + .cmuxFont(.headline) + Text(entry.subtitle) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + Text(String(localized: "settings.customCommands.record.prompt", defaultValue: "Shortcut")) + Spacer() + ShortcutRecorderView( + hasPendingRejection: bareKeyRejected, + firstStrokeRequiresModifier: true, + onStroke: { stroke in handleStroke(stroke, for: entry) }, + onBareKeyRejected: { + bareKeyRejected = true + conflictMessage = nil + } + ) + .frame(width: 180) + } + + if let message = validationMessage { + ShortcutValidationBanner(message: message) { + conflictMessage = nil + bareKeyRejected = false + } + } + + Text(String( + localized: "settings.customCommands.record.help", + defaultValue: "Press a single keystroke that includes ⌘, ⌥, or ⌃." + )) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + + Spacer() + } + .padding(16) + } + + private var validationMessage: String? { + if bareKeyRejected { + return String( + localized: "shortcut.recorder.error.commandRequiresPrimaryModifier", + defaultValue: "Command shortcuts must include ⌘, ⌥, or ⌃." + ) + } + if let conflictMessage { + let format = String( + localized: "shortcut.recorder.error.conflictsWithBinding", + defaultValue: "This shortcut conflicts with %@." + ) + return String.localizedStringWithFormat(format, conflictMessage) + } + return nil + } + + private func handleStroke(_ stroke: ShortcutStroke, for entry: CommandShortcutCatalogEntry) { + // The recorder requires some modifier, but a command shortcut needs a + // primary one (⌘/⌥/⌃) — a shift-only printable key would steal typing. + guard stroke.command || stroke.option || stroke.control else { + bareKeyRejected = true + conflictMessage = nil + return + } + let proposed = StoredShortcut(first: stroke) + if let conflict = conflictLabel(proposed, entry.commandId) { + conflictMessage = conflict + bareKeyRejected = false + return + } + conflictMessage = nil + bareKeyRejected = false + onAssign(entry.commandId, proposed) + dismiss() + } + + private func resetToPicker() { + pendingCommand = nil + conflictMessage = nil + bareKeyRejected = false + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomCommandShortcutsCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomCommandShortcutsCard.swift new file mode 100644 index 000000000000..16cc13f451e7 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CustomCommandShortcutsCard.swift @@ -0,0 +1,307 @@ +import CmuxFoundation +import CmuxSettings +import SwiftUI + +/// **Custom Commands** card on the Keyboard Shortcuts settings page. +/// +/// Lets the user bind a single keystroke to any Command Palette command that +/// ships without a built-in shortcut (the example from the feature request: +/// "Open Current Directory in VS Code"). Bindings persist under +/// `shortcuts.commands` in cmux.json; the app target dispatches a match on the +/// focused window. +/// +/// Unlike the per-action recorder rows above it (which override built-in cmux +/// actions in `shortcuts.bindings`), this card adds *new* bindings keyed by +/// command id. Recording a keystroke already used by a built-in action or +/// another command is blocked with a banner so a custom binding can never +/// silently shadow a built-in action or collide with another command. +@MainActor +struct CustomCommandShortcutsCard: View { + private let jsonStore: JSONConfigStore + private let catalog: SettingCatalog + private let errorLog: SettingsErrorLog + private let hostActions: SettingsHostActions + + /// Bound command shortcuts, read through the host's lenient config parser + /// (see ``SettingsHostActions/commandShortcuts()``) so a binding written in + /// string form `"cmd+n"` is resolved rather than dropped. Updated + /// optimistically on each edit; the per-entry write is lossless regardless. + @State private var commandShortcuts: [String: StoredShortcut] = [:] + /// The full command catalog (id → title/subtitle/keywords), loaded once from + /// the host so bound rows can resolve a command id back to a title. + @State private var catalogEntries: [CommandShortcutCatalogEntry] = [] + + @State private var isPickerPresented = false + /// Per-command re-record conflict banner: command id → conflicting label. + @State private var rowConflicts: [String: String] = [:] + /// Per-command bare-key (no modifier) rejection banner. + @State private var rowBareKeyRejections: Set = [] + + init( + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog, + hostActions: SettingsHostActions + ) { + self.jsonStore = jsonStore + self.catalog = catalog + self.errorLog = errorLog + self.hostActions = hostActions + } + + var body: some View { + Group { + SettingsSectionHeader( + String(localized: "settings.section.customCommands", defaultValue: "Custom Commands"), + section: .keyboardShortcuts + ) + .accessibilityIdentifier("SettingsCustomCommandShortcutsSection") + + SettingsCard { + addRow + let bound = sortedBoundCommandIds + if !bound.isEmpty { + SettingsCardDivider() + ForEach(Array(bound.enumerated()), id: \.element) { index, commandId in + boundRow(commandId) + if index < bound.count - 1 { + SettingsCardDivider() + } + } + } + } + .settingsSearchAnchors(["setting:keyboardShortcuts:customCommands"]) + + Text(String( + localized: "settings.customCommands.hint", + defaultValue: "Bind a single keystroke to any Command Palette command. The shortcut fires on the focused window; commands unavailable in the current context do nothing." + )) + .cmuxFont(.caption) + .foregroundColor(.secondary) + .padding(.leading, 2) + .accessibilityIdentifier("CustomCommandShortcutsHint") + } + .onAppear { + reloadCatalogIfNeeded() + seedFromHost() + } + .sheet(isPresented: $isPickerPresented) { + CommandShortcutPickerSheet( + search: { hostActions.searchCommandShortcutCatalog(query: $0, limit: 60) }, + alreadyBoundCommandIds: Set(commandShortcuts.keys), + conflictLabel: { stroke, excluding in + conflictChecker().conflictLabel(stroke: stroke, excludingCommandId: excluding) + }, + onAssign: { commandId, shortcut in + Task { await assign(shortcut, to: commandId) } + } + ) + } + } + + // MARK: - Rows + + @ViewBuilder + private var addRow: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:keyboardShortcuts:customCommands:add", + String(localized: "settings.customCommands.add.title", defaultValue: "Command Shortcuts"), + subtitle: String( + localized: "settings.customCommands.add.subtitle", + defaultValue: "Search the Command Palette and assign a keystroke." + ) + ) { + Button { + isPickerPresented = true + } label: { + Label( + String(localized: "settings.customCommands.add.button", defaultValue: "Add Shortcut"), + systemImage: "plus" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("CustomCommandShortcutsAddButton") + } + } + + @ViewBuilder + private func boundRow(_ commandId: String) -> some View { + let shortcut = commandShortcuts[commandId] + let bareKeyRejected = rowBareKeyRejections.contains(commandId) + let conflict = rowConflicts[commandId] + let validationMessage: String? = { + if bareKeyRejected { + return String( + localized: "shortcut.recorder.error.commandRequiresPrimaryModifier", + defaultValue: "Command shortcuts must include ⌘, ⌥, or ⌃." + ) + } + if let conflict { + let messageFormat = String( + localized: "shortcut.recorder.error.conflictsWithBinding", + defaultValue: "This shortcut conflicts with %@." + ) + return String.localizedStringWithFormat(messageFormat, conflict) + } + return nil + }() + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title(for: commandId)) + Text(commandId) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + ShortcutRecorderView( + placeholder: shortcut.map { shortcutDisplayString($0, numbered: false) } + ?? String(localized: "shortcut.unbound.displayValue", defaultValue: "None"), + hasPendingRejection: bareKeyRejected, + firstStrokeRequiresModifier: true, + onStroke: { stroke in Task { await reassign(stroke: stroke, to: commandId) } }, + onBareKeyRejected: { rowBareKeyRejections.insert(commandId) } + ) + .frame(width: 160) + + Button { + rowBareKeyRejections.remove(commandId) + rowConflicts.removeValue(forKey: commandId) + Task { await remove(commandId) } + } label: { + Image(systemName: "xmark.circle.fill") + .imageScale(.medium) + } + .buttonStyle(.borderless) + .help(String(localized: "shortcut.recorder.clear.help", defaultValue: "Unbind shortcut")) + .accessibilityLabel(String(localized: "shortcut.recorder.clear", defaultValue: "Unbind")) + .accessibilityIdentifier("CustomCommandShortcutRemoveButton") + } + + if let validationMessage { + ShortcutValidationBanner(message: validationMessage) { + rowBareKeyRejections.remove(commandId) + rowConflicts.removeValue(forKey: commandId) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + } + + // MARK: - Data + + private var sortedBoundCommandIds: [String] { + commandShortcuts.keys + .filter { !(commandShortcuts[$0]?.isUnbound ?? true) } + .sorted { title(for: $0).localizedCaseInsensitiveCompare(title(for: $1)) == .orderedAscending } + } + + private func title(for commandId: String) -> String { + catalogEntries.first { $0.commandId == commandId }?.title ?? commandId + } + + private func conflictChecker() -> CommandShortcutConflictChecker { + // Read the built-in action bindings *fresh* at conflict-check time (only + // on a record attempt, so cheap) rather than from a snapshot: the + // per-action recorder rows on this same Settings page can rebind a + // built-in mid-session, and a stale snapshot would let a command shadow + // a just-rebound built-in (or keep blocking a just-freed key). + CommandShortcutConflictChecker( + actionBindings: hostActions.effectiveActionShortcuts(), + configuredActionShortcuts: hostActions.configuredActionShortcuts(), + commandShortcuts: commandShortcuts, + title: title(for:) + ) + } + + private func reloadCatalogIfNeeded() { + if catalogEntries.isEmpty { + catalogEntries = hostActions.commandShortcutCatalog() + } + } + + /// Loads the bound command shortcuts from the host's lenient parser (rather + /// than the package's object-only typed decode, which is all-or-nothing and + /// would drop a single string-form binding). Built-in action bindings, used + /// only for conflict detection, are read fresh per check in + /// ``conflictChecker()`` so a same-session rebind is never stale. + private func seedFromHost() { + commandShortcuts = hostActions.commandShortcuts() + pruneStaleRejections() + } + + private func pruneStaleRejections() { + for commandId in Array(rowConflicts.keys) where commandShortcuts[commandId] == nil { + rowConflicts.removeValue(forKey: commandId) + } + for commandId in Array(rowBareKeyRejections) where commandShortcuts[commandId] != nil { + rowBareKeyRejections.remove(commandId) + } + } + + /// Re-records an existing bound command's shortcut, applying the same + /// conflict check as the picker. + private func reassign(stroke: ShortcutStroke, to commandId: String) async { + // The recorder requires *some* modifier, but a command shortcut must + // include a primary one (⌘/⌥/⌃): a shift-only printable key would steal + // ordinary typing. Reuse the bare-key banner (now command-specific text). + guard stroke.command || stroke.option || stroke.control else { + rowBareKeyRejections.insert(commandId) + rowConflicts.removeValue(forKey: commandId) + return + } + let proposed = StoredShortcut(first: stroke) + if let conflict = conflictChecker().conflictLabel(stroke: proposed, excludingCommandId: commandId) { + rowConflicts[commandId] = conflict + rowBareKeyRejections.remove(commandId) + return + } + rowConflicts.removeValue(forKey: commandId) + rowBareKeyRejections.remove(commandId) + await assign(proposed, to: commandId) + } + + private func assign(_ shortcut: StoredShortcut, to commandId: String) async { + // Per-entry write preserves every sibling's raw on-disk form (including + // string-form bindings the package can't type-decode) instead of + // replacing the whole map. Update local state optimistically; the host + // re-seed on the next appear reconciles with the authoritative parse. + do { + try await jsonStore.setMapEntry(shortcut, forKey: commandId, in: catalog.shortcuts.commands) + commandShortcuts[commandId] = shortcut + } catch { + errorLog.record(error, keyID: catalog.shortcuts.commands.id) + } + } + + private func remove(_ commandId: String) async { + // Write an explicit unbind marker rather than deleting the key. The + // settings resolver merges `shortcuts.commands` from fallback config + // files (``fillMissingSettings``) and only suppresses a fallback binding + // when the primary file has an entry for that command. Deleting the + // primary entry would let a fallback-inherited shortcut reappear on the + // next reload; the documented "none" marker is parsed as unbound, stays + // schema-valid (unlike the unbound object form), and suppresses it. + do { + try await jsonStore.setMapEntry( + CustomCommandShortcutsCard.unbindMarker, + forKey: commandId, + in: JSONKey<[String: String]>(id: catalog.shortcuts.commands.id, defaultValue: [:]) + ) + commandShortcuts.removeValue(forKey: commandId) + } catch { + errorLog.record(error, keyID: catalog.shortcuts.commands.id) + } + } + + /// The documented unbind marker written for a cleared command shortcut. A + /// string (not the unbound object form) so it satisfies the schema and is + /// parsed as ``StoredShortcut/unbound`` by the app's config reader. + private static let unbindMarker = "none" +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index 6024433ecfcf..8e28b69eb2e1 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -90,6 +90,13 @@ public struct KeyboardShortcutsSection: View { .foregroundColor(.secondary) .padding(.leading, 2) .accessibilityIdentifier("ShortcutRecordingHint") + + CustomCommandShortcutsCard( + jsonStore: jsonStore, + catalog: catalog, + errorLog: errorLog, + hostActions: hostActions + ) } .task { await streamBindings() } .task { await streamWhenOverrides() } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutValidationBanner.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutValidationBanner.swift new file mode 100644 index 000000000000..f89f9f55ad59 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutValidationBanner.swift @@ -0,0 +1,38 @@ +import CmuxFoundation +import SwiftUI + +/// Red inline validation banner with an Undo affordance, matching the per-action +/// recorder rows. Factored out so the bound-command rows and any future +/// command-shortcut surface render an identical banner. +@MainActor +struct ShortcutValidationBanner: View { + let message: String + let onUndo: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .cmuxFont(.caption) + .foregroundStyle(.red) + Text(message) + .cmuxFont(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + Button(String(localized: "shortcut.recorder.undo", defaultValue: "Undo")) { + onUndo() + } + .buttonStyle(.link) + .cmuxFont(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 6).fill(Color.red.opacity(0.12)) + } + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(Color.red.opacity(0.35), lineWidth: 1) + } + .accessibilityIdentifier("ShortcutRecorderValidationMessage") + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CommandShortcutCatalogEntry.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CommandShortcutCatalogEntry.swift new file mode 100644 index 000000000000..d89e25172cbb --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Values/CommandShortcutCatalogEntry.swift @@ -0,0 +1,35 @@ +import Foundation + +/// One Command Palette command that the **Custom Commands** keyboard-shortcut +/// section can bind a shortcut to. +/// +/// The host app owns the live command catalog (titles, subtitles, and search +/// keywords are derived from the same contributions that drive the Command +/// Palette), so this Foundation-only value type is the bridge the package uses +/// to render the command picker and resolve a bound command id back to a +/// human-readable title. The host supplies these through +/// ``SettingsHostActions/commandShortcutCatalog()`` and ranks them for a query +/// through ``SettingsHostActions/searchCommandShortcutCatalog(query:limit:)``, +/// reusing the Command Palette's own ranking engine. +public struct CommandShortcutCatalogEntry: Sendable, Equatable, Hashable, Identifiable { + /// Stable command identifier (e.g. `palette.openFolderInVSCode`). This is + /// the key written under `shortcuts.commands` in cmux.json. + public let commandId: String + /// Display title, matching the command's Command Palette title. + public let title: String + /// Secondary descriptor (e.g. `Workspace`, `Browser • Example`). + public let subtitle: String + /// Additional search keywords the host's matcher considers. + public let keywords: [String] + + /// `Identifiable` conformance — the stable command id. + public var id: String { commandId } + + /// Creates a catalog entry for one bindable Command Palette command. + public init(commandId: String, title: String, subtitle: String, keywords: [String] = []) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.keywords = keywords + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/CommandShortcutConflictTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/CommandShortcutConflictTests.swift new file mode 100644 index 000000000000..21bb0434410c --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/CommandShortcutConflictTests.swift @@ -0,0 +1,140 @@ +import Testing +import CmuxSettings +@testable import CmuxSettingsUI + +@MainActor +@Suite("Custom command shortcut conflict detection") +struct CommandShortcutConflictTests { + private func single( + _ key: String, + command: Bool = false, + shift: Bool = false, + option: Bool = false, + control: Bool = false + ) -> StoredShortcut { + StoredShortcut(first: ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + )) + } + + private func checker( + actionBindings: [String: StoredShortcut] = [:], + configuredActionShortcuts: [(label: String, shortcut: StoredShortcut)] = [], + commandShortcuts: [String: StoredShortcut] = [:], + title: @escaping (String) -> String = { $0 } + ) -> CommandShortcutConflictChecker { + CommandShortcutConflictChecker( + actionBindings: actionBindings, + configuredActionShortcuts: configuredActionShortcuts, + commandShortcuts: commandShortcuts, + title: title + ) + } + + @Test func freeKeystrokeHasNoConflict() { + let label = checker().conflictLabel( + stroke: single("y", command: true, option: true, control: true), + excludingCommandId: nil + ) + #expect(label == nil) + } + + @Test func conflictsWithAnotherCommand() { + let existing = single("y", command: true, option: true, control: true) + let label = checker( + commandShortcuts: ["palette.foo": existing], + title: { $0 == "palette.foo" ? "Foo" : $0 } + ).conflictLabel( + stroke: single("y", command: true, option: true, control: true), + excludingCommandId: nil + ) + #expect(label == "Foo") + } + + @Test func rebindingExcludesItself() { + // Re-recording the same command's shortcut must not flag the command's + // own existing binding as a conflict. + let existing = single("y", command: true, option: true, control: true) + let label = checker(commandShortcuts: ["palette.foo": existing]).conflictLabel( + stroke: single("y", command: true, option: true, control: true), + excludingCommandId: "palette.foo" + ) + #expect(label == nil) + } + + @Test func conflictsWithBuiltInActionDefault() { + // A stroke equal to a built-in action's default binding must be blocked + // so a custom command shortcut can never silently shadow a built-in. + let action = ShortcutAction.commandPalette + guard let def = action.defaultShortcut, !def.isUnbound else { + Issue.record("commandPalette is expected to have a default binding") + return + } + let label = checker().conflictLabel( + stroke: StoredShortcut(first: def.first), + excludingCommandId: nil + ) + #expect(label != nil) + } + + @Test func conflictsWithConfiguredAction() { + // A user cmux.json action that already owns ⌘⌥⌃Y must block a command on + // the same keystroke (the key router runs configured actions first). + let existing = single("y", command: true, option: true, control: true) + let label = checker( + configuredActionShortcuts: [(label: "Run tests", shortcut: existing)] + ).conflictLabel( + stroke: single("y", command: true, option: true, control: true), + excludingCommandId: nil + ) + #expect(label == "Run tests") + } + + @Test func conflictsWithConfiguredActionDespiteDuplicateTitles() { + // Two configured actions share a title but bind different keys; the list + // (not a title-keyed map) must keep both so neither shortcut is hidden. + let label = checker( + configuredActionShortcuts: [ + (label: "Custom", shortcut: single("j", command: true, option: true, control: true)), + (label: "Custom", shortcut: single("k", command: true, option: true, control: true)), + ] + ).conflictLabel( + stroke: single("k", command: true, option: true, control: true), + excludingCommandId: nil + ) + #expect(label == "Custom") + } + + @Test func conflictsWithConfiguredActionChordPrefix() { + // A configured chord ⌃B,N: binding a command to its ⌃B prefix would arm + // the chord and swallow the key, so it must conflict. + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "n") + ) + let label = checker( + configuredActionShortcuts: [(label: "Chord action", shortcut: chord)] + ).conflictLabel( + stroke: single("b", control: true), + excludingCommandId: nil + ) + #expect(label == "Chord action") + } + + @Test func conflictsWithOverriddenBuiltInAction() { + // The conflict check honors a user's `shortcuts.bindings` override, not + // just the built-in default. ⌘⌥⌃J is not any built-in default, so a + // non-nil result here can only come from honoring the override binding. + let action = ShortcutAction.commandPalette + let override = single("j", command: true, option: true, control: true) + let label = checker(actionBindings: [action.rawValue: override]).conflictLabel( + stroke: single("j", command: true, option: true, control: true), + excludingCommandId: nil + ) + #expect(label != nil) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4028a5ebc9f9..3d09612ad603 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -60804,6 +60804,23 @@ } } }, + "common.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, "common.cancel": { "extractionState": "manual", "localizations": { @@ -150156,6 +150173,193 @@ } } }, + "settings.customCommands.add.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Shortcut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットを追加" + } + } + } + }, + "settings.customCommands.add.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search the Command Palette and assign a keystroke." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドパレットを検索してキーを割り当てます。" + } + } + } + }, + "settings.customCommands.add.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command Shortcuts" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドショートカット" + } + } + } + }, + "settings.customCommands.hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bind a single keystroke to any Command Palette command. The shortcut fires on the focused window; commands unavailable in the current context do nothing." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "任意のコマンドパレットコマンドに単一のキーストロークを割り当てます。ショートカットはフォーカス中のウィンドウで実行され、現在のコンテキストで利用できないコマンドは何も行いません。" + } + } + } + }, + "settings.customCommands.picker.alreadyBound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Already has a shortcut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すでにショートカットがあります" + } + } + } + }, + "settings.customCommands.picker.noResults": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching commands." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するコマンドがありません。" + } + } + } + }, + "settings.customCommands.picker.searchPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search commands…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドを検索…" + } + } + } + }, + "settings.customCommands.picker.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Command Shortcut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドショートカットを追加" + } + } + } + }, + "settings.customCommands.record.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press a single keystroke that includes ⌘, ⌥, or ⌃." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘、⌥、⌃ のいずれかを含む単一のキーストロークを押してください。" + } + } + } + }, + "settings.customCommands.record.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカット" + } + } + } + }, + "settings.customCommands.record.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Record Shortcut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットを記録" + } + } + } + }, "settings.customSidebars.enabled": { "extractionState": "manual", "localizations": { @@ -152411,6 +152615,267 @@ } } }, + "settings.notifications.agentIdleReminder.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notify when an agent has been idle waiting for your input. Suppressed while background work is still running." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントがあなたの入力を待って待機しているときに通知します。バックグラウンド作業の実行中は抑制されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트가 사용자의 입력을 기다리며 유휴 상태일 때 알립니다. 백그라운드 작업이 실행 중일 때는 억제됩니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сповіщати, коли агент неактивний і очікує вашого вводу. Придушується, доки виконується фонова робота." + } + } + } + }, + "settings.notifications.agentIdleReminder.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent Waiting for Input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが入力待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 입력 대기 중" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент очікує вводу" + } + } + } + }, + "settings.notifications.agentPermissionPrompt.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notify when an agent is blocked waiting for your permission to run a tool." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントがツールの実行にあなたの許可を待って停止しているときに通知します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트가 도구 실행에 대한 사용자의 허가를 기다리며 멈췄을 때 알립니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сповіщати, коли агент заблоковано в очікуванні вашого дозволу на запуск інструмента." + } + } + } + }, + "settings.notifications.agentPermissionPrompt.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent Needs Permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが許可待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 권한 필요" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент очікує дозволу" + } + } + } + }, + "settings.notifications.agentTurnComplete.option.always": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Always" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항상" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завжди" + } + } + } + }, + "settings.notifications.agentTurnComplete.option.never": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안 함" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ніколи" + } + } + } + }, + "settings.notifications.agentTurnComplete.option.whenIdle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When idle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유휴 시" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Коли неактивний" + } + } + } + }, + "settings.notifications.agentTurnComplete.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When to notify that an agent finished. When idle waits until background tasks and scheduled wakeups drain." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが完了したときに通知するタイミング。待機中はバックグラウンドタスクとスケジュール再開が片付くまで待ちます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트가 완료되었을 때 알릴 시점입니다. 유휴 시에는 백그라운드 작업과 예약된 재개가 끝날 때까지 기다립니다." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Коли сповіщати про завершення роботи агента. У режимі очікування чекає, доки завершаться фонові задачі та заплановані пробудження." + } + } + } + }, + "settings.notifications.agentTurnComplete.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent Finished" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 완료" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент завершив" + } + } + } + }, "settings.notifications.command": { "extractionState": "manual", "localizations": { @@ -166733,6 +167198,23 @@ } } }, + "settings.section.customCommands": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Commands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタムコマンド" + } + } + } + }, "settings.section.customSidebars": { "extractionState": "manual", "localizations": { @@ -182617,6 +183099,23 @@ } } }, + "shortcut.recorder.error.commandRequiresPrimaryModifier": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command shortcuts must include ⌘, ⌥, or ⌃." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドショートカットには ⌘、⌥、⌃ のいずれかを含める必要があります。" + } + } + } + }, "shortcut.recorder.error.conflictsWithAction": { "extractionState": "manual", "localizations": { @@ -182651,6 +183150,23 @@ } } }, + "shortcut.recorder.error.conflictsWithBinding": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This shortcut conflicts with %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このショートカットは %@ と競合します。" + } + } + } + }, "shortcut.recorder.error.numberedShortcutRequiresDigit": { "extractionState": "manual", "localizations": { @@ -218515,267 +219031,6 @@ } } } - }, - "settings.notifications.agentPermissionPrompt.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent Needs Permission" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが許可待ち" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 권한 필요" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Агент очікує дозволу" - } - } - } - }, - "settings.notifications.agentPermissionPrompt.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notify when an agent is blocked waiting for your permission to run a tool." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントがツールの実行にあなたの許可を待って停止しているときに通知します。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트가 도구 실행에 대한 사용자의 허가를 기다리며 멈췄을 때 알립니다." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Сповіщати, коли агент заблоковано в очікуванні вашого дозволу на запуск інструмента." - } - } - } - }, - "settings.notifications.agentTurnComplete.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent Finished" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが完了" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 완료" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Агент завершив" - } - } - } - }, - "settings.notifications.agentTurnComplete.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When to notify that an agent finished. When idle waits until background tasks and scheduled wakeups drain." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが完了したときに通知するタイミング。待機中はバックグラウンドタスクとスケジュール再開が片付くまで待ちます。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트가 완료되었을 때 알릴 시점입니다. 유휴 시에는 백그라운드 작업과 예약된 재개가 끝날 때까지 기다립니다." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Коли сповіщати про завершення роботи агента. У режимі очікування чекає, доки завершаться фонові задачі та заплановані пробудження." - } - } - } - }, - "settings.notifications.agentTurnComplete.option.whenIdle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "When idle" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "待機中" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "유휴 시" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Коли неактивний" - } - } - } - }, - "settings.notifications.agentTurnComplete.option.always": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Always" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "常に" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "항상" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Завжди" - } - } - } - }, - "settings.notifications.agentTurnComplete.option.never": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Never" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "しない" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "안 함" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Ніколи" - } - } - } - }, - "settings.notifications.agentIdleReminder.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent Waiting for Input" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが入力待ち" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 입력 대기 중" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Агент очікує вводу" - } - } - } - }, - "settings.notifications.agentIdleReminder.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notify when an agent has been idle waiting for your input. Suppressed while background work is still running." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントがあなたの入力を待って待機しているときに通知します。バックグラウンド作業の実行中は抑制されます。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트가 사용자의 입력을 기다리며 유휴 상태일 때 알립니다. 백그라운드 작업이 실행 중일 때는 억제됩니다." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Сповіщати, коли агент неактивний і очікує вашого вводу. Придушується, доки виконується фонова робота." - } - } - } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3c060841eac9..a0b065e7f9a1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5256,6 +5256,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + /// Runs a Command Palette command by id on the focused (or `preferredWindow`) + /// window without opening the palette. Used by the custom `shortcuts.commands` + /// keyboard-shortcut dispatcher; the window's `ContentView` rebuilds its + /// command list with live context and runs the match (or no-ops if the + /// command is gated out by its `when`/`enablement` clause). + func requestCommandPaletteRunCommand( + commandId: String, + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRunCommand" + ) { + // Unlike opening the palette, firing a command must not mutate focus + // state up front: a context-gated shortcut whose command is unavailable + // is a silent no-op, so exiting browser focus mode here would be a + // surprising side effect of pressing a dead binding. Any focus change is + // left to the command's own action when `ContentView` actually runs it. + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow + NotificationCenter.default.post( + name: .commandPaletteRunCommandRequested, + object: targetWindow, + userInfo: ["commandId": commandId] + ) +#if DEBUG + cmuxDebugLog( + "shortcut.palette.runCommand source=\(source) commandId=\(commandId) " + + "target={\(debugWindowToken(targetWindow))}" + ) +#endif + } + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { postCommandPaletteRequest( kind: .renameTab, @@ -13945,9 +13974,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Custom Command Palette command shortcuts (`shortcuts.commands`) are + // matched last so a built-in action always wins a shared keystroke + // (the Settings recorder blocks such conflicts up front, but dispatch + // order makes the precedence robust regardless of how cmux.json was + // edited). On a match the event is consumed even when the command is + // gated out by its `when`/`enablement` clause — the window's + // `ContentView` decides whether to run or silently no-op. + if let commandId = firstMatchingCommandShortcut(event: event) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow + requestCommandPaletteRunCommand( + commandId: commandId, + preferredWindow: targetWindow, + source: "shortcut.commandShortcut" + ) + return true + } + return false } + /// The command id whose user-assigned `shortcuts.commands` binding matches + /// `event`, or `nil`. Bindings are single-stroke and require a primary + /// modifier (⌘/⌥/⌃); they are indexed at reload into per-modifier-mask buckets + /// (a deterministically ordered, capped snapshot — not rebuilt per event). + /// + /// `matchConfiguredShortcut` requires exact modifier-mask equality, so this + /// probes only the bucket for the event's modifier mask. Ordinary unmodified + /// or shift-only typing — the latency-critical path — maps to an empty bucket + /// and returns after a single hash probe, O(1) regardless of how many bindings + /// `shortcuts.commands` holds. Only a keystroke carrying a bound modifier + /// combination scans, and then only the entries sharing that exact combination. + private func firstMatchingCommandShortcut(event: NSEvent) -> String? { + let modifierMask = ShortcutStroke.normalizedModifierFlags(from: event.modifierFlags).rawValue + let candidates = KeyboardShortcutSettings.commandShortcutsMatchingModifierMask(modifierMask) + guard !candidates.isEmpty else { return nil } + for entry in candidates { + if matchConfiguredShortcut(event: event, shortcut: entry.shortcut) { + return entry.commandId + } + } + return nil + } + func shouldSuppressSplitShortcutForTransientTerminalFocusState( direction: SplitDirection? = nil, tabManager preferredTabManager: TabManager? = nil diff --git a/Sources/ContentView+MoveTabToNewWorkspace.swift b/Sources/ContentView+MoveTabToNewWorkspace.swift index ea7a1b155497..f8de4b4d7512 100644 --- a/Sources/ContentView+MoveTabToNewWorkspace.swift +++ b/Sources/ContentView+MoveTabToNewWorkspace.swift @@ -4,7 +4,7 @@ import CmuxFoundation import SwiftUI extension ContentView { - func appendMoveTabToNewWorkspaceCommandContribution( + static func appendMoveTabToNewWorkspaceCommandContribution( to contributions: inout [CommandPaletteCommandContribution], panelSubtitle: @escaping (CommandPaletteContextSnapshot) -> String ) { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5378a63140e3..b99210312bbc 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2827,6 +2827,18 @@ struct ContentView: View { openCommandPaletteSwitcher() }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRunCommandRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + guard let commandId = notification.userInfo?["commandId"] as? String else { return } + runCommandPaletteCommandById(commandId) + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .defaultTerminalRegistrationDidChange)) { _ in refreshCachedDefaultTerminalStatus() }) @@ -6242,7 +6254,14 @@ struct ContentView: View { "ios", "ipados", "iphone", "ipad", "phone", "tablet", "qr", ] - private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + /// All built-in Command Palette command contributions — pure metadata + /// (ids, context-derived titles/subtitles/keywords, `when`/`enablement` + /// gates) with no dependency on live window state. Single source of truth + /// shared by the live palette command builder + /// (``commandPaletteCommandContributions()``) and the Settings "Custom + /// Commands" catalog (``HostSettingsActions/commandShortcutCatalog()``), so + /// the bindable command set and the palette never drift. + static func builtInCommandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { { _ in value } } @@ -6457,7 +6476,7 @@ struct ContentView: View { let titleFormat = String(localized: "command.switchExtensionSidebar.title", defaultValue: "Sidebar: %@") contributions.append( CommandPaletteCommandContribution( - commandId: commandPaletteExtensionSidebarCommandID(descriptor.id), + commandId: Self.commandPaletteExtensionSidebarCommandID(descriptor.id), title: constant(String.localizedStringWithFormat(titleFormat, title)), subtitle: constant(String(localized: "command.switchExtensionSidebar.subtitle", defaultValue: "Choose Sidebar")), keywords: ["sidebar", "switch", "extension", title.lowercased()] @@ -6720,7 +6739,7 @@ struct ContentView: View { for entry in WorkspaceTabColorSettings.palette() { contributions.append( CommandPaletteCommandContribution( - commandId: commandPaletteWorkspaceColorCommandID(entry.name), + commandId: Self.commandPaletteWorkspaceColorCommandID(entry.name), title: constant(workspaceColorCommandTitle(entry.name)), subtitle: workspaceSubtitle, keywords: ["workspace", "color", "palette", entry.name.lowercased()], @@ -6826,7 +6845,7 @@ struct ContentView: View { enablement: { $0.bool(CommandPaletteContextKeys.workspaceCanMarkUnread) } ) ) - appendIdentifierCopyCommandContributions( + Self.appendIdentifierCopyCommandContributions( to: &contributions, workspaceSubtitle: workspaceSubtitle, panelSubtitle: panelSubtitle @@ -6854,7 +6873,7 @@ struct ContentView: View { } ) ) - appendMoveTabToNewWorkspaceCommandContribution(to: &contributions, panelSubtitle: panelSubtitle) + Self.appendMoveTabToNewWorkspaceCommandContribution(to: &contributions, panelSubtitle: panelSubtitle) contributions.append( CommandPaletteCommandContribution( commandId: "palette.toggleTabPin", @@ -7390,13 +7409,26 @@ struct ContentView: View { ) ) + return contributions + } + + /// The contributions active for this window: every built-in + /// (``builtInCommandPaletteCommandContributions()``) plus the + /// config-derived entries that depend on live `cmuxConfigStore` state + /// (configuration-issue commands and user `actions` marked for the + /// palette). + private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + var contributions = Self.builtInCommandPaletteCommandContributions() + let cmuxConfigDefaultSubtitle = String(localized: "command.cmuxConfig.subtitle", defaultValue: "cmux.json") for issue in cmuxConfigStore.configurationIssues { + let issueTitle = commandPaletteCmuxConfigIssueTitle(issue) + let issueSubtitle = commandPaletteCmuxConfigIssueSubtitle(issue) contributions.append( CommandPaletteCommandContribution( commandId: commandPaletteCmuxConfigIssueCommandID(issue), - title: constant(commandPaletteCmuxConfigIssueTitle(issue)), - subtitle: constant(commandPaletteCmuxConfigIssueSubtitle(issue)), + title: { _ in issueTitle }, + subtitle: { _ in issueSubtitle }, keywords: ["cmux", "config", "json", "schema", "error", "warning"] ) ) @@ -7410,8 +7442,8 @@ struct ContentView: View { contributions.append( CommandPaletteCommandContribution( commandId: action.id, - title: constant(actionTitle), - subtitle: constant(subtitleText), + title: { _ in actionTitle }, + subtitle: { _ in subtitleText }, keywords: action.keywords ) ) @@ -7440,7 +7472,7 @@ struct ContentView: View { return "palette.cmuxConfig.issue.\(String(hash, radix: 16))" } - private func commandPaletteWorkspaceColorCommandID(_ colorName: String) -> String { + private static func commandPaletteWorkspaceColorCommandID(_ colorName: String) -> String { var hash: UInt64 = 1_469_598_103_934_665_603 for byte in colorName.utf8 { hash ^= UInt64(byte) @@ -7449,7 +7481,7 @@ struct ContentView: View { return "palette.workspaceColor.\(String(hash, radix: 16))" } - private func commandPaletteExtensionSidebarCommandID(_ providerId: String) -> String { + private static func commandPaletteExtensionSidebarCommandID(_ providerId: String) -> String { var hash: UInt64 = 1_469_598_103_934_665_603 for byte in providerId.utf8 { hash ^= UInt64(byte) @@ -7622,7 +7654,7 @@ struct ContentView: View { // was visible when the flag was on still resolves after a runtime flip. // Visibility is gated by `descriptors`; the handler set is the superset. for descriptor in CmuxExtensionSidebarSelection.allDescriptors { - registry.register(commandId: commandPaletteExtensionSidebarCommandID(descriptor.id)) { + registry.register(commandId: Self.commandPaletteExtensionSidebarCommandID(descriptor.id)) { CmuxExtensionSidebarSelection.setProviderId(descriptor.id) } } @@ -7763,7 +7795,7 @@ struct ContentView: View { tabManager.applyWorkspaceColor(nil, toWorkspaceIds: [workspace.id]) } for entry in WorkspaceTabColorSettings.palette() { - registry.register(commandId: commandPaletteWorkspaceColorCommandID(entry.name)) { + registry.register(commandId: Self.commandPaletteWorkspaceColorCommandID(entry.name)) { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() return @@ -8484,6 +8516,48 @@ struct ContentView: View { } } + /// Runs the palette command with `commandId` — the handler for a custom + /// `shortcuts.commands` keyboard shortcut fired on this window. The command + /// list is rebuilt with the window's live context, so a command gated out by + /// its `when`/`enablement` clause is simply absent and the press is a silent + /// no-op (the app-level handler still consumes the event on any binding + /// match). Execution delegates to ``runCommandPaletteCommand(_:)`` so + /// palette dismissal, focus restoration, and usage tracking stay identical + /// to selection from the visible palette. + /// + /// A `dismissOnRun == false` command drives an *in-palette* input flow (e.g. + /// rename / edit-description, which switch `commandPaletteMode` to an input + /// mode that only renders while the palette is presented). For those, the + /// palette is presented first so the flow's UI is visible; self-contained + /// commands run directly without showing any palette UI. + private func runCommandPaletteCommandById(_ commandId: String) { + // Resolve the command list against a freshly-built context (refreshing + // terminal open-target availability) rather than the cached palette + // context, which only updates on a palette search refresh. Otherwise a + // context-gated command (e.g. the VS Code serve-web stop/restart entries) + // could be wrongly treated as unavailable until the palette was opened. + let terminalOpenTargets = resolveCommandPaletteTerminalOpenTargets(for: .commands) + let commandsContext = commandPaletteCommandsContext(terminalOpenTargets: terminalOpenTargets) + let commands = commandPaletteEntries( + for: .commands, + includeSurfaces: false, + commandsContext: commandsContext + ) + guard let command = commands.first(where: { $0.id == commandId }) else { +#if DEBUG + cmuxDebugLog("palette.runById commandId=\(commandId) result=noop(unavailable)") +#endif + return + } +#if DEBUG + cmuxDebugLog("palette.runById commandId=\(commandId) result=run dismissOnRun=\(command.dismissOnRun ? 1 : 0)") +#endif + if !command.dismissOnRun, !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + runCommandPaletteCommand(command) + } + private func commandPalettePostRunFocusTarget(for command: CommandPaletteCommand) -> CommandPaletteRestoreFocusTarget? { guard let intent = Self.commandPalettePostRunRestoreFocusIntent(forCommandId: command.id), let panelContext = focusedPanelContext else { diff --git a/Sources/ContentViewIdentifierCopyCommands.swift b/Sources/ContentViewIdentifierCopyCommands.swift index f9820e14ad2a..eee6e70c6b4a 100644 --- a/Sources/ContentViewIdentifierCopyCommands.swift +++ b/Sources/ContentViewIdentifierCopyCommands.swift @@ -3,7 +3,7 @@ import AppKit import Foundation extension ContentView { - func appendIdentifierCopyCommandContributions( + static func appendIdentifierCopyCommandContributions( to contributions: inout [CommandPaletteCommandContribution], workspaceSubtitle: @escaping (CommandPaletteContextSnapshot) -> String, panelSubtitle: @escaping (CommandPaletteContextSnapshot) -> String diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index a55b0cea3279..8fb2a6356d27 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -1,5 +1,7 @@ import AppKit import CMUXMobileCore +import CmuxCommandPalette +import CmuxSettings import CmuxWorkspaces import CmuxSettingsUI import CmuxFoundation @@ -43,6 +45,15 @@ final class HostSettingsActions: SettingsHostActions { private var configWindow: NSWindow? private var configWindowCloseObserver: WindowCloseObserver? + /// Memoized bindable command catalog + its prepared search engine. The + /// catalog is static for the process lifetime (locale changes restart the + /// app), so both are built once and reused across the picker's per-keystroke + /// searches rather than rebuilt each call. + private var cachedCommandShortcutCatalog: [CommandShortcutCatalogEntry]? + private var cachedCommandShortcutSearchEngine: CommandPaletteSearchEngine? + private typealias ConfiguredActionShortcut = (label: String, shortcut: CmuxSettings.StoredShortcut) + private var cachedStandaloneConfiguredActionShortcuts: [ConfiguredActionShortcut]? + init(configFileURL: URL) { self.configFileURL = configFileURL startObservingAppIconMode() @@ -318,6 +329,182 @@ final class HostSettingsActions: SettingsHostActions { } } + /// All built-in palette commands a custom shortcut can target, in a stable + /// display order (deduplicated by command id, keeping the first occurrence). + /// + /// Derived from ``ContentView/builtInCommandPaletteCommandContributions()`` — + /// the same single source of truth the live palette uses — evaluated against + /// a neutral context so every bindable command appears with a generic title + /// regardless of the current window's focus. Config-derived `actions` are + /// intentionally excluded: those already support a `shortcut` field directly + /// in cmux.json, so surfacing them here would offer two ways to bind one thing. + /// + /// Built once and memoized: the contributions are static (the only variable, + /// the active locale, requires a process restart), and the Settings picker + /// calls this on every keystroke, so rebuilding the list each time is wasted + /// work. + func commandShortcutCatalog() -> [CommandShortcutCatalogEntry] { + if let cachedCommandShortcutCatalog { + return cachedCommandShortcutCatalog + } + let neutralContext = CommandPaletteContextSnapshot() + var seen = Set() + var entries: [CommandShortcutCatalogEntry] = [] + for contribution in ContentView.builtInCommandPaletteCommandContributions() { + guard seen.insert(contribution.commandId).inserted else { continue } + let title = contribution.title(neutralContext) + guard !title.isEmpty else { continue } + entries.append( + CommandShortcutCatalogEntry( + commandId: contribution.commandId, + title: title, + subtitle: contribution.subtitle(neutralContext), + keywords: contribution.keywords + ) + ) + } + cachedCommandShortcutCatalog = entries + return entries + } + + /// The user's bound command shortcuts, parsed by the app's lenient settings + /// reader (``KeyboardShortcutSettings/commandShortcuts()``) so a binding + /// written in any documented form — `"cmd+n"`, the package's object form, or + /// an unbind marker — resolves the same way the runtime dispatcher sees it. + /// + /// The return type is the **package** ``CmuxSettings/StoredShortcut`` (the + /// protocol's type), not the app's identically-named flat struct; the app + /// type would make this a non-witness and silently fall back to the + /// protocol's empty default. ``Self/packageStoredShortcut(from:)`` bridges. + func commandShortcuts() -> [String: CmuxSettings.StoredShortcut] { + KeyboardShortcutSettings.commandShortcuts().mapValues(Self.packageStoredShortcut(from:)) + } + + /// The effective (override-or-default) binding for every built-in cmux + /// action, keyed by action id, read through the app's lenient resolver so a + /// string-form `shortcuts.bindings` override is honored. Bridged to the + /// package ``CmuxSettings/StoredShortcut`` for the protocol witness. + /// + /// Includes explicitly-unbound actions as the package unbound marker (rather + /// than omitting them) so the package conflict checker — which falls back to + /// an action's built-in default for any *absent* id — treats a user-unbound + /// action as free rather than as still occupying its default keystroke. + func effectiveActionShortcuts() -> [String: CmuxSettings.StoredShortcut] { + var result: [String: CmuxSettings.StoredShortcut] = [:] + for action in KeyboardShortcutSettings.Action.allCases { + result[action.rawValue] = Self.packageStoredShortcut( + from: KeyboardShortcutSettings.shortcut(for: action) + ) + } + return result + } + + /// The shortcuts of user-defined cmux config actions (cmux.json `actions` + /// with a `shortcut`), keyed by display label, for the Custom Commands + /// conflict check. These are dispatched by the key router *before* custom + /// command shortcuts, so a command bound to a keystroke an action already + /// owns would never fire — the conflict check must see them. + /// + /// cmux.json is global, so any live window's config store carries the same + /// `actions`; the first available one is used. + func configuredActionShortcuts() -> [(label: String, shortcut: CmuxSettings.StoredShortcut)] { + let liveStores = AppDelegate.shared?.mainWindowContexts.values + .compactMap { $0.cmuxConfigStore } ?? [] + if liveStores.isEmpty { + if let cachedStandaloneConfiguredActionShortcuts { + return cachedStandaloneConfiguredActionShortcuts + } + // No live window (e.g. Settings opened after the last window closed): + // load the global config standalone so configured-action conflicts are + // still checked rather than silently skipped. Cache the snapshot so + // recorder validation does not synchronously reload cmux.json on + // every attempted keystroke. + let transient = CmuxConfigStore() + transient.loadAll() + let shortcuts = configuredActionShortcuts(from: [transient]) + cachedStandaloneConfiguredActionShortcuts = shortcuts + return shortcuts + } + cachedStandaloneConfiguredActionShortcuts = nil + // Each window's store merges its local config with the global one, and + // runtime dispatch checks the focused window's store. Aggregate across + // every live window so a configured action defined in another project + // window is still treated as a conflict. + return configuredActionShortcuts(from: liveStores) + } + + private func configuredActionShortcuts(from stores: [CmuxConfigStore]) -> [ConfiguredActionShortcut] { + // A list, not a title-keyed map: action titles are free-form and may + // collide, and dropping a duplicate would hide a real conflict. Dedup by + // (id, keystroke) so the global actions shared across windows collapse + // while distinct per-window overrides are all kept. + var seen = Set() + var result: [ConfiguredActionShortcut] = [] + for store in stores { + for action in store.shortcutActions() { + guard let shortcut = action.shortcut, !shortcut.isUnbound else { continue } + guard seen.insert("\(action.id)\u{0}\(shortcut.configIdentifier)").inserted else { continue } + result.append((label: action.title, shortcut: Self.packageStoredShortcut(from: shortcut))) + } + } + return result + } + + /// Bridges the app's flat ``StoredShortcut`` to the package + /// ``CmuxSettings/StoredShortcut`` (first/second strokes) the Settings + /// package speaks. Field-by-field — the two Codable shapes differ (flat vs + /// nested), so a JSON round-trip would not bridge them. + private static func packageStoredShortcut(from shortcut: StoredShortcut) -> CmuxSettings.StoredShortcut { + func packageStroke(_ stroke: ShortcutStroke) -> CmuxSettings.ShortcutStroke { + CmuxSettings.ShortcutStroke( + key: stroke.key, + command: stroke.command, + shift: stroke.shift, + option: stroke.option, + control: stroke.control, + keyCode: stroke.keyCode + ) + } + return CmuxSettings.StoredShortcut( + first: packageStroke(shortcut.firstStroke), + second: shortcut.secondStroke.map(packageStroke) + ) + } + + /// Ranks ``commandShortcutCatalog()`` for `query` with the Command Palette's + /// own ``CommandPaletteSearchEngine`` so the Settings picker matches palette + /// search exactly. An empty query yields the default order capped to `limit`. + /// The corpus + engine are built once (the catalog is static) and reused so a + /// per-keystroke search does not re-prepare the whole corpus. + func searchCommandShortcutCatalog(query: String, limit: Int) -> [CommandShortcutCatalogEntry] { + let entries = commandShortcutCatalog() + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return limit >= 0 ? Array(entries.prefix(limit)) : entries + } + let engine: CommandPaletteSearchEngine + if let cachedCommandShortcutSearchEngine { + engine = cachedCommandShortcutSearchEngine + } else { + let corpus = entries.enumerated().map { index, entry in + CommandPaletteSearchCorpusEntry( + payload: entry, + rank: index, + title: entry.title, + searchableTexts: [entry.title, entry.subtitle] + entry.keywords + ) + } + engine = CommandPaletteSearchEngine(entries: corpus) + cachedCommandShortcutSearchEngine = engine + } + let results = engine.search( + query: trimmed, + resultLimit: limit >= 0 ? limit : nil, + historyBoost: { _, _ in 0 } + ) + return results.map(\.payload) + } + /// Localized transport label for a pairing route shown in diagnostics. private static func routeKindLabel(_ kind: CmxAttachTransportKind) -> String { switch kind { diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index bf08f3e34904..f3109bff6d0c 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -70,6 +70,24 @@ final class CmuxSettingsFileStore { private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] private var whenClausesByAction: [KeyboardShortcutSettings.Action: ShortcutWhenClause] = [:] + private var commandShortcutsByCommandId: [String: StoredShortcut] = [:] + /// Live `shortcuts.commands` bindings indexed by each (single) stroke's + /// normalized modifier mask, rebuilt once per reload (prefiltered to non-unbound + /// entries, capped at ``maxDispatchableCommandShortcuts``). The per-keystroke + /// dispatcher (`firstMatchingCommandShortcut`) probes only the bucket for the + /// event's modifier mask. Every command shortcut requires a primary modifier + /// (⌘/⌥/⌃), so ordinary unmodified or shift-only typing — the latency-critical + /// path — maps to an absent bucket and dispatch is O(1) regardless of how many + /// bindings `shortcuts.commands` holds (issue #6431 review follow-up). + private var commandShortcutsByModifierMask: [UInt: [(commandId: String, shortcut: StoredShortcut)]] = [:] + + /// Upper bound on the number of `shortcuts.commands` bindings that participate + /// in per-keystroke dispatch. The Settings conflict checker already prevents + /// duplicate keystrokes, so a UI-built config never approaches this; the cap + /// only bounds a hand-edited cmux.json so a pathologically large map cannot + /// turn a modified keystroke into unbounded work. Far above the command + /// catalog size, so binding every command stays well within it. + static let maxDispatchableCommandShortcuts = 1000 private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] private var importedManagedDefaults: [String: ManagedSettingsValue] = [:] private var activeLegacyDerivedManagedUserDefaultKeys: Set = [] @@ -153,6 +171,7 @@ final class CmuxSettingsFileStore { ( shortcuts: shortcutsByAction, whenClauses: whenClausesByAction, + commandShortcuts: commandShortcutsByCommandId, importedManagedDefaults: importedManagedDefaults, sourcePath: activeSourcePath ) @@ -171,6 +190,8 @@ final class CmuxSettingsFileStore { synchronized { shortcutsByAction = resolved.shortcuts whenClausesByAction = resolved.whenClauses + commandShortcutsByCommandId = resolved.commandShortcuts + commandShortcutsByModifierMask = Self.makeCommandShortcutDispatchIndex(resolved.commandShortcuts).byModifierMask activeManagedUserDefaults = resolved.managedUserDefaults importedManagedDefaults = resolved.managedUserDefaults activeLegacyDerivedManagedUserDefaultKeys = resolved.legacyDerivedManagedUserDefaultKeys @@ -181,6 +202,7 @@ final class CmuxSettingsFileStore { if previousState.shortcuts != resolved.shortcuts || previousState.whenClauses != resolved.whenClauses + || previousState.commandShortcuts != resolved.commandShortcuts || previousState.sourcePath != resolved.path { KeyboardShortcutSettings.notifySettingsFileDidChange(center: notificationCenter) } @@ -190,6 +212,45 @@ final class CmuxSettingsFileStore { synchronized { shortcutsByAction[action] } } + /// User-assigned Command Palette command shortcuts (`shortcuts.commands`), + /// keyed by command id. Entries that resolve to ``StoredShortcut/unbound`` + /// are dropped so the dispatcher only sees live single-stroke bindings. + func commandShortcuts() -> [String: StoredShortcut] { + synchronized { + commandShortcutsByCommandId.filter { !$0.value.isUnbound } + } + } + + /// Command shortcuts whose stroke carries exactly `modifierMask` — the bucket + /// the per-keystroke dispatcher probes. Empty for unmodified or shift-only + /// masks because every command shortcut requires a primary modifier (⌘/⌥/⌃), + /// so ordinary typing dispatches in O(1). Reading it is a single lock-guarded + /// dictionary lookup. + func commandShortcutsMatchingModifierMask(_ modifierMask: UInt) -> [(commandId: String, shortcut: StoredShortcut)] { + synchronized { commandShortcutsByModifierMask[modifierMask] ?? [] } + } + + /// Builds the per-keystroke command-shortcut dispatch index from a resolved + /// `shortcuts.commands` map: a deterministically ordered, prefiltered list + /// (unbound entries dropped, capped at ``maxDispatchableCommandShortcuts``) + /// plus that list bucketed by normalized modifier mask. Pure and `static` so + /// the bucketing and cap invariants are unit-testable without a live store. + static func makeCommandShortcutDispatchIndex( + _ commandShortcuts: [String: StoredShortcut], + limit: Int = CmuxSettingsFileStore.maxDispatchableCommandShortcuts + ) -> ( + ordered: [(commandId: String, shortcut: StoredShortcut)], + byModifierMask: [UInt: [(commandId: String, shortcut: StoredShortcut)]] + ) { + let ordered = commandShortcuts + .filter { !$0.value.isUnbound } + .sorted { $0.key < $1.key } + .prefix(max(0, limit)) + .map { (commandId: $0.key, shortcut: $0.value) } + let byModifierMask = Dictionary(grouping: ordered) { $0.shortcut.modifierFlags.rawValue } + return (ordered, byModifierMask) + } + /// The `when`-clause override for an action parsed from `shortcuts.when` in /// cmux.json, or `nil` when the action has no configured override (so the /// caller falls back to the action's built-in ``shortcutContext``). @@ -993,7 +1054,8 @@ final class CmuxSettingsFileStore { } else if section.keys.contains("showModifierHoldHints") { logInvalid("shortcuts.showModifierHoldHints", sourcePath: sourcePath) } - for (key, rawValue) in section where key != "bindings" && key != "showModifierHoldHints" && key != "when" { + for (key, rawValue) in section + where key != "bindings" && key != "showModifierHoldHints" && key != "when" && key != "commands" { bindings[key] = rawValue } @@ -1010,6 +1072,78 @@ final class CmuxSettingsFileStore { } parseShortcutWhenClauses(section["when"], sourcePath: sourcePath, snapshot: &snapshot) + parseCommandShortcuts(section["commands"], sourcePath: sourcePath, snapshot: &snapshot) + } + + /// Parses the optional `shortcuts.commands` map — `{ "": + /// "" }` — into per-command-id ``StoredShortcut`` overrides. + /// Each binding is **single-stroke only**: a chord (two-element array or a + /// `second` stroke) is rejected so a custom command shortcut can never + /// silently shadow a tmux-style built-in chord. `null` / `"none"` etc. map + /// to ``StoredShortcut/unbound`` (an explicit clear). Invalid entries are + /// logged and skipped. + private func parseCommandShortcuts( + _ rawValue: Any?, + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard let rawValue else { return } + guard let commandsSection = rawValue as? [String: Any] else { + logInvalid("shortcuts.commands", sourcePath: sourcePath) + return + } + for (commandId, rawBinding) in commandsSection { + guard !commandId.isEmpty else { continue } + guard let shortcut = parseCommandShortcutValue(rawBinding) else { + cmuxSettingsFileStoreLogger.warning("ignoring invalid shortcuts.commands binding for '\(commandId, privacy: .private(mask: .hash))' in \(sourcePath, privacy: .private(mask: .hash))") + continue + } + snapshot.commandShortcuts[commandId] = shortcut + } + } + + /// Decodes one `shortcuts.commands` value into a single-stroke + /// ``StoredShortcut``. Accepts a string (`"cmd+shift+v"`), the package's + /// nested object form (`{ "first": { … } }`), or an explicit unbind marker + /// (`null` / `""` / `none` / …). A chord — a two-element string array or a + /// non-null `second` stroke — is rejected (`nil`) so custom command + /// shortcuts stay single-stroke. + private func parseCommandShortcutValue(_ rawValue: Any) -> StoredShortcut? { + if rawValue is NSNull { return .unbound } + + let shortcut: StoredShortcut + if let stroke = jsonString(rawValue) { + guard let parsed = StoredShortcut.parseConfig(stroke, allowBareFirstStroke: false) else { + return nil + } + if parsed.isUnbound { return .unbound } + if parsed.hasChord { return nil } + shortcut = parsed + } else if rawValue is [Any] { + // A chord array is explicitly unsupported for command shortcuts. + return nil + } else if let object = rawValue as? [String: Any] { + if let secondValue = object["second"], !(secondValue is NSNull) { + return nil + } + guard let firstValue = object["first"], + let first = parseShortcutStrokeObject(firstValue) else { + return nil + } + if first.key.isEmpty { return .unbound } + shortcut = StoredShortcut(first: first) + } else { + return nil + } + + // Command shortcuts must include a *primary* modifier (⌘, ⌥, or ⌃). + // They are installed in the app-level key monitor and fire before the + // terminal/browser receives the key, so a bare key (incl. Space) or a + // shift-only printable key like ⇧Y would otherwise run the command every + // time the user types that character. Shift alone is not enough. + let firstStroke = shortcut.firstStroke + guard firstStroke.command || firstStroke.option || firstStroke.control else { return nil } + return shortcut } /// Parses the optional `shortcuts.when` map — `{ "": "" }` @@ -1832,12 +1966,17 @@ private struct ResolvedSettingsSnapshot { /// Per-action `when`-clause overrides parsed from `shortcuts.when` — gate a /// binding to a focus context (see ``ShortcutWhenClause``). var whenClauses: [KeyboardShortcutSettings.Action: ShortcutWhenClause] = [:] + /// User-assigned Command Palette command shortcuts parsed from + /// `shortcuts.commands`, keyed by command id. Single-stroke only; the app + /// dispatches a match on the focused window. + var commandShortcuts: [String: StoredShortcut] = [:] var managedUserDefaults: [String: ManagedSettingsValue] = [:] var legacyDerivedManagedUserDefaultKeys: Set = [] var managedCustomSettings = ManagedCustomSettings() mutating func fillMissingSettings(from fallback: ResolvedSettingsSnapshot) { if path == nil && (!fallback.shortcuts.isEmpty || + !fallback.commandShortcuts.isEmpty || !fallback.managedUserDefaults.isEmpty || !fallback.managedCustomSettings.isEmpty) { path = fallback.path @@ -1848,6 +1987,9 @@ private struct ResolvedSettingsSnapshot { for (action, clause) in fallback.whenClauses where whenClauses[action] == nil { whenClauses[action] = clause } + for (commandId, shortcut) in fallback.commandShortcuts where commandShortcuts[commandId] == nil { + commandShortcuts[commandId] = shortcut + } for (key, value) in fallback.managedUserDefaults where managedUserDefaults[key] == nil { managedUserDefaults[key] = value if fallback.legacyDerivedManagedUserDefaultKeys.contains(key) { diff --git a/Sources/KeyboardShortcutSettingsLookup.swift b/Sources/KeyboardShortcutSettingsLookup.swift index 0e0ef5a9633e..fb9723ea3816 100644 --- a/Sources/KeyboardShortcutSettingsLookup.swift +++ b/Sources/KeyboardShortcutSettingsLookup.swift @@ -57,6 +57,24 @@ extension KeyboardShortcutSettings { settingsFileStore.isManagedByFile(action) } + /// User-assigned Command Palette command shortcuts (`shortcuts.commands` in + /// cmux.json), keyed by command id. Only live single-stroke bindings are + /// returned (cleared/`unbound` entries are filtered out). The app's + /// keyboard handler matches a key event against these and fires the matched + /// command on the focused window. + static func commandShortcuts() -> [String: StoredShortcut] { + settingsFileStore.commandShortcuts() + } + + /// Command shortcuts whose stroke carries exactly `modifierMask`, the bucket + /// the per-event keyboard dispatcher probes for the event's modifier flags. + /// Empty for unmodified or shift-only masks (every command shortcut requires a + /// primary modifier), so ordinary typing dispatches in O(1) regardless of how + /// many bindings `shortcuts.commands` holds. + static func commandShortcutsMatchingModifierMask(_ modifierMask: UInt) -> [(commandId: String, shortcut: StoredShortcut)] { + settingsFileStore.commandShortcutsMatchingModifierMask(modifierMask) + } + /// The effective focus predicate gating `action`: the `shortcuts.when` /// override from cmux.json if present, otherwise the action's built-in /// ``KeyboardShortcutSettings/Action/shortcutContext`` expressed as a diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a54ce901b309..b85c19fcc2a0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6125,6 +6125,11 @@ extension Notification.Name { static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + /// Posted to run a Command Palette command by id on a specific window + /// (`object`), without opening the palette — the dispatch path for a custom + /// `shortcuts.commands` keyboard shortcut. `userInfo["commandId"]` carries + /// the command id. + static let commandPaletteRunCommandRequested = Notification.Name("cmux.commandPaletteRunCommandRequested") static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested") static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested") static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") diff --git a/cmuxTests/CommandPaletteShortcutCustomizationTests.swift b/cmuxTests/CommandPaletteShortcutCustomizationTests.swift index c7364517404b..16cd8003ca60 100644 --- a/cmuxTests/CommandPaletteShortcutCustomizationTests.swift +++ b/cmuxTests/CommandPaletteShortcutCustomizationTests.swift @@ -478,6 +478,192 @@ final class CommandPaletteShortcutCustomizationTests: XCTestCase { } } + func testCommandShortcutsParseSingleStrokeAndRejectChordsAndNulls() throws { + try writeSettingsFile( + """ + { + "shortcuts": { + "commands": { + "palette.newWindow": "cmd+ctrl+opt+y", + "palette.openFolder": ["cmd+b", "c"], + "palette.newWorkspace": null, + "palette.bareSpace": "space", + "palette.shiftOnly": "shift+y", + "palette.modifierSpace": "cmd+space" + } + } + } + """ + ) + + let resolved = KeyboardShortcutSettings.commandShortcuts() + XCTAssertEqual(resolved.count, 2, "Only the valid primary-modifier bindings should survive") + + let newWindow = try XCTUnwrap(resolved["palette.newWindow"]) + XCTAssertFalse(newWindow.hasChord) + XCTAssertEqual(newWindow.firstStroke.key, "y") + XCTAssertTrue(newWindow.firstStroke.command) + XCTAssertTrue(newWindow.firstStroke.control) + XCTAssertTrue(newWindow.firstStroke.option) + XCTAssertFalse(newWindow.firstStroke.shift) + + XCTAssertNil(resolved["palette.openFolder"], "Chord bindings are not allowed for command shortcuts") + XCTAssertNil(resolved["palette.newWorkspace"], "A null binding clears the command shortcut") + XCTAssertNil(resolved["palette.bareSpace"], "A bare Space must be rejected — command shortcuts require a primary modifier") + XCTAssertNil(resolved["palette.shiftOnly"], "Shift-only must be rejected — it would steal ordinary typing") + + let modifierSpace = try XCTUnwrap(resolved["palette.modifierSpace"], "Space with a modifier is allowed") + XCTAssertEqual(modifierSpace.firstStroke.key, "space") + XCTAssertTrue(modifierSpace.firstStroke.command) + } + + func testCommandShortcutDispatchPostsRunCommandRequest() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + // Bind a deliberately non-existent command id so the dispatch path is + // exercised end-to-end (match → consume → post) while the focused + // window's command runner simply no-ops on the unknown id — no real + // command runs and no window is created as a side effect. + let fakeCommandId = "palette.test.customShortcutDispatch" + try writeSettingsFile( + """ + { + "shortcuts": { + "commands": { + "\(fakeCommandId)": "cmd+ctrl+opt+y" + } + } + } + """ + ) + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + let runExpectation = expectation(description: "Command shortcut posts a run-command request") + var observedCommandId: String? + let token = NotificationCenter.default.addObserver( + forName: .commandPaletteRunCommandRequested, + object: nil, + queue: nil + ) { notification in + observedCommandId = notification.userInfo?["commandId"] as? String + runExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + guard let event = makeKeyDownEvent( + key: "y", + modifiers: [.command, .control, .option], + keyCode: 16, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct ⌘⌃⌥Y event") + return + } + + #if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: event), + "A matching command shortcut must consume the event" + ) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + + wait(for: [runExpectation], timeout: 1.0) + XCTAssertEqual(observedCommandId, fakeCommandId) + } + + func testCommandShortcutDispatchIndexBucketsByModifierMask() throws { + try writeSettingsFile( + """ + { + "shortcuts": { + "commands": { + "palette.alpha": "cmd+ctrl+opt+y", + "palette.beta": "cmd+b" + } + } + } + """ + ) + + let store = KeyboardShortcutSettings.settingsFileStore + + // Ordinary typing carries no primary modifier, so its bucket is always + // empty regardless of how many command shortcuts are configured — the + // O(1) fast path the per-keystroke dispatcher relies on. + XCTAssertTrue( + store.commandShortcutsMatchingModifierMask(0).isEmpty, + "Unmodified keystrokes must not scan any command binding" + ) + XCTAssertTrue( + store.commandShortcutsMatchingModifierMask(NSEvent.ModifierFlags.shift.rawValue).isEmpty, + "Shift-only keystrokes must not scan any command binding" + ) + + XCTAssertEqual( + store.commandShortcutsMatchingModifierMask(NSEvent.ModifierFlags.command.rawValue).map { $0.commandId }, + ["palette.beta"], + "The ⌘ bucket holds only the ⌘ binding" + ) + XCTAssertEqual( + store.commandShortcutsMatchingModifierMask( + NSEvent.ModifierFlags([.command, .control, .option]).rawValue + ).map { $0.commandId }, + ["palette.alpha"], + "The ⌘⌃⌥ bucket holds only the ⌘⌃⌥ binding" + ) + } + + func testCommandShortcutDispatchIndexDropsUnboundAndCapsConfigSize() { + var commands: [String: StoredShortcut] = [:] + // Far more than the cap, all sharing one modifier combination — the + // pathological hand-edited cmux.json the cap defends dispatch against. + for index in 0..<1200 { + commands["palette.cmd.\(index)"] = StoredShortcut( + key: "y", + command: true, + shift: false, + option: false, + control: false, + keyCode: UInt16(index) + ) + } + commands["palette.unbound"] = StoredShortcut( + key: "", + command: false, + shift: false, + option: false, + control: false + ) + + let index = CmuxSettingsFileStore.makeCommandShortcutDispatchIndex(commands, limit: 1000) + + XCTAssertEqual(index.ordered.count, 1000, "The dispatch list is capped regardless of config size") + XCTAssertFalse(index.ordered.contains { $0.shortcut.isUnbound }, "Unbound entries are dropped") + XCTAssertNil(index.byModifierMask[0], "No command shortcut lands in the unmodified bucket") + XCTAssertEqual( + index.byModifierMask[NSEvent.ModifierFlags.command.rawValue]?.count, + 1000, + "Every surviving binding stays in its modifier bucket, capped" + ) + } + + private func writeSettingsFile(_ json: String) throws { + let url = settingsDirectoryURL.appendingPathComponent("cmux.json") + try json.write(to: url, atomically: true, encoding: .utf8) + KeyboardShortcutSettings.settingsFileStore.reload() + } + private func withCommandPaletteFieldEditor( appDelegate: AppDelegate, _ body: (NSWindow, CommandPaletteShortcutFieldEditor) -> Void diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index cea4b5a67860..97fda634c044 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -498,6 +498,24 @@ working-directory = ~/code`} "selectWorkspaceByNumber": "!sidebarFocus", "selectSurfaceByNumber": "sidebarMode == 'find' && paneCount > 1" } +}`} + + + + shortcuts.commands + +

+ {t.rich("shortcutsCommandsIntro", { + code: (chunks) => {chunks}, + settings: (chunks) => {chunks}, + })} +

+
+        {`"shortcuts": {
+  "commands": {
+    "palette.openFolderInVSCodeInline": "cmd+shift+o",
+    "palette.newWindow": "cmd+ctrl+n"
+  }
 }`}
       
diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index ea865ee63272..bd0081644acc 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -55,6 +55,18 @@ export default function KeyboardShortcutsPage() {
  • {t("chordsRuleSyntax")}
  • + {t("customCommandsTitle")} +

    + {t.rich("customCommandsIntro", { + settings: (chunks) => {chunks}, + settingsFile: (chunks) => {chunks}, + configurationLink: (chunks) => ( + {chunks} + ), + })} +

    + {t("customCommandsCallout")} + ); diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 145b8c003027..51015ca224ec 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -63,7 +63,12 @@ "items": { "type": "object", "additionalProperties": true, - "required": ["id", "name", "sessionIdSource", "resumeCommand"], + "required": [ + "id", + "name", + "sessionIdSource", + "resumeCommand" + ], "properties": { "id": { "type": "string", @@ -89,10 +94,14 @@ }, "argvContains": { "oneOf": [ - { "type": "string" }, + { + "type": "string" + }, { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } ], "description": "Substring or substrings that must appear in the process argv." @@ -108,11 +117,16 @@ { "type": "object", "additionalProperties": false, - "required": ["type"], + "required": [ + "type" + ], "properties": { "type": { "type": "string", - "enum": ["argvOption", "piSessionFile"] + "enum": [ + "argvOption", + "piSessionFile" + ] }, "argvOption": { "type": "string", @@ -123,19 +137,31 @@ { "if": { "properties": { - "type": { "const": "argvOption" } + "type": { + "const": "argvOption" + } } }, - "then": { "required": ["argvOption"] } + "then": { + "required": [ + "argvOption" + ] + } }, { "if": { "properties": { - "type": { "const": "piSessionFile" } + "type": { + "const": "piSessionFile" + } } }, "then": { - "not": { "required": ["argvOption"] } + "not": { + "required": [ + "argvOption" + ] + } } } ] @@ -146,22 +172,33 @@ "resumeCommand": { "type": "string", "anyOf": [ - { "pattern": "\\{\\{sessionId\\}\\}" }, - { "pattern": "\\{\\{sessionPath\\}\\}" } + { + "pattern": "\\{\\{sessionId\\}\\}" + }, + { + "pattern": "\\{\\{sessionPath\\}\\}" + } ], "description": "Shell-like argv template used to resume a session. Supported placeholders: {{sessionId}}, {{sessionPath}}, {{executable}}, {{cwd}}, {{sessionDir}}. Must include {{sessionId}} or {{sessionPath}}." }, "forkCommand": { "type": "string", "anyOf": [ - { "pattern": "\\{\\{sessionId\\}\\}" }, - { "pattern": "\\{\\{sessionPath\\}\\}" } + { + "pattern": "\\{\\{sessionId\\}\\}" + }, + { + "pattern": "\\{\\{sessionPath\\}\\}" + } ], "description": "Optional shell-like argv template used to fork (branch) a session into a new copy, for example \"{{executable}} --session {{sessionId}} --fork\". Same placeholders as resumeCommand. Omit when the agent has no fork capability; Fork Conversation stays hidden for it." }, "cwd": { "type": "string", - "enum": ["preserve", "ignore"], + "enum": [ + "preserve", + "ignore" + ], "default": "preserve", "description": "Whether Vault should cd to the saved working directory before running resumeCommand." }, @@ -187,7 +224,11 @@ "properties": { "newWorkspacePlacement": { "type": "string", - "enum": ["afterCurrent", "top", "end"], + "enum": [ + "afterCurrent", + "top", + "end" + ], "default": "afterCurrent", "description": "Global default for where Cmd-N inside a group, the group header + button, and configured group actions place the new workspace: `afterCurrent` (after the active in-group workspace, falling back to top), `top` (second slot, just after the anchor), or `end` (after the last member).", "descriptionKey": "schemaDescriptions.workspaceGroups.newWorkspacePlacement" @@ -212,14 +253,23 @@ "description": "Items appearing in the right-click menu on the group's + button. Same schema as the global ui.newWorkspace.contextMenu entries.", "items": { "oneOf": [ - { "type": "string" }, - { "type": "object", "additionalProperties": true } + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": true + } ] } }, "newWorkspacePlacement": { "type": "string", - "enum": ["afterCurrent", "top", "end"], + "enum": [ + "afterCurrent", + "top", + "end" + ], "description": "Per-cwd override for where Cmd-N inside the group, the group header + button, and configured group actions place the new workspace: `afterCurrent` (after the active in-group workspace, falling back to top), `top` (second slot, just after the anchor), or `end` (after the last member). Falls back to the global default when omitted.", "descriptionKey": "schemaDescriptions.workspaceGroups.byCwd.newWorkspacePlacement" } @@ -234,7 +284,9 @@ "type": "array", "items": { "oneOf": [ - { "type": "string" }, + { + "type": "string" + }, { "type": "object", "additionalProperties": true @@ -276,13 +328,21 @@ }, "appearance": { "type": "string", - "enum": ["system", "light", "dark"], + "enum": [ + "system", + "light", + "dark" + ], "default": "system", "description": "App appearance mode." }, "appIcon": { "type": "string", - "enum": ["automatic", "light", "dark"], + "enum": [ + "automatic", + "light", + "dark" + ], "default": "automatic", "description": "Dock and app switcher icon style." }, @@ -299,13 +359,24 @@ }, "newWorkspacePlacement": { "type": "string", - "enum": ["top", "afterCurrent", "end"], + "enum": [ + "top", + "afterCurrent", + "end" + ], "default": "afterCurrent", "description": "Where new workspaces are inserted in the sidebar." }, "forkConversationDefaultDestination": { "type": "string", - "enum": ["right", "left", "top", "bottom", "newTab", "newWorkspace"], + "enum": [ + "right", + "left", + "top", + "bottom", + "newTab", + "newWorkspace" + ], "default": "right", "descriptionKey": "schemaDescriptions.app.forkConversationDefaultDestination", "description": "Default destination for the tab context menu's primary Fork Conversation action. The submenu still exposes every destination." @@ -371,7 +442,11 @@ }, "confirmQuit": { "type": "string", - "enum": ["always", "dirty-only", "never"], + "enum": [ + "always", + "dirty-only", + "never" + ], "default": "always", "descriptionKey": "schemaDescriptions.app.confirmQuit", "description": "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." @@ -615,7 +690,16 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["version", "id", "commandPrefix", "environmentKeys", "policy", "createdAt", "updatedAt", "signature"], + "required": [ + "version", + "id", + "commandPrefix", + "environmentKeys", + "policy", + "createdAt", + "updatedAt", + "signature" + ], "properties": { "version": { "type": "integer", @@ -658,7 +742,11 @@ }, "policy": { "type": "string", - "enum": ["manual", "prompt", "auto"] + "enum": [ + "manual", + "prompt", + "auto" + ] }, "createdAt": { "type": "number" @@ -765,7 +853,10 @@ }, "hooksMode": { "type": "string", - "enum": ["append", "replace"], + "enum": [ + "append", + "replace" + ], "default": "append", "description": "Controls whether project-local notification hooks append to inherited hooks or replace them." }, @@ -776,7 +867,10 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["id", "command"], + "required": [ + "id", + "command" + ], "properties": { "id": { "type": "string", @@ -828,7 +922,10 @@ }, "branchLayout": { "type": "string", - "enum": ["vertical", "inline"], + "enum": [ + "vertical", + "inline" + ], "default": "vertical", "description": "Show git branch details stacked vertically or inline." }, @@ -1132,7 +1229,25 @@ "properties": { "defaultSearchEngine": { "type": "string", - "enum": ["google", "duckduckgo", "bing", "kagi", "startpage", "brave", "perplexity", "exa", "yahoo", "ecosia", "qwant", "mojeek", "wikipedia", "github", "baidu", "yandex", "custom"], + "enum": [ + "google", + "duckduckgo", + "bing", + "kagi", + "startpage", + "brave", + "perplexity", + "exa", + "yahoo", + "ecosia", + "qwant", + "mojeek", + "wikipedia", + "github", + "baidu", + "yandex", + "custom" + ], "default": "google", "descriptionKey": "schemaDescriptions.browser.defaultSearchEngine", "description": "Default search engine for non-URL browser address bar queries. Use custom with customSearchEngineURLTemplate for arbitrary providers." @@ -1156,7 +1271,11 @@ }, "theme": { "type": "string", - "enum": ["system", "light", "dark"], + "enum": [ + "system", + "light", + "dark" + ], "default": "system", "description": "Embedded browser theme." }, @@ -1306,7 +1425,11 @@ "properties": { "doubleClickAction": { "type": "string", - "enum": ["preview", "defaultEditor", "preferredEditor"], + "enum": [ + "preview", + "defaultEditor", + "preferredEditor" + ], "default": "preview", "description": "What double-clicking (or pressing Return on a search result for) a file in the file explorer does. `preview` opens the built-in cmux file preview (the default and historical behavior). `defaultEditor` opens with the macOS default app for the file type. `preferredEditor` opens with the `app.preferredEditor` command, falling back to the default app when none is set. Only applies to files; directories always expand/collapse, and non-local (remote) file explorers always open the cmux preview.", "descriptionKey": "schemaDescriptions.fileExplorer.doubleClickAction" @@ -1321,7 +1444,10 @@ "properties": { "defaultLayout": { "type": "string", - "enum": ["unified", "split"], + "enum": [ + "unified", + "split" + ], "default": "unified", "description": "Default layout for newly opened diff viewers. The toolbar layout toggle persists the user's last choice; an explicit `cmux diff --layout` option overrides both.", "descriptionKey": "schemaDescriptions.diffViewer.defaultLayout" @@ -1494,6 +1620,14 @@ "additionalProperties": { "type": "string" } + }, + "commands": { + "type": "object", + "default": {}, + "description": "Custom single-stroke keyboard shortcuts for Command Palette commands, keyed by command id (for example \"palette.openFolderInVSCodeInline\"). The shortcut fires the command on the focused window; a command unavailable in the current context does nothing. Single-stroke only — chords are not supported here. Use null, an empty string, none, clear, unbound, or disabled to clear a binding. Manage these in Settings > Keyboard Shortcuts > Custom Commands.", + "additionalProperties": { + "$ref": "#/$defs/singleShortcutBindingNullable" + } } } } @@ -1551,6 +1685,100 @@ } ] }, + "singleShortcutBindingNullable": { + "oneOf": [ + { + "$ref": "#/$defs/unboundShortcutBinding", + "description": "Unbind this command shortcut. Accepted values are an empty string, none, clear, unbound, or disabled." + }, + { + "$ref": "#/$defs/commandShortcutFirstStroke", + "description": "Single-stroke shortcut, for example cmd+shift+v. Command shortcuts are single-stroke only." + }, + { + "$ref": "#/$defs/singleStrokeObjectBinding", + "description": "Object form written by Settings > Keyboard Shortcuts > Custom Commands. Prefer the string form when hand-editing." + }, + { + "type": "null", + "description": "Unbind this command shortcut." + } + ] + }, + "singleStrokeObjectBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "first" + ], + "description": "A single-stroke shortcut in the nested object form the Settings recorder writes (no second/chord stroke).", + "properties": { + "first": { + "$ref": "#/$defs/shortcutStrokeObject" + } + } + }, + "shortcutStrokeObject": { + "type": "object", + "additionalProperties": false, + "required": [ + "key" + ], + "description": "One recorded keystroke as flat fields: the key plus modifier booleans and an optional macOS virtual key code. At least one primary modifier (command, option, or control) must be true; shift alone is not enough for a command shortcut.", + "properties": { + "key": { + "type": "string" + }, + "command": { + "type": "boolean" + }, + "shift": { + "type": "boolean" + }, + "option": { + "type": "boolean" + }, + "control": { + "type": "boolean" + }, + "keyCode": { + "type": "integer", + "minimum": 0 + } + }, + "anyOf": [ + { + "required": [ + "command" + ], + "properties": { + "command": { + "const": true + } + } + }, + { + "required": [ + "option" + ], + "properties": { + "option": { + "const": true + } + } + }, + { + "required": [ + "control" + ], + "properties": { + "control": { + "const": true + } + } + } + ] + }, "singleBareFirstStrokeShortcutBinding": { "oneOf": [ { @@ -1613,7 +1841,13 @@ }, "unboundShortcutBinding": { "type": "string", - "enum": ["", "none", "clear", "unbound", "disabled"] + "enum": [ + "", + "none", + "clear", + "unbound", + "disabled" + ] }, "shortcutFirstStroke": { "allOf": [ @@ -1664,6 +1898,20 @@ "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])$", "description": "One keyboard shortcut stroke using modifier+key syntax. Supported key names include space, Space, , and ." + }, + "commandShortcutFirstStroke": { + "allOf": [ + { + "$ref": "#/$defs/shortcutStroke" + }, + { + "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": "(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[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]|⌘|⌥|⌃)\\+" + } + ], + "description": "Single-stroke command shortcut string. Must include a primary modifier (cmd, opt, or ctrl); shift alone or a bare key (including Space) is not allowed." } } } diff --git a/web/messages/ar.json b/web/messages/ar.json index 49fd5ba4e416..63dcbf646701 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "مفاتيح منطقية. المفتاح غير المعروف أو الغائب يُقرأ على أنه false؛ وتُقبل أيضًا القيمتان الحرفيتان true و false.", "shortcutsWhenTypedKeys": "مفاتيح مصنّفة للمقارنات: الوضع النشط للشريط الجانبي الأيمن (سلسلة نصية) وعددا الألواح ومساحات العمل (أعداد صحيحة).", "shortcutsWhenOperators": "عوامل منطقية ومقارنات مصنّفة ومطابقة بتعبير نمطي وعضوية قائمة. ترتبط المقارنات بشكل أوثق من && و ||.", - "shortcutsWhenExample": "على سبيل المثال، يجعل هذا Ctrl+1–9 يحدد مساحات العمل في كل مكان إلا عندما يكون التركيز على الشريط الجانبي الأيمن (تاركًا Ctrl+1–5 لمبدّل أوضاع الشريط الجانبي)، ويقصر اختيار السطح على الشريط الجانبي للبحث عندما تحتوي مساحة العمل على عدة ألواح:" + "shortcutsWhenExample": "على سبيل المثال، يجعل هذا Ctrl+1–9 يحدد مساحات العمل في كل مكان إلا عندما يكون التركيز على الشريط الجانبي الأيمن (تاركًا Ctrl+1–5 لمبدّل أوضاع الشريط الجانبي)، ويقصر اختيار السطح على الشريط الجانبي للبحث عندما تحتوي مساحة العمل على عدة ألواح:", + "shortcutsCommandsIntro": "عيّن اختصار لوحة المفاتيح الخاص بك لأي أمر في لوحة الأوامر — بما في ذلك الأوامر التي لا تأتي باختصار مدمج، مثل فتح الدليل الحالي في المحرر. المفاتيح هي معرّفات الأوامر (نفس المعرّفات الظاهرة في اللوحة، على سبيل المثال palette.openFolderInVSCodeInline)؛ والقيم هي اختصار بضغطة واحدة. لا تُدعم التركيبات المتسلسلة هنا — استخدم ضغطة مفتاح واحدة تتضمن مُعدِّلًا أساسيًا (⌘ أو ⌥ أو ⌃). يُشغّل الاختصار الأمر في النافذة المركّز عليها؛ والأمر غير المتاح في السياق الحالي لا يفعل شيئًا. استخدم null (أو \"\" أو none أو clear أو unbound أو disabled) لمسح الربط. أسهل طريقة لإدارتها هي الإعدادات → اختصارات لوحة المفاتيح → الأوامر المخصصة، التي تبحث عن الأوامر بشكل تقريبي وتمنع أي ضغطة تنتمي بالفعل إلى إجراء مدمج أو أمر آخر." }, "customCommands": { "title": "أوامر مخصصة", @@ -1032,7 +1033,10 @@ "wn-reload": "إعادة تحميل الإعدادات", "wn-quit": "إنهاء" }, - "metaTitle": "اختصارات لوحة المفاتيح" + "metaTitle": "اختصارات لوحة المفاتيح", + "customCommandsTitle": "اختصارات الأوامر المخصصة", + "customCommandsIntro": "اربط ضغطة واحدة بأي أمر في لوحة الأوامر من الإعدادات → اختصارات لوحة المفاتيح → الأوامر المخصصة، أو مباشرةً ضمن shortcuts.commands في cmux.json. راجع وثائق الإعداد لمعرفة تنسيق الملف.", + "customCommandsCallout": "يُحظر بشريط تسجيل ضغطة مستخدمة بالفعل من قِبل إجراء مدمج أو أمر آخر، بحيث لا يمكن للاختصار المخصص أن يتجاوز إجراءً مدمجًا بصمت أو يتعارض مع أمر آخر. اختصارات الأوامر المخصصة بضغطة واحدة فقط." }, "api": { "title": "مرجع CLI", diff --git a/web/messages/bs.json b/web/messages/bs.json index 42854cfbceb0..fedf9bac058b 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "Booleovi ključevi. Nepoznat ili odsutan ključ čita se kao false; prihvataju se i literali true i false.", "shortcutsWhenTypedKeys": "tipizirani ključevi za poređenja: aktivni način desne bočne trake (string) te broj okana i radnih prostora (cijeli brojevi).", "shortcutsWhenOperators": "Booleovi operatori, tipizirana poređenja, regex podudaranje i pripadnost listi. Poređenja se vezuju jače od && i ||.", - "shortcutsWhenExample": "Na primjer, ovo čini da Ctrl+1–9 bira radne prostore svuda osim kada desna bočna traka ima fokus (ostavljajući Ctrl+1–5 prebacivaču načina bočne trake), i ograničava izbor površine na bočnu traku za pretragu kada radni prostor ima više okana:" + "shortcutsWhenExample": "Na primjer, ovo čini da Ctrl+1–9 bira radne prostore svuda osim kada desna bočna traka ima fokus (ostavljajući Ctrl+1–5 prebacivaču načina bočne trake), i ograničava izbor površine na bočnu traku za pretragu kada radni prostor ima više okana:", + "shortcutsCommandsIntro": "Dodijelite vlastiti tastaturni prečac bilo kojoj naredbi iz palete naredbi — uključujući naredbe koje dolaze bez ugrađenog prečaca, kao što je otvaranje trenutnog direktorija u vašem uređivaču. Ključevi su id-ovi naredbi (isti id-ovi prikazani u paleti, na primjer palette.openFolderInVSCodeInline); vrijednosti su prečac s jednim pritiskom. Akordi ovdje nisu podržani — koristite jedan pritisak tastera koji uključuje primarni modifikator (⌘, ⌥ ili ⌃). Prečac pokreće naredbu u fokusiranom prozoru; naredba nedostupna u trenutnom kontekstu ne radi ništa. Koristite null (ili \"\", none, clear, unbound, disabled) da obrišete vezu. Najlakši način za upravljanje njima je Postavke → Tastaturni prečaci → Prilagođene naredbe, koji nejasno pretražuje naredbe i blokira pritisak koji već pripada ugrađenoj akciji ili drugoj naredbi." }, "customCommands": { "title": "Prilagođene komande", @@ -1032,7 +1033,10 @@ "wn-reload": "Ponovo učitaj konfiguraciju", "wn-quit": "Izađi" }, - "metaTitle": "Prečice na tastaturi" + "metaTitle": "Prečice na tastaturi", + "customCommandsTitle": "Prilagođeni prečaci naredbi", + "customCommandsIntro": "Povežite jedan pritisak tastera s bilo kojom naredbom iz palete naredbi putem Postavke → Tastaturni prečaci → Prilagođene naredbe, ili direktno pod shortcuts.commands u cmux.json. Pogledajte dokumentaciju o konfiguraciji za format datoteke.", + "customCommandsCallout": "Snimanje pritiska koji već koristi ugrađena akcija ili druga naredba blokira se banerom, tako da prilagođeni prečac ne može tiho zasjeniti ugrađenu akciju niti se sukobiti s drugom naredbom. Prilagođeni prečaci naredbi imaju samo jedan pritisak." }, "api": { "title": "CLI Referenca", diff --git a/web/messages/da.json b/web/messages/da.json index 7099fcecd776..1783ed29f0eb 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "boolske nøgler. En ukendt eller manglende nøgle læses som false; litteralerne true og false accepteres også.", "shortcutsWhenTypedKeys": "typede nøgler til sammenligninger: den højre sidebjælkes aktive tilstand (streng) samt antal paneler og workspaces (heltal).", "shortcutsWhenOperators": "boolske operatorer, typede sammenligninger, regex-match og listemedlemskab. Sammenligninger binder tættere end && og ||.", - "shortcutsWhenExample": "Dette får for eksempel Ctrl+1–9 til at vælge workspaces overalt, undtagen når højre sidebjælke har fokus (så Ctrl+1–5 er fri til sidebjælkens tilstandsskifter), og begrænser overfladevalg til Find-sidebjælken, når workspacet har flere paneler:" + "shortcutsWhenExample": "Dette får for eksempel Ctrl+1–9 til at vælge workspaces overalt, undtagen når højre sidebjælke har fokus (så Ctrl+1–5 er fri til sidebjælkens tilstandsskifter), og begrænser overfladevalg til Find-sidebjælken, når workspacet har flere paneler:", + "shortcutsCommandsIntro": "Tildel din egen tastaturgenvej til enhver kommando i kommandopaletten — herunder kommandoer uden en indbygget genvej, såsom at åbne den aktuelle mappe i din editor. Nøgler er kommando-id'er (de samme id'er, der vises i paletten, for eksempel palette.openFolderInVSCodeInline); værdier er en genvej med ét tastetryk. Akkorder understøttes ikke her — brug et enkelt tastetryk, der inkluderer en primær modifikator (⌘, ⌥ eller ⌃). Genvejen udløser kommandoen i det fokuserede vindue; en kommando, der ikke er tilgængelig i den aktuelle kontekst, gør ingenting. Brug null (eller \"\", none, clear, unbound, disabled) til at rydde en binding. Den nemmeste måde at administrere dem på er Indstillinger → Tastaturgenveje → Brugerdefinerede kommandoer, som søger uskarpt efter kommandoer og blokerer et tastetryk, der allerede tilhører en indbygget handling eller en anden kommando." }, "customCommands": { "title": "Brugerdefinerede kommandoer", @@ -1032,7 +1033,10 @@ "wn-reload": "Genindlæs konfiguration", "wn-quit": "Afslut" }, - "metaTitle": "Tastaturgenveje" + "metaTitle": "Tastaturgenveje", + "customCommandsTitle": "Brugerdefinerede kommandogenveje", + "customCommandsIntro": "Bind et enkelt tastetryk til enhver kommando i kommandopaletten fra Indstillinger → Tastaturgenveje → Brugerdefinerede kommandoer, eller direkte under shortcuts.commands i cmux.json. Se konfigurationsdokumentationen for filformatet.", + "customCommandsCallout": "Optagelse af et tastetryk, der allerede bruges af en indbygget handling eller en anden kommando, blokeres med et banner, så en brugerdefineret genvej ikke i stilhed kan tilsidesætte en indbygget handling eller kollidere med en anden kommando. Brugerdefinerede kommandogenveje har kun ét tastetryk." }, "api": { "title": "CLI-reference", diff --git a/web/messages/de.json b/web/messages/de.json index 378ca2d07ec1..e133f59b1a00 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "boolesche Schlüssel. Ein unbekannter oder fehlender Schlüssel gilt als false; die Literale true und false werden ebenfalls akzeptiert.", "shortcutsWhenTypedKeys": "typisierte Schlüssel für Vergleiche: der aktive Modus der rechten Seitenleiste (Zeichenkette) sowie Pane- und Workspace-Anzahl (Ganzzahlen).", "shortcutsWhenOperators": "boolesche Operatoren, typisierte Vergleiche, Regex-Abgleich und Listenzugehörigkeit. Vergleiche binden stärker als && und ||.", - "shortcutsWhenExample": "Damit wählt Ctrl+1–9 zum Beispiel überall Workspaces aus, außer wenn die rechte Seitenleiste fokussiert ist (Ctrl+1–5 bleibt für den Modus-Umschalter der Seitenleiste frei), und die Surface-Auswahl ist auf die Suchen-Seitenleiste beschränkt, wenn der Workspace mehrere Panes hat:" + "shortcutsWhenExample": "Damit wählt Ctrl+1–9 zum Beispiel überall Workspaces aus, außer wenn die rechte Seitenleiste fokussiert ist (Ctrl+1–5 bleibt für den Modus-Umschalter der Seitenleiste frei), und die Surface-Auswahl ist auf die Suchen-Seitenleiste beschränkt, wenn der Workspace mehrere Panes hat:", + "shortcutsCommandsIntro": "Weisen Sie jedem Befehl der Befehlspalette ein eigenes Tastenkürzel zu — einschließlich Befehlen ohne eingebautes Kürzel, etwa das Öffnen des aktuellen Verzeichnisses im Editor. Schlüssel sind Befehls-IDs (dieselben IDs wie in der Palette, zum Beispiel palette.openFolderInVSCodeInline); Werte sind ein Kürzel mit einem Anschlag. Akkorde werden hier nicht unterstützt — verwenden Sie einen einzelnen Tastendruck mit einem primären Modifikator (⌘, ⌥ oder ⌃). Das Kürzel löst den Befehl im fokussierten Fenster aus; ein im aktuellen Kontext nicht verfügbarer Befehl bewirkt nichts. Verwenden Sie null (oder \"\", none, clear, unbound, disabled), um eine Bindung zu löschen. Am einfachsten verwalten Sie diese unter Einstellungen → Tastaturkurzbefehle → Benutzerdefinierte Befehle, wo Befehle unscharf gesucht werden und ein Tastendruck blockiert wird, der bereits zu einer eingebauten Aktion oder einem anderen Befehl gehört." }, "customCommands": { "title": "Benutzerdefinierte Befehle", @@ -1032,7 +1033,10 @@ "wn-reload": "Konfiguration neu laden", "wn-quit": "Beenden" }, - "metaTitle": "Tastenkürzel" + "metaTitle": "Tastenkürzel", + "customCommandsTitle": "Eigene Befehlskürzel", + "customCommandsIntro": "Weisen Sie über Einstellungen → Tastaturkurzbefehle → Benutzerdefinierte Befehle oder direkt unter shortcuts.commands in cmux.json einem beliebigen Befehl der Befehlspalette einen einzelnen Tastendruck zu. Das Dateiformat finden Sie in der Konfigurationsdokumentation.", + "customCommandsCallout": "Das Aufzeichnen eines Tastendrucks, der bereits von einer eingebauten Aktion oder einem anderen Befehl verwendet wird, wird mit einem Banner blockiert, sodass ein eigenes Kürzel keine eingebaute Aktion stillschweigend überschreiben oder mit einem anderen Befehl kollidieren kann. Eigene Befehlskürzel haben nur einen Anschlag." }, "api": { "title": "CLI-Referenz", diff --git a/web/messages/en.json b/web/messages/en.json index b19ef75f8723..8adf41c18f4b 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1162,7 +1162,8 @@ "shortcutsWhenBooleanKeys": "boolean keys. An unknown or absent key reads as false; the literals true and false are also accepted.", "shortcutsWhenTypedKeys": "typed keys for comparisons: the right sidebar's active mode (a string) plus pane and workspace counts (integers).", "shortcutsWhenOperators": "boolean operators, typed comparisons, regex match, and list membership. Comparisons bind tighter than && and ||.", - "shortcutsWhenExample": "For example, this makes Ctrl+1–9 select workspaces everywhere except when the right sidebar is focused (leaving Ctrl+1–5 free for the sidebar's mode switcher), and scopes surface selection to the Find sidebar when the workspace has multiple panes:" + "shortcutsWhenExample": "For example, this makes Ctrl+1–9 select workspaces everywhere except when the right sidebar is focused (leaving Ctrl+1–5 free for the sidebar's mode switcher), and scopes surface selection to the Find sidebar when the workspace has multiple panes:", + "shortcutsCommandsIntro": "Assign your own keyboard shortcut to any Command Palette command — including commands that ship without a built-in shortcut, such as opening the current directory in your editor. Keys are command ids (the same ids shown in the palette, for example palette.openFolderInVSCodeInline); values are a single-stroke shortcut. Chords are not supported here — use a single keystroke that includes a primary modifier (⌘, ⌥, or ⌃). The shortcut fires the command on the focused window; a command unavailable in the current context does nothing. Use null (or \"\", none, clear, unbound, disabled) to clear a binding. The easiest way to manage these is Settings → Keyboard Shortcuts → Custom Commands, which fuzzy-finds commands and blocks a keystroke that already belongs to a built-in action or another command." }, "customCommands": { "title": "Custom Commands", @@ -1276,6 +1277,9 @@ "chordsRuleSingle": "Use a plain string for a one-step shortcut.", "chordsRuleArray": "Use a two-item array for a chord. The first item is the prefix stroke, the second is the key that follows it.", "chordsRuleSyntax": "Each item uses the same syntax as regular bindings, for example cmd+b, ctrl+b, shift+/, or ctrl+1.", + "customCommandsTitle": "Custom command shortcuts", + "customCommandsIntro": "Bind a single keystroke to any Command Palette command from Settings → Keyboard Shortcuts → Custom Commands, or directly under shortcuts.commands in cmux.json. See the configuration docs for the file format.", + "customCommandsCallout": "Recording a keystroke already used by a built-in action or another command is blocked with a banner, so a custom shortcut can't silently shadow a built-in action or collide with another command. Custom command shortcuts are single-stroke only.", "searchPlaceholder": "Search shortcuts...", "searchLabel": "Search keyboard shortcuts", "noResults": "No shortcuts found", diff --git a/web/messages/es.json b/web/messages/es.json index 5dbcfba2e4c2..0a22637a1acd 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "claves booleanas. Una clave desconocida o ausente se evalúa como false; también se aceptan los literales true y false.", "shortcutsWhenTypedKeys": "claves tipadas para comparaciones: el modo activo de la barra lateral derecha (cadena) y los recuentos de paneles y espacios de trabajo (enteros).", "shortcutsWhenOperators": "operadores booleanos, comparaciones tipadas, coincidencia por expresión regular y pertenencia a listas. Las comparaciones ligan más fuerte que && y ||.", - "shortcutsWhenExample": "Por ejemplo, esto hace que Ctrl+1–9 seleccione espacios de trabajo en todas partes salvo cuando la barra lateral derecha tiene el foco (dejando Ctrl+1–5 libre para el selector de modo de la barra lateral), y limita la selección de superficie a la barra lateral de búsqueda cuando el espacio de trabajo tiene varios paneles:" + "shortcutsWhenExample": "Por ejemplo, esto hace que Ctrl+1–9 seleccione espacios de trabajo en todas partes salvo cuando la barra lateral derecha tiene el foco (dejando Ctrl+1–5 libre para el selector de modo de la barra lateral), y limita la selección de superficie a la barra lateral de búsqueda cuando el espacio de trabajo tiene varios paneles:", + "shortcutsCommandsIntro": "Asigne su propio atajo de teclado a cualquier comando de la paleta de comandos, incluidos los comandos que no traen un atajo integrado, como abrir el directorio actual en su editor. Las claves son id de comando (los mismos que se muestran en la paleta, por ejemplo palette.openFolderInVSCodeInline); los valores son un atajo de una sola pulsación. Aquí no se admiten acordes: use una sola pulsación que incluya un modificador principal (⌘, ⌥ o ⌃). El atajo ejecuta el comando en la ventana enfocada; un comando no disponible en el contexto actual no hace nada. Use null (o \"\", none, clear, unbound, disabled) para borrar una asignación. La forma más sencilla de gestionarlos es Ajustes → Atajos de teclado → Comandos personalizados, que busca comandos de forma aproximada y bloquea una pulsación que ya pertenece a una acción integrada o a otro comando." }, "customCommands": { "title": "Comandos personalizados", @@ -1032,7 +1033,10 @@ "wn-reload": "Recargar configuración", "wn-quit": "Salir" }, - "metaTitle": "Atajos de teclado" + "metaTitle": "Atajos de teclado", + "customCommandsTitle": "Atajos de comando personalizados", + "customCommandsIntro": "Asigne una sola pulsación a cualquier comando de la paleta de comandos desde Ajustes → Atajos de teclado → Comandos personalizados, o directamente en shortcuts.commands en cmux.json. Consulte la documentación de configuración para el formato del archivo.", + "customCommandsCallout": "Grabar una pulsación que ya usa una acción integrada u otro comando se bloquea con un aviso, de modo que un atajo personalizado no pueda ocultar silenciosamente una acción integrada ni chocar con otro comando. Los atajos de comando personalizados son de una sola pulsación." }, "api": { "title": "Referencia de CLI", diff --git a/web/messages/fr.json b/web/messages/fr.json index 8181f3a87644..283920dad875 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "clés booléennes. Une clé inconnue ou absente vaut false ; les littéraux true et false sont également acceptés.", "shortcutsWhenTypedKeys": "clés typées pour les comparaisons : le mode actif de la barre latérale droite (chaîne) ainsi que le nombre de panneaux et d'espaces de travail (entiers).", "shortcutsWhenOperators": "opérateurs booléens, comparaisons typées, correspondance par expression régulière et appartenance à une liste. Les comparaisons lient plus fort que && et ||.", - "shortcutsWhenExample": "Par exemple, ceci fait que Ctrl+1–9 sélectionne les espaces de travail partout sauf lorsque la barre latérale droite a le focus (laissant Ctrl+1–5 au sélecteur de mode de la barre latérale), et limite la sélection de surface à la barre latérale Rechercher lorsque l'espace de travail comporte plusieurs panneaux :" + "shortcutsWhenExample": "Par exemple, ceci fait que Ctrl+1–9 sélectionne les espaces de travail partout sauf lorsque la barre latérale droite a le focus (laissant Ctrl+1–5 au sélecteur de mode de la barre latérale), et limite la sélection de surface à la barre latérale Rechercher lorsque l'espace de travail comporte plusieurs panneaux :", + "shortcutsCommandsIntro": "Attribuez votre propre raccourci clavier à n'importe quelle commande de la palette de commandes — y compris les commandes sans raccourci intégré, comme ouvrir le répertoire courant dans votre éditeur. Les clés sont des ID de commande (les mêmes que ceux affichés dans la palette, par exemple palette.openFolderInVSCodeInline) ; les valeurs sont un raccourci à une frappe. Les accords ne sont pas pris en charge ici — utilisez une seule frappe incluant un modificateur principal (⌘, ⌥ ou ⌃). Le raccourci déclenche la commande dans la fenêtre active ; une commande indisponible dans le contexte actuel ne fait rien. Utilisez null (ou \"\", none, clear, unbound, disabled) pour effacer une liaison. Le plus simple est de les gérer dans Réglages → Raccourcis clavier → Commandes personnalisées, qui recherche les commandes en mode flou et bloque une frappe appartenant déjà à une action intégrée ou à une autre commande." }, "customCommands": { "title": "Commandes personnalisées", @@ -1032,7 +1033,10 @@ "wn-reload": "Recharger la configuration", "wn-quit": "Quitter" }, - "metaTitle": "Raccourcis clavier" + "metaTitle": "Raccourcis clavier", + "customCommandsTitle": "Raccourcis de commande personnalisés", + "customCommandsIntro": "Associez une seule frappe à n'importe quelle commande de la palette de commandes depuis Réglages → Raccourcis clavier → Commandes personnalisées, ou directement sous shortcuts.commands dans cmux.json. Consultez la documentation de configuration pour le format du fichier.", + "customCommandsCallout": "L'enregistrement d'une frappe déjà utilisée par une action intégrée ou une autre commande est bloqué par une bannière, afin qu'un raccourci personnalisé ne puisse pas masquer silencieusement une action intégrée ni entrer en conflit avec une autre commande. Les raccourcis de commande personnalisés sont à une seule frappe." }, "api": { "title": "Référence CLI", diff --git a/web/messages/it.json b/web/messages/it.json index bc8230ce127f..b22205023932 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "chiavi booleane. Una chiave sconosciuta o assente vale false; sono accettati anche i letterali true e false.", "shortcutsWhenTypedKeys": "chiavi tipizzate per i confronti: la modalità attiva della barra laterale destra (stringa) e i conteggi di riquadri e spazi di lavoro (interi).", "shortcutsWhenOperators": "operatori booleani, confronti tipizzati, corrispondenza regex e appartenenza a liste. I confronti legano più strettamente di && e ||.", - "shortcutsWhenExample": "Ad esempio, questo fa sì che Ctrl+1–9 selezioni gli spazi di lavoro ovunque tranne quando la barra laterale destra ha il focus (lasciando Ctrl+1–5 al selettore di modalità della barra laterale), e limita la selezione della superficie alla barra laterale Trova quando lo spazio di lavoro ha più riquadri:" + "shortcutsWhenExample": "Ad esempio, questo fa sì che Ctrl+1–9 selezioni gli spazi di lavoro ovunque tranne quando la barra laterale destra ha il focus (lasciando Ctrl+1–5 al selettore di modalità della barra laterale), e limita la selezione della superficie alla barra laterale Trova quando lo spazio di lavoro ha più riquadri:", + "shortcutsCommandsIntro": "Assegna una scorciatoia da tastiera a qualsiasi comando della palette dei comandi, inclusi i comandi privi di una scorciatoia integrata, come aprire la directory corrente nel tuo editor. Le chiavi sono id di comando (gli stessi mostrati nella palette, ad esempio palette.openFolderInVSCodeInline); i valori sono una scorciatoia a singola pressione. Gli accordi non sono supportati qui: usa una singola pressione che includa un modificatore principale (⌘, ⌥ o ⌃). La scorciatoia attiva il comando nella finestra attiva; un comando non disponibile nel contesto corrente non fa nulla. Usa null (o \"\", none, clear, unbound, disabled) per cancellare un'associazione. Il modo più semplice per gestirle è Impostazioni → Abbreviazioni da tastiera → Comandi personalizzati, che cerca i comandi in modo approssimativo e blocca una pressione che appartiene già a un'azione integrata o a un altro comando." }, "customCommands": { "title": "Comandi personalizzati", @@ -1032,7 +1033,10 @@ "wn-reload": "Ricarica configurazione", "wn-quit": "Esci" }, - "metaTitle": "Scorciatoie da tastiera" + "metaTitle": "Scorciatoie da tastiera", + "customCommandsTitle": "Scorciatoie di comando personalizzate", + "customCommandsIntro": "Associa una singola pressione a qualsiasi comando della palette dei comandi da Impostazioni → Abbreviazioni da tastiera → Comandi personalizzati, o direttamente sotto shortcuts.commands in cmux.json. Consulta la documentazione di configurazione per il formato del file.", + "customCommandsCallout": "La registrazione di una pressione già usata da un'azione integrata o da un altro comando viene bloccata con un banner, così una scorciatoia personalizzata non può oscurare silenziosamente un'azione integrata né entrare in conflitto con un altro comando. Le scorciatoie di comando personalizzate sono a singola pressione." }, "api": { "title": "Riferimento CLI", diff --git a/web/messages/ja.json b/web/messages/ja.json index 261185592d09..8c7c4135a6d8 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1132,7 +1132,8 @@ "shortcutsWhenBooleanKeys": "boolean キー。未知または存在しないキーは false として扱われ、リテラル true / false も使えます。", "shortcutsWhenTypedKeys": "比較用の型付きキー。右サイドバーのアクティブモード(文字列)と、ペイン数・ワークスペース数(整数)です。", "shortcutsWhenOperators": "boolean 演算子、型付き比較、正規表現マッチ、リスト所属。比較は && や || より強く結合します。", - "shortcutsWhenExample": "例えば次の設定では、右サイドバーにフォーカスがある場合を除いて Ctrl+1–9 でワークスペースを選択し(Ctrl+1–5 はサイドバーのモード切り替えに残します)、ワークスペースに複数のペインがあるときはサーフェス選択を Find サイドバーに限定します。" + "shortcutsWhenExample": "例えば次の設定では、右サイドバーにフォーカスがある場合を除いて Ctrl+1–9 でワークスペースを選択し(Ctrl+1–5 はサイドバーのモード切り替えに残します)、ワークスペースに複数のペインがあるときはサーフェス選択を Find サイドバーに限定します。", + "shortcutsCommandsIntro": "任意のコマンドパレットコマンドに、自分のキーボードショートカットを割り当てられます。組み込みショートカットを持たないコマンド(現在のディレクトリをエディタで開くなど)も対象です。キーはコマンド id(パレットに表示されるものと同じ id、例: palette.openFolderInVSCodeInline)、値は単一ストロークのショートカットです。ここではコードは使用できません。プライマリ修飾キー(⌘、⌥、⌃)を含む単一のキーストロークを使用してください。ショートカットはフォーカス中のウィンドウでコマンドを実行し、現在のコンテキストで利用できないコマンドは何も行いません。バインドを解除するには null(または \"\"、none、clear、unbound、disabled)を使用します。最も簡単な管理方法は 設定 → キーボードショートカット → カスタムコマンド で、コマンドをあいまい検索し、組み込みアクションや他のコマンドですでに使用されているキーストロークをブロックします。" }, "customCommands": { "title": "カスタムコマンド", @@ -1244,6 +1245,9 @@ "chordsRuleSingle": "1回のショートカットは文字列で指定します。", "chordsRuleArray": "コードは2要素の配列で指定します。1つ目がプレフィックス、2つ目が続けて押すキーです。", "chordsRuleSyntax": "各要素は通常のショートカットと同じ構文で、例: cmd+b、ctrl+b、shift+/、ctrl+1。", + "customCommandsTitle": "カスタムコマンドショートカット", + "customCommandsIntro": "設定 → キーボードショートカット → カスタムコマンドから、または cmux.json の shortcuts.commands に直接、任意のコマンドパレットコマンドに単一のキーストロークを割り当てられます。ファイル形式については設定ドキュメントを参照してください。", + "customCommandsCallout": "組み込みアクションや他のコマンドですでに使用されているキーストロークを記録しようとするとバナーでブロックされるため、カスタムショートカットが組み込みアクションを暗黙的に上書きしたり、他のコマンドと競合したりすることはありません。カスタムコマンドショートカットは単一ストロークのみです。", "metaDescription": "cmuxのmacOS向けデフォルトキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、アプリ操作を収録し、すべて設定画面または cmux.json で変更できます。", "searchPlaceholder": "ショートカットを検索...", "searchLabel": "キーボードショートカットを検索", diff --git a/web/messages/km.json b/web/messages/km.json index d9fb77e7d6b6..7f7bf1325ce1 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "គន្លឹះ boolean។ គន្លឹះមិនស្គាល់ ឬមិនមាននឹងត្រូវអានជា false ហើយក៏អាចប្រើ true និង false ផងដែរ។", "shortcutsWhenTypedKeys": "គន្លឹះមានប្រភេទសម្រាប់ការប្រៀបធៀប៖ មុខងារសកម្មរបស់របារចំហៀងស្តាំ (ខ្សែអក្សរ) និងចំនួនផ្ទាំង និង workspace (ចំនួនគត់)។", "shortcutsWhenOperators": "សញ្ញាប្រមាណវិធី boolean ការប្រៀបធៀបតាមប្រភេទ ការផ្គូផ្គង regex និងសមាជិកភាពក្នុងបញ្ជី។ ការប្រៀបធៀបចងតឹងជាង && និង ||។", - "shortcutsWhenExample": "ឧទាហរណ៍ ការកំណត់នេះធ្វើឱ្យ Ctrl+1–9 ជ្រើសរើស workspace នៅគ្រប់ទីកន្លែង លើកលែងពេលរបារចំហៀងស្តាំមាន focus (ទុក Ctrl+1–5 សម្រាប់កុងតាក់មុខងាររបារចំហៀង) ហើយកំណត់ការជ្រើសរើសផ្ទៃទៅរបារចំហៀង Find ពេល workspace មានផ្ទាំងច្រើន៖" + "shortcutsWhenExample": "ឧទាហរណ៍ ការកំណត់នេះធ្វើឱ្យ Ctrl+1–9 ជ្រើសរើស workspace នៅគ្រប់ទីកន្លែង លើកលែងពេលរបារចំហៀងស្តាំមាន focus (ទុក Ctrl+1–5 សម្រាប់កុងតាក់មុខងាររបារចំហៀង) ហើយកំណត់ការជ្រើសរើសផ្ទៃទៅរបារចំហៀង Find ពេល workspace មានផ្ទាំងច្រើន៖", + "shortcutsCommandsIntro": "កំណត់ផ្លូវកាត់ក្តារចុចផ្ទាល់ខ្លួនរបស់អ្នកទៅពាក្យបញ្ជាណាមួយក្នុងផ្ទាំងពាក្យបញ្ជា — រួមទាំងពាក្យបញ្ជាដែលគ្មានផ្លូវកាត់ដែលភ្ជាប់មកស្រាប់ ដូចជាការបើកថតបច្ចុប្បន្នក្នុងកម្មវិធីកែសម្រួលរបស់អ្នក។ កូនសោគឺជាលេខសម្គាល់ពាក្យបញ្ជា (លេខសម្គាល់ដូចគ្នានឹងក្នុងផ្ទាំង ឧទាហរណ៍ palette.openFolderInVSCodeInline); តម្លៃគឺជាផ្លូវកាត់ចុចតែម្តង។ មិនគាំទ្រ chord នៅទីនេះទេ — សូមប្រើការចុចគ្រាប់ចុចតែម្តងដែលមានគ្រាប់ចុចកែប្រែចម្បង (⌘, ⌥ ឬ ⌃)។ ផ្លូវកាត់នេះដំណើរការពាក្យបញ្ជានៅលើបង្អួចដែលកំពុងផ្តោត; ពាក្យបញ្ជាដែលមិនអាចប្រើបានក្នុងបរិបទបច្ចុប្បន្ននឹងមិនធ្វើអ្វីទេ។ ប្រើ null (ឬ \"\", none, clear, unbound, disabled) ដើម្បីលុបការចង។ វិធីគ្រប់គ្រងងាយស្រួលបំផុតគឺ ការកំណត់ → ផ្លូវកាត់ក្តារចុច → ពាក្យបញ្ជាផ្ទាល់ខ្លួន ដែលស្វែងរកពាក្យបញ្ជាបែប fuzzy និងទប់ស្កាត់ការចុចគ្រាប់ចុចដែលជារបស់សកម្មភាពភ្ជាប់មកស្រាប់ ឬពាក្យបញ្ជាផ្សេងទៀតរួចហើយ។" }, "customCommands": { "title": "ពាក្យបញ្ជាផ្ទាល់ខ្លួន", @@ -1032,7 +1033,10 @@ "wn-reload": "ផ្ទុកកំណត់រចនាសម្ព័ន្ធឡើងវិញ", "wn-quit": "ចាកចេញ" }, - "metaTitle": "ផ្លូវកាត់ក្តារចុច" + "metaTitle": "ផ្លូវកាត់ក្តារចុច", + "customCommandsTitle": "ផ្លូវកាត់ពាក្យបញ្ជាផ្ទាល់ខ្លួន", + "customCommandsIntro": "ចងការចុចតែម្តងទៅពាក្យបញ្ជាណាមួយក្នុងផ្ទាំងពាក្យបញ្ជា ពី ការកំណត់ → ផ្លូវកាត់ក្តារចុច → ពាក្យបញ្ជាផ្ទាល់ខ្លួន ឬដោយផ្ទាល់ក្រោម shortcuts.commands ក្នុង cmux.json។ សូមមើល ឯកសារកំណត់រចនាសម្ព័ន្ធ សម្រាប់ទម្រង់ឯកសារ។", + "customCommandsCallout": "ការថតការចុចគ្រាប់ចុចដែលត្រូវបានប្រើរួចហើយដោយសកម្មភាពភ្ជាប់មកស្រាប់ ឬពាក្យបញ្ជាផ្សេងទៀត ត្រូវបានទប់ស្កាត់ដោយផ្ទាំងបដា ដូច្នេះផ្លូវកាត់ផ្ទាល់ខ្លួនមិនអាចបិទបាំងសកម្មភាពភ្ជាប់មកស្រាប់ដោយស្ងាត់ ឬប៉ះទង្គិចជាមួយពាក្យបញ្ជាផ្សេងបានទេ។ ផ្លូវកាត់ពាក្យបញ្ជាផ្ទាល់ខ្លួនមានតែការចុចតែម្តងប៉ុណ្ណោះ។" }, "api": { "title": "ឯកសារយោង CLI", diff --git a/web/messages/ko.json b/web/messages/ko.json index ea3ed14f8744..a360eebb6c69 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "불리언 키. 알 수 없거나 존재하지 않는 키는 false로 평가되며, 리터럴 true와 false도 사용할 수 있습니다.", "shortcutsWhenTypedKeys": "비교용 타입 키: 오른쪽 사이드바의 활성 모드(문자열)와 패널 및 워크스페이스 개수(정수)입니다.", "shortcutsWhenOperators": "불리언 연산자, 타입 비교, 정규식 일치, 리스트 포함 여부. 비교는 &&와 ||보다 더 강하게 결합합니다.", - "shortcutsWhenExample": "예를 들어 다음 설정은 오른쪽 사이드바에 포커스가 있을 때를 제외한 모든 곳에서 Ctrl+1–9로 워크스페이스를 선택하게 하고(Ctrl+1–5는 사이드바 모드 전환기에 남겨 둠), 워크스페이스에 패널이 여러 개 있을 때 서피스 선택을 찾기 사이드바로 한정합니다:" + "shortcutsWhenExample": "예를 들어 다음 설정은 오른쪽 사이드바에 포커스가 있을 때를 제외한 모든 곳에서 Ctrl+1–9로 워크스페이스를 선택하게 하고(Ctrl+1–5는 사이드바 모드 전환기에 남겨 둠), 워크스페이스에 패널이 여러 개 있을 때 서피스 선택을 찾기 사이드바로 한정합니다:", + "shortcutsCommandsIntro": "명령 팔레트의 모든 명령에 직접 키보드 단축키를 지정할 수 있습니다. 편집기에서 현재 디렉터리 열기처럼 기본 단축키가 없는 명령도 포함됩니다. 키는 명령 id(팔레트에 표시되는 것과 동일한 id, 예: palette.openFolderInVSCodeInline)이고, 값은 단일 입력 단축키입니다. 여기서는 코드(연속 입력)를 지원하지 않습니다. 기본 보조키(⌘, ⌥ 또는 ⌃)를 포함하는 단일 키 입력을 사용하세요. 단축키는 포커스된 창에서 명령을 실행하며, 현재 컨텍스트에서 사용할 수 없는 명령은 아무 동작도 하지 않습니다. 바인딩을 지우려면 null(또는 \"\", none, clear, unbound, disabled)을 사용하세요. 가장 쉬운 관리 방법은 설정 → 키보드 단축키 → 사용자 지정 명령이며, 명령을 퍼지 검색하고 이미 기본 동작이나 다른 명령에 속한 입력을 차단합니다." }, "customCommands": { "title": "사용자 정의 명령어", @@ -1032,7 +1033,10 @@ "wn-reload": "설정 새로고침", "wn-quit": "종료" }, - "metaTitle": "키보드 단축키" + "metaTitle": "키보드 단축키", + "customCommandsTitle": "사용자 지정 명령 단축키", + "customCommandsIntro": "설정 → 키보드 단축키 → 사용자 지정 명령에서, 또는 cmux.json의 shortcuts.commands 아래에 직접, 명령 팔레트의 모든 명령에 단일 키 입력을 바인딩하세요. 파일 형식은 구성 문서를 참고하세요.", + "customCommandsCallout": "기본 동작이나 다른 명령에서 이미 사용 중인 키 입력을 기록하려고 하면 배너로 차단되므로, 사용자 지정 단축키가 기본 동작을 조용히 가리거나 다른 명령과 충돌하지 않습니다. 사용자 지정 명령 단축키는 단일 입력만 가능합니다." }, "api": { "title": "CLI 레퍼런스", diff --git a/web/messages/no.json b/web/messages/no.json index d9bd759aece1..a85b1f5cae06 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "boolske nøkler. En ukjent eller fraværende nøkkel leses som false; literalene true og false godtas også.", "shortcutsWhenTypedKeys": "typede nøkler for sammenligninger: høyre sidelinjes aktive modus (streng) samt antall paneler og arbeidsområder (heltall).", "shortcutsWhenOperators": "boolske operatorer, typede sammenligninger, regex-treff og listemedlemskap. Sammenligninger binder tettere enn && og ||.", - "shortcutsWhenExample": "Dette gjør for eksempel at Ctrl+1–9 velger arbeidsområder overalt unntatt når høyre sidelinje har fokus (slik at Ctrl+1–5 er ledig for sidelinjens modusvelger), og begrenser overflatevalg til Finn-sidelinjen når arbeidsområdet har flere paneler:" + "shortcutsWhenExample": "Dette gjør for eksempel at Ctrl+1–9 velger arbeidsområder overalt unntatt når høyre sidelinje har fokus (slik at Ctrl+1–5 er ledig for sidelinjens modusvelger), og begrenser overflatevalg til Finn-sidelinjen når arbeidsområdet har flere paneler:", + "shortcutsCommandsIntro": "Tildel din egen hurtigtast til enhver kommando i kommandopaletten — inkludert kommandoer uten en innebygd hurtigtast, for eksempel å åpne gjeldende katalog i editoren din. Nøkler er kommando-ID-er (de samme ID-ene som vises i paletten, for eksempel palette.openFolderInVSCodeInline); verdier er en hurtigtast med ett tastetrykk. Akkorder støttes ikke her — bruk et enkelt tastetrykk som inkluderer en primær modifikator (⌘, ⌥ eller ⌃). Hurtigtasten utløser kommandoen i det fokuserte vinduet; en kommando som ikke er tilgjengelig i gjeldende kontekst, gjør ingenting. Bruk null (eller \"\", none, clear, unbound, disabled) for å fjerne en binding. Den enkleste måten å administrere dem på er Innstillinger → Hurtigtaster → Egendefinerte kommandoer, som søker uskarpt etter kommandoer og blokkerer et tastetrykk som allerede tilhører en innebygd handling eller en annen kommando." }, "customCommands": { "title": "Egendefinerte kommandoer", @@ -1032,7 +1033,10 @@ "wn-reload": "Last inn konfigurasjon på nytt", "wn-quit": "Avslutt" }, - "metaTitle": "Tastatursnarveier" + "metaTitle": "Tastatursnarveier", + "customCommandsTitle": "Egendefinerte kommandosnarveier", + "customCommandsIntro": "Bind et enkelt tastetrykk til enhver kommando i kommandopaletten fra Innstillinger → Hurtigtaster → Egendefinerte kommandoer, eller direkte under shortcuts.commands i cmux.json. Se konfigurasjonsdokumentasjonen for filformatet.", + "customCommandsCallout": "Å registrere et tastetrykk som allerede brukes av en innebygd handling eller en annen kommando, blokkeres med et banner, slik at en egendefinert snarvei ikke stille kan overstyre en innebygd handling eller kollidere med en annen kommando. Egendefinerte kommandosnarveier har bare ett tastetrykk." }, "api": { "title": "CLI-referanse", diff --git a/web/messages/pl.json b/web/messages/pl.json index cda279f25e64..9b58fb5c6b82 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "klucze logiczne. Nieznany lub nieobecny klucz jest traktowany jako false; akceptowane są też literały true i false.", "shortcutsWhenTypedKeys": "klucze typowane do porównań: aktywny tryb prawego paska bocznego (łańcuch) oraz liczby paneli i obszarów roboczych (liczby całkowite).", "shortcutsWhenOperators": "operatory logiczne, porównania typowane, dopasowanie wyrażeniem regularnym i przynależność do listy. Porównania wiążą mocniej niż && i ||.", - "shortcutsWhenExample": "Na przykład poniższe sprawia, że Ctrl+1–9 wybiera obszary robocze wszędzie poza sytuacją, gdy fokus ma prawy pasek boczny (pozostawiając Ctrl+1–5 przełącznikowi trybów paska bocznego), i ogranicza wybór powierzchni do paska bocznego wyszukiwania, gdy obszar roboczy ma wiele paneli:" + "shortcutsWhenExample": "Na przykład poniższe sprawia, że Ctrl+1–9 wybiera obszary robocze wszędzie poza sytuacją, gdy fokus ma prawy pasek boczny (pozostawiając Ctrl+1–5 przełącznikowi trybów paska bocznego), i ogranicza wybór powierzchni do paska bocznego wyszukiwania, gdy obszar roboczy ma wiele paneli:", + "shortcutsCommandsIntro": "Przypisz własny skrót klawiszowy do dowolnego polecenia palety poleceń — w tym poleceń bez wbudowanego skrótu, takich jak otwieranie bieżącego katalogu w edytorze. Klucze to identyfikatory poleceń (te same, które są wyświetlane w palecie, na przykład palette.openFolderInVSCodeInline); wartości to skrót z pojedynczym naciśnięciem. Akordy nie są tu obsługiwane — użyj pojedynczego naciśnięcia z głównym modyfikatorem (⌘, ⌥ lub ⌃). Skrót uruchamia polecenie w aktywnym oknie; polecenie niedostępne w bieżącym kontekście nic nie robi. Użyj null (lub \"\", none, clear, unbound, disabled), aby wyczyścić powiązanie. Najłatwiej zarządzać nimi w Ustawienia → Skróty klawiszowe → Polecenia niestandardowe, które wyszukuje polecenia w sposób rozmyty i blokuje naciśnięcie należące już do wbudowanej akcji lub innego polecenia." }, "customCommands": { "title": "Niestandardowe polecenia", @@ -1032,7 +1033,10 @@ "wn-reload": "Przeładuj konfigurację", "wn-quit": "Zakończ" }, - "metaTitle": "Skróty klawiszowe" + "metaTitle": "Skróty klawiszowe", + "customCommandsTitle": "Niestandardowe skróty poleceń", + "customCommandsIntro": "Przypisz pojedyncze naciśnięcie do dowolnego polecenia palety poleceń w Ustawienia → Skróty klawiszowe → Polecenia niestandardowe lub bezpośrednio w shortcuts.commands w cmux.json. Format pliku znajdziesz w dokumentacji konfiguracji.", + "customCommandsCallout": "Zarejestrowanie naciśnięcia używanego już przez wbudowaną akcję lub inne polecenie jest blokowane banerem, więc niestandardowy skrót nie może po cichu przesłonić wbudowanej akcji ani kolidować z innym poleceniem. Niestandardowe skróty poleceń mają pojedyncze naciśnięcie." }, "api": { "title": "Dokumentacja CLI", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 91aa528928f1..0cd6ad906972 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "chaves booleanas. Uma chave desconhecida ou ausente é avaliada como false; os literais true e false também são aceitos.", "shortcutsWhenTypedKeys": "chaves tipadas para comparações: o modo ativo da barra lateral direita (string) e as contagens de painéis e workspaces (inteiros).", "shortcutsWhenOperators": "operadores booleanos, comparações tipadas, correspondência por regex e pertinência a listas. Comparações ligam mais forte que && e ||.", - "shortcutsWhenExample": "Por exemplo, isto faz Ctrl+1–9 selecionar workspaces em todos os lugares, exceto quando a barra lateral direita está em foco (deixando Ctrl+1–5 para o seletor de modo da barra lateral), e restringe a seleção de superfície à barra lateral Localizar quando o workspace tem vários painéis:" + "shortcutsWhenExample": "Por exemplo, isto faz Ctrl+1–9 selecionar workspaces em todos os lugares, exceto quando a barra lateral direita está em foco (deixando Ctrl+1–5 para o seletor de modo da barra lateral), e restringe a seleção de superfície à barra lateral Localizar quando o workspace tem vários painéis:", + "shortcutsCommandsIntro": "Atribua seu próprio atalho de teclado a qualquer comando da paleta de comandos — incluindo comandos que vêm sem um atalho integrado, como abrir o diretório atual no seu editor. As chaves são ids de comando (os mesmos ids mostrados na paleta, por exemplo palette.openFolderInVSCodeInline); os valores são um atalho de um único toque. Acordes não são suportados aqui — use um único toque que inclua um modificador principal (⌘, ⌥ ou ⌃). O atalho aciona o comando na janela em foco; um comando indisponível no contexto atual não faz nada. Use null (ou \"\", none, clear, unbound, disabled) para limpar uma associação. A maneira mais fácil de gerenciá-los é Configurações → Atalhos de teclado → Comandos personalizados, que busca comandos de forma aproximada e bloqueia um toque que já pertence a uma ação integrada ou a outro comando." }, "customCommands": { "title": "Comandos personalizados", @@ -1032,7 +1033,10 @@ "wn-reload": "Recarregar configuração", "wn-quit": "Sair" }, - "metaTitle": "Atalhos de teclado" + "metaTitle": "Atalhos de teclado", + "customCommandsTitle": "Atalhos de comando personalizados", + "customCommandsIntro": "Associe um único toque a qualquer comando da paleta de comandos em Configurações → Atalhos de teclado → Comandos personalizados, ou diretamente em shortcuts.commands no cmux.json. Consulte a documentação de configuração para o formato do arquivo.", + "customCommandsCallout": "Gravar um toque já usado por uma ação integrada ou outro comando é bloqueado com um aviso, para que um atalho personalizado não possa ocultar silenciosamente uma ação integrada nem colidir com outro comando. Atalhos de comando personalizados são de um único toque." }, "api": { "title": "Referência da CLI", diff --git a/web/messages/ru.json b/web/messages/ru.json index 5e87385515cd..f23881b321f2 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "булевы ключи. Неизвестный или отсутствующий ключ считается false; также принимаются литералы true и false.", "shortcutsWhenTypedKeys": "типизированные ключи для сравнений: активный режим правой боковой панели (строка) и количество панелей и рабочих областей (целые числа).", "shortcutsWhenOperators": "булевы операторы, типизированные сравнения, сопоставление по регулярному выражению и принадлежность списку. Сравнения связывают сильнее, чем && и ||.", - "shortcutsWhenExample": "Например, эта настройка заставляет Ctrl+1–9 выбирать рабочие области везде, кроме случаев, когда в фокусе правая боковая панель (оставляя Ctrl+1–5 переключателю режимов боковой панели), и ограничивает выбор поверхности боковой панелью поиска, когда в рабочей области несколько панелей:" + "shortcutsWhenExample": "Например, эта настройка заставляет Ctrl+1–9 выбирать рабочие области везде, кроме случаев, когда в фокусе правая боковая панель (оставляя Ctrl+1–5 переключателю режимов боковой панели), и ограничивает выбор поверхности боковой панелью поиска, когда в рабочей области несколько панелей:", + "shortcutsCommandsIntro": "Назначьте собственное сочетание клавиш любой команде палитры команд — включая команды без встроенного сочетания, например открытие текущего каталога в редакторе. Ключи — это идентификаторы команд (те же, что показаны в палитре, например palette.openFolderInVSCodeInline); значения — сочетание из одного нажатия. Аккорды здесь не поддерживаются — используйте одно нажатие с основным модификатором (⌘, ⌥ или ⌃). Сочетание запускает команду в активном окне; недоступная в текущем контексте команда ничего не делает. Используйте null (или \"\", none, clear, unbound, disabled), чтобы очистить привязку. Проще всего управлять ими в Настройки → Сочетания клавиш → Пользовательские команды, где выполняется нечёткий поиск команд и блокируется нажатие, уже принадлежащее встроенному действию или другой команде." }, "customCommands": { "title": "Пользовательские команды", @@ -1032,7 +1033,10 @@ "wn-reload": "Перезагрузить конфигурацию", "wn-quit": "Выход" }, - "metaTitle": "Горячие клавиши" + "metaTitle": "Горячие клавиши", + "customCommandsTitle": "Пользовательские сочетания команд", + "customCommandsIntro": "Назначьте одно нажатие любой команде палитры команд через Настройки → Сочетания клавиш → Пользовательские команды или напрямую в shortcuts.commands в cmux.json. Формат файла см. в документации по настройке.", + "customCommandsCallout": "Запись нажатия, уже используемого встроенным действием или другой командой, блокируется баннером, поэтому пользовательское сочетание не может незаметно переопределить встроенное действие или конфликтовать с другой командой. Пользовательские сочетания команд состоят из одного нажатия." }, "api": { "title": "Справочник CLI", diff --git a/web/messages/th.json b/web/messages/th.json index 3e365973e924..daa70828a46f 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "คีย์บูลีน คีย์ที่ไม่รู้จักหรือไม่มีอยู่จะถูกอ่านเป็น false และยอมรับลิเทอรัล true กับ false ด้วย", "shortcutsWhenTypedKeys": "คีย์แบบมีชนิดสำหรับการเปรียบเทียบ: โหมดปัจจุบันของแถบด้านข้างขวา (สตริง) และจำนวนเพนกับเวิร์กสเปซ (จำนวนเต็ม)", "shortcutsWhenOperators": "ตัวดำเนินการบูลีน การเปรียบเทียบตามชนิด การจับคู่ regex และการเป็นสมาชิกของรายการ การเปรียบเทียบยึดเหนี่ยวแน่นกว่า && และ ||", - "shortcutsWhenExample": "ตัวอย่างเช่น การตั้งค่านี้ทำให้ Ctrl+1–9 เลือกเวิร์กสเปซได้ทุกที่ยกเว้นเมื่อแถบด้านข้างขวาอยู่ในโฟกัส (เหลือ Ctrl+1–5 ให้ตัวสลับโหมดของแถบด้านข้าง) และจำกัดการเลือกเซอร์เฟซไว้กับแถบด้านข้างค้นหาเมื่อเวิร์กสเปซมีหลายเพน:" + "shortcutsWhenExample": "ตัวอย่างเช่น การตั้งค่านี้ทำให้ Ctrl+1–9 เลือกเวิร์กสเปซได้ทุกที่ยกเว้นเมื่อแถบด้านข้างขวาอยู่ในโฟกัส (เหลือ Ctrl+1–5 ให้ตัวสลับโหมดของแถบด้านข้าง) และจำกัดการเลือกเซอร์เฟซไว้กับแถบด้านข้างค้นหาเมื่อเวิร์กสเปซมีหลายเพน:", + "shortcutsCommandsIntro": "กำหนดคีย์ลัดของคุณเองให้กับคำสั่งใด ๆ ในแถบคำสั่ง — รวมถึงคำสั่งที่มาโดยไม่มีคีย์ลัดในตัว เช่น การเปิดไดเรกทอรีปัจจุบันในตัวแก้ไขของคุณ คีย์คือรหัสคำสั่ง (รหัสเดียวกับที่แสดงในแถบคำสั่ง เช่น palette.openFolderInVSCodeInline) ส่วนค่าคือคีย์ลัดแบบกดครั้งเดียว ที่นี่ไม่รองรับคอร์ด — ให้ใช้การกดปุ่มครั้งเดียวที่มีปุ่มปรับหลัก (⌘, ⌥ หรือ ⌃) คีย์ลัดจะเรียกใช้คำสั่งบนหน้าต่างที่โฟกัสอยู่ คำสั่งที่ไม่พร้อมใช้งานในบริบทปัจจุบันจะไม่ทำอะไรเลย ใช้ null (หรือ \"\", none, clear, unbound, disabled) เพื่อล้างการผูกคีย์ วิธีจัดการที่ง่ายที่สุดคือ การตั้งค่า → คีย์ลัดแป้นพิมพ์ → คำสั่งที่กำหนดเอง ซึ่งค้นหาคำสั่งแบบฟัซซีและบล็อกการกดปุ่มที่เป็นของการกระทำในตัวหรือคำสั่งอื่นอยู่แล้ว" }, "customCommands": { "title": "คำสั่งที่กำหนดเอง", @@ -1032,7 +1033,10 @@ "wn-reload": "โหลดการตั้งค่าใหม่", "wn-quit": "ออก" }, - "metaTitle": "ทางลัดคีย์บอร์ด" + "metaTitle": "ทางลัดคีย์บอร์ด", + "customCommandsTitle": "คีย์ลัดคำสั่งที่กำหนดเอง", + "customCommandsIntro": "ผูกการกดปุ่มครั้งเดียวเข้ากับคำสั่งใด ๆ ในแถบคำสั่งจาก การตั้งค่า → คีย์ลัดแป้นพิมพ์ → คำสั่งที่กำหนดเอง หรือโดยตรงภายใต้ shortcuts.commands ใน cmux.json ดูรูปแบบไฟล์ได้ที่เอกสารการกำหนดค่า", + "customCommandsCallout": "การบันทึกการกดปุ่มที่การกระทำในตัวหรือคำสั่งอื่นใช้อยู่แล้วจะถูกบล็อกด้วยแบนเนอร์ ดังนั้นคีย์ลัดที่กำหนดเองจึงไม่สามารถแทนที่การกระทำในตัวอย่างเงียบ ๆ หรือชนกับคำสั่งอื่นได้ คีย์ลัดคำสั่งที่กำหนดเองรองรับการกดครั้งเดียวเท่านั้น" }, "api": { "title": "เอกสาร CLI", diff --git a/web/messages/tr.json b/web/messages/tr.json index 7f0dd773d5e0..c32e3c13dd67 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "boolean anahtarlar. Bilinmeyen veya bulunmayan bir anahtar false kabul edilir; true ve false sabitleri de kabul edilir.", "shortcutsWhenTypedKeys": "karşılaştırmalar için türlü anahtarlar: sağ kenar çubuğunun etkin modu (dize) ile bölme ve çalışma alanı sayıları (tamsayılar).", "shortcutsWhenOperators": "boolean işleçler, türlü karşılaştırmalar, düzenli ifade eşleşmesi ve liste üyeliği. Karşılaştırmalar && ve || işleçlerinden daha sıkı bağlanır.", - "shortcutsWhenExample": "Örneğin bu ayar, sağ kenar çubuğu odaklı olmadığı sürece Ctrl+1–9 ile çalışma alanlarını seçer (Ctrl+1–5'i kenar çubuğunun mod değiştiricisine bırakır) ve çalışma alanında birden çok bölme varken yüzey seçimini Bul kenar çubuğuyla sınırlar:" + "shortcutsWhenExample": "Örneğin bu ayar, sağ kenar çubuğu odaklı olmadığı sürece Ctrl+1–9 ile çalışma alanlarını seçer (Ctrl+1–5'i kenar çubuğunun mod değiştiricisine bırakır) ve çalışma alanında birden çok bölme varken yüzey seçimini Bul kenar çubuğuyla sınırlar:", + "shortcutsCommandsIntro": "Komut paletindeki herhangi bir komuta kendi klavye kısayolunuzu atayın — geçerli dizini düzenleyicinizde açmak gibi yerleşik kısayolu olmayan komutlar dahil. Anahtarlar komut kimlikleridir (palette gösterilen kimliklerin aynısı, örneğin palette.openFolderInVSCodeInline); değerler tek vuruşluk bir kısayoldur. Akorlar burada desteklenmez — birincil bir değiştirici (⌘, ⌥ veya ⌃) içeren tek bir tuş vuruşu kullanın. Kısayol, komutu odaklanmış pencerede çalıştırır; mevcut bağlamda kullanılamayan bir komut hiçbir şey yapmaz. Bir bağlamayı temizlemek için null (veya \"\", none, clear, unbound, disabled) kullanın. Bunları yönetmenin en kolay yolu Ayarlar → Klavye Kısayolları → Özel Komutlar, komutları bulanık olarak arar ve halihazırda yerleşik bir eyleme veya başka bir komuta ait olan bir tuş vuruşunu engeller." }, "customCommands": { "title": "Özel Komutlar", @@ -1032,7 +1033,10 @@ "wn-reload": "Yapılandırmayı yeniden yükle", "wn-quit": "Çık" }, - "metaTitle": "Klavye kısayolları" + "metaTitle": "Klavye kısayolları", + "customCommandsTitle": "Özel komut kısayolları", + "customCommandsIntro": "Ayarlar → Klavye Kısayolları → Özel Komutlar üzerinden veya doğrudan cmux.json içindeki shortcuts.commands altında, komut paletindeki herhangi bir komuta tek bir tuş vuruşu bağlayın. Dosya biçimi için yapılandırma belgelerine bakın.", + "customCommandsCallout": "Yerleşik bir eylem veya başka bir komut tarafından zaten kullanılan bir tuş vuruşunu kaydetmek bir afişle engellenir; böylece özel bir kısayol yerleşik bir eylemi sessizce gölgeleyemez veya başka bir komutla çakışamaz. Özel komut kısayolları yalnızca tek vuruşludur." }, "api": { "title": "CLI Referansı", diff --git a/web/messages/uk.json b/web/messages/uk.json index 1b232c14a8b6..0645215a695d 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "булеві ключі. Невідомий або відсутній ключ вважається false; також приймаються літерали true і false.", "shortcutsWhenTypedKeys": "типізовані ключі для порівнянь: активний режим правої бічної панелі (рядок) та кількість панелей і робочих просторів (цілі числа).", "shortcutsWhenOperators": "булеві оператори, типізовані порівняння, відповідність регулярному виразу та належність до списку. Порівняння зв'язують сильніше, ніж && і ||.", - "shortcutsWhenExample": "Наприклад, це змушує Ctrl+1–9 вибирати робочі простори всюди, крім випадків, коли у фокусі права бічна панель (залишаючи Ctrl+1–5 перемикачу режимів бічної панелі), і обмежує вибір поверхні бічною панеллю пошуку, коли в робочому просторі кілька панелей:" + "shortcutsWhenExample": "Наприклад, це змушує Ctrl+1–9 вибирати робочі простори всюди, крім випадків, коли у фокусі права бічна панель (залишаючи Ctrl+1–5 перемикачу режимів бічної панелі), і обмежує вибір поверхні бічною панеллю пошуку, коли в робочому просторі кілька панелей:", + "shortcutsCommandsIntro": "Призначте власне сполучення клавіш будь-якій команді палітри команд — включно з командами без вбудованого сполучення, наприклад відкриття поточного каталогу в редакторі. Ключі — це ідентифікатори команд (ті самі, що показані в палітрі, наприклад palette.openFolderInVSCodeInline); значення — сполучення з одного натискання. Акорди тут не підтримуються — використовуйте одне натискання з основним модифікатором (⌘, ⌥ або ⌃). Сполучення запускає команду в активному вікні; недоступна в поточному контексті команда нічого не робить. Використовуйте null (або \"\", none, clear, unbound, disabled), щоб очистити прив'язку. Найпростіше керувати ними в Налаштування → Комбінації клавіш → Власні команди, що виконує нечіткий пошук команд і блокує натискання, яке вже належить вбудованій дії або іншій команді." }, "customCommands": { "title": "Користувацькі команди", @@ -1032,7 +1033,10 @@ "wn-settings": "Параметри", "wn-reload": "Перезавантажити конфігурацію", "wn-quit": "Вийти" - } + }, + "customCommandsTitle": "Власні сполучення команд", + "customCommandsIntro": "Прив'яжіть одне натискання до будь-якої команди палітри команд через Налаштування → Комбінації клавіш → Власні команди або безпосередньо в shortcuts.commands у cmux.json. Формат файлу див. у документації з налаштування.", + "customCommandsCallout": "Запис натискання, яке вже використовується вбудованою дією або іншою командою, блокується банером, тож власне сполучення не може непомітно перевизначити вбудовану дію або конфліктувати з іншою командою. Власні сполучення команд складаються з одного натискання." }, "api": { "title": "Довідник CLI", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 63d7e76eba50..68953370b7ef 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "布尔键。未知或不存在的键按 false 处理;也接受字面量 true 和 false。", "shortcutsWhenTypedKeys": "用于比较的类型化键:右侧边栏的当前模式(字符串),以及窗格和工作区数量(整数)。", "shortcutsWhenOperators": "布尔运算符、类型化比较、正则匹配和列表成员判断。比较的结合优先级高于 && 和 ||。", - "shortcutsWhenExample": "例如,下面的配置让 Ctrl+1–9 在右侧边栏未聚焦时选择工作区(把 Ctrl+1–5 留给侧边栏模式切换器),并在工作区有多个窗格时将表面选择限定在查找侧边栏:" + "shortcutsWhenExample": "例如,下面的配置让 Ctrl+1–9 在右侧边栏未聚焦时选择工作区(把 Ctrl+1–5 留给侧边栏模式切换器),并在工作区有多个窗格时将表面选择限定在查找侧边栏:", + "shortcutsCommandsIntro": "为命令面板中的任何命令分配你自己的键盘快捷键——包括没有内置快捷键的命令,例如在编辑器中打开当前目录。键是命令 id(与面板中显示的 id 相同,例如 palette.openFolderInVSCodeInline);值是单次按键的快捷键。这里不支持和弦——请使用包含主修饰键(⌘、⌥ 或 ⌃)的单次按键。该快捷键会在当前聚焦的窗口上触发命令;在当前上下文中不可用的命令不会执行任何操作。使用 null(或 \"\"、none、clear、unbound、disabled)来清除绑定。最简单的管理方式是 设置 → 键盘快捷键 → 自定义命令,它会对命令进行模糊搜索,并阻止已属于内置操作或其他命令的按键。" }, "customCommands": { "title": "自定义命令", @@ -1032,7 +1033,10 @@ "wn-reload": "重新加载配置", "wn-quit": "退出" }, - "metaTitle": "键盘快捷键" + "metaTitle": "键盘快捷键", + "customCommandsTitle": "自定义命令快捷键", + "customCommandsIntro": "在 设置 → 键盘快捷键 → 自定义命令 中,或直接在 cmux.json 的 shortcuts.commands 下,为命令面板中的任何命令绑定一次按键。文件格式请参阅配置文档。", + "customCommandsCallout": "记录一个已被内置操作或其他命令使用的按键时会被横幅阻止,因此自定义快捷键不会悄悄覆盖内置操作或与其他命令冲突。自定义命令快捷键仅支持单次按键。" }, "api": { "title": "CLI 参考", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 23a22483d1d8..a28efc301324 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -865,7 +865,8 @@ "shortcutsWhenBooleanKeys": "布林鍵。未知或不存在的鍵視為 false;也接受常值 true 和 false。", "shortcutsWhenTypedKeys": "用於比較的型別化鍵:右側邊欄的目前模式(字串),以及窗格與工作區數量(整數)。", "shortcutsWhenOperators": "布林運算子、型別化比較、正規表示式比對與清單成員判斷。比較的結合優先順序高於 && 和 ||。", - "shortcutsWhenExample": "例如,以下設定讓 Ctrl+1–9 在右側邊欄未取得焦點時選取工作區(將 Ctrl+1–5 留給側邊欄模式切換器),並在工作區有多個窗格時,將表面選取限制在尋找側邊欄:" + "shortcutsWhenExample": "例如,以下設定讓 Ctrl+1–9 在右側邊欄未取得焦點時選取工作區(將 Ctrl+1–5 留給側邊欄模式切換器),並在工作區有多個窗格時,將表面選取限制在尋找側邊欄:", + "shortcutsCommandsIntro": "為命令面板中的任何命令指派你自己的鍵盤快速鍵——包括沒有內建快速鍵的命令,例如在編輯器中開啟目前目錄。鍵是命令 id(與面板中顯示的 id 相同,例如 palette.openFolderInVSCodeInline);值是單次按鍵的快速鍵。這裡不支援和弦——請使用包含主要修飾鍵(⌘、⌥ 或 ⌃)的單次按鍵。該快速鍵會在目前聚焦的視窗上觸發命令;在目前情境中無法使用的命令不會執行任何動作。使用 null(或 \"\"、none、clear、unbound、disabled)來清除繫結。最簡單的管理方式是 設定 → 鍵盤快速鍵 → 自訂命令,它會對命令進行模糊搜尋,並封鎖已屬於內建動作或其他命令的按鍵。" }, "customCommands": { "title": "自訂指令", @@ -1032,7 +1033,10 @@ "wn-reload": "重新載入設定", "wn-quit": "結束" }, - "metaTitle": "鍵盤快捷鍵" + "metaTitle": "鍵盤快捷鍵", + "customCommandsTitle": "自訂命令快速鍵", + "customCommandsIntro": "在 設定 → 鍵盤快速鍵 → 自訂命令 中,或直接在 cmux.json 的 shortcuts.commands 底下,為命令面板中的任何命令繫結一次按鍵。檔案格式請參閱設定文件。", + "customCommandsCallout": "錄製一個已被內建動作或其他命令使用的按鍵時會被橫幅封鎖,因此自訂快速鍵不會悄悄覆蓋內建動作或與其他命令衝突。自訂命令快速鍵僅支援單次按鍵。" }, "api": { "title": "CLI 參考",