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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/AppDelegate+NewWorkspaceMenuRendering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,19 @@ extension AppDelegate {
parent.submenu = submenu
items.append(parent)
}
items.append(.separator())
let actionsItem = NSMenuItem(
title: Self.actionsAndLaunchersMenuTitle,
action: #selector(presentActionsAndLaunchersMenuItem(_:)),
keyEquivalent: ""
)
actionsItem.target = self
actionsItem.representedObject = context.windowId as NSUUID
actionsItem.image = NSImage(
systemSymbolName: "slider.horizontal.3",
accessibilityDescription: nil
)
items.append(actionsItem)
addRenderedSection(items)
}
}
Expand Down
239 changes: 239 additions & 0 deletions Sources/AppDelegate+WorkspaceActionSave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,246 @@ final class WorkspaceDefaultLayoutBox: NSObject {
}
}

/// Read-only projection of the live cmux.json action registry for native
/// discovery UI. It deliberately carries only resolved action metadata and
/// effective placements; execution stays on the existing action paths.
struct ActionsAndLaunchersDiscoveryModel: Equatable {
struct Entry: Equatable {
let id: String
let title: String
let actionType: String
let sourcePath: String
let appearsInCommandPalette: Bool
let isNewWorkspaceDefault: Bool
let appearsInNewWorkspaceMenu: Bool
let appearsInSurfaceTabBar: Bool
let shortcutDisplay: String?

var detailTokens: [String] {
var tokens = ["type=\(actionType)"]
if appearsInCommandPalette {
tokens.append("palette")
}
if isNewWorkspaceDefault {
tokens.append("ui.newWorkspace.action")
}
if appearsInNewWorkspaceMenu {
tokens.append("ui.newWorkspace.contextMenu")
}
if appearsInSurfaceTabBar {
tokens.append("ui.surfaceTabBar.buttons")
}
if let shortcutDisplay {
tokens.append("shortcut=\(shortcutDisplay)")
}
return tokens
}
}

let entries: [Entry]

static func build(
actions: [CmuxResolvedConfigAction],
resolvedNewWorkspaceActionID: String?,
newWorkspaceMenuActionIDs: Set<String>,
surfaceTabBarActionIDs: Set<String>
) -> ActionsAndLaunchersDiscoveryModel {
let entries = actions.compactMap { action -> Entry? in
// Built-ins that only come from cmux itself add noise here. An
// overridden built-in has a source path and remains discoverable.
guard let sourcePath = action.actionSourcePath else { return nil }
let shortcutDisplay = action.shortcut.flatMap { shortcut in
shortcut.isUnbound ? nil : shortcut.displayString
}
return Entry(
id: action.id,
title: action.title,
actionType: actionType(action.action),
sourcePath: sourcePath,
appearsInCommandPalette: action.palette,
isNewWorkspaceDefault: action.id == resolvedNewWorkspaceActionID,
appearsInNewWorkspaceMenu: newWorkspaceMenuActionIDs.contains(action.id),
appearsInSurfaceTabBar: surfaceTabBarActionIDs.contains(action.id),
shortcutDisplay: shortcutDisplay
)
}
.sorted {
let titleOrder = $0.title.localizedStandardCompare($1.title)
if titleOrder != .orderedSame {
return titleOrder == .orderedAscending
}
return $0.id.localizedStandardCompare($1.id) == .orderedAscending
}
return ActionsAndLaunchersDiscoveryModel(entries: entries)
}

var summaryText: String {
guard !entries.isEmpty else { return "actions: {}" }
return entries.map { entry in
let source = (entry.sourcePath as NSString).abbreviatingWithTildeInPath
return [
"\(entry.title) [\(entry.id)]",
" " + entry.detailTokens.joined(separator: " · "),
" " + source,
].joined(separator: "\n")
}
.joined(separator: "\n\n")
}

private static func actionType(_ action: CmuxSurfaceTabBarButtonAction) -> String {
switch action {
case .builtIn:
return "builtin"
case .command:
return "command"
case .agent:
return "agent"
case .workspaceCommand:
return "workspaceCommand"
case .workspace:
return "workspace"
case .actionReference:
return "action"
}
}
}

extension AppDelegate {
static var actionsAndLaunchersMenuTitle: String {
String(
localized: "debug.titlebarLayoutDebug.actions",
defaultValue: "Actions"
) + " · cmux.json…"
}

private static var actionsAndLaunchersDialogTitle: String {
String(
localized: "debug.titlebarLayoutDebug.actions",
defaultValue: "Actions"
) + " · cmux.json"
}

func presentActionsAndLaunchersCustomization(preferredWindow: NSWindow? = nil) {
let context = [
preferredWindow,
NSApp.keyWindow,
NSApp.mainWindow,
shortcutRoutingActiveWindow,
]
.compactMap { $0 }
.compactMap { contextForMainWindow($0) }
.first

let cmuxConfigStore: CmuxConfigStore
if let activeStore = context?.cmuxConfigStore {
cmuxConfigStore = activeStore
} else {
let globalStore = CmuxConfigStore()
globalStore.loadAll()
cmuxConfigStore = globalStore
}

let newWorkspaceMenuActionIDs = Set(
cmuxConfigStore.newWorkspaceContextMenuItems.compactMap { item -> String? in
guard case .action(let menuAction) = item else { return nil }
return menuAction.action.id
}
)
let model = ActionsAndLaunchersDiscoveryModel.build(
actions: cmuxConfigStore.loadedActions,
resolvedNewWorkspaceActionID: cmuxConfigStore.resolvedNewWorkspaceAction()?.id,
newWorkspaceMenuActionIDs: newWorkspaceMenuActionIDs,
surfaceTabBarActionIDs: Set(cmuxConfigStore.surfaceTabBarActionReferenceIDs.values)
)

let alert = NSAlert()
alert.messageText = Self.actionsAndLaunchersDialogTitle
alert.informativeText = (cmuxConfigStore.globalConfigPath as NSString).abbreviatingWithTildeInPath
alert.accessoryView = actionsAndLaunchersAccessoryView(
text: model.summaryText,
entryCount: model.entries.count
)
let openLabel = String(
localized: "settings.settingsJSON.openButton",
defaultValue: "Open"
)
let globalPath = (cmuxConfigStore.globalConfigPath as NSString).abbreviatingWithTildeInPath
alert.addButton(withTitle: "\(openLabel) \(globalPath)")
alert.addButton(withTitle: String(
localized: "settings.settingsJSON.docsButton",
defaultValue: "Open Docs"
))
alert.addButton(withTitle: String(
localized: "common.ok",
defaultValue: "OK"
))

let presentingWindow = context.flatMap { resolvedWindow(for: $0) } ?? preferredWindow
if let presentingWindow {
alert.beginSheetModal(for: presentingWindow) { [weak self] response in
self?.handleActionsAndLaunchersResponse(response)
}
} else {
handleActionsAndLaunchersResponse(alert.runModal())
}
}

@objc func presentActionsAndLaunchersMenuItem(_ sender: NSMenuItem) {
let preferredWindow: NSWindow?
if let windowId = (sender.representedObject as? NSUUID) as UUID?,
let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) {
preferredWindow = resolvedWindow(for: context)
} else {
preferredWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
presentActionsAndLaunchersCustomization(preferredWindow: preferredWindow)
}

private func actionsAndLaunchersAccessoryView(
text: String,
entryCount: Int
) -> NSView {
let height = min(CGFloat(360), max(CGFloat(120), CGFloat(max(entryCount, 1)) * 64))
let size = NSSize(width: 620, height: height)
let textView = NSTextView(frame: NSRect(origin: .zero, size: size))
textView.string = text
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = false
textView.drawsBackground = false
textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.maxSize = NSSize(width: size.width, height: .greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: size.width,
height: .greatestFiniteMagnitude
)

let scrollView = NSScrollView(frame: NSRect(origin: .zero, size: size))
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
scrollView.borderType = .bezelBorder
scrollView.documentView = textView
return scrollView
}

private func handleActionsAndLaunchersResponse(_ response: NSApplication.ModalResponse) {
switch response {
case .alertFirstButtonReturn:
// Reuse the existing in-cmux config editor path used by Settings'
// workspace-layout customization.
openWorkspaceLayoutsCustomization()
Comment thread
teamleaderleo marked this conversation as resolved.
case .alertSecondButtonReturn:
guard let url = URL(string: "https://cmux.com/docs/custom-commands") else { return }
NSWorkspace.shared.open(url)
default:
break
}
}

/// Actions defined in the global config (where saved workspace layouts
/// write) are deletable from the UI; project-local and built-in actions
Expand Down
32 changes: 27 additions & 5 deletions Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,10 @@ final class CmuxConfigStore: ObservableObject {
/// configured.
@Published private(set) var workspaceGroupConfigs: [CmuxResolvedWorkspaceGroupConfig] = []
@Published private(set) var surfaceTabBarButtons: [CmuxSurfaceTabBarButton] = CmuxSurfaceTabBarButton.defaults
/// Original action-registry reference keyed by resolved surface-tab button id.
/// Inline buttons have no entry; this survives resolution so discovery UI
/// never has to infer action identity from equal payloads.
@Published private(set) var surfaceTabBarActionReferenceIDs: [String: String] = [:]
@Published private(set) var notificationHooks: [CmuxResolvedNotificationHook] = []
@Published private(set) var configurationIssues: [CmuxConfigIssue] = []
@Published private(set) var configRevision: UInt64 = 0
Expand Down Expand Up @@ -1736,11 +1740,13 @@ final class CmuxConfigStore: ObservableObject {
private struct ResolvedSurfaceTabBarButtonEntry {
let button: CmuxSurfaceTabBarButton
let terminalCommandSourcePath: String?
let actionReferenceID: String?
}

private struct ResolvedSurfaceTabBarButtons {
let buttons: [CmuxSurfaceTabBarButton]
let terminalCommandSourcePaths: [String: String]
let actionReferenceIDs: [String: String]
}

private struct ResolvedContextMenuItems {
Expand Down Expand Up @@ -2075,7 +2081,8 @@ final class CmuxConfigStore: ObservableObject {
settingName: "ui.surfaceTabBar.buttons"
) ?? ResolvedSurfaceTabBarButtons(
buttons: defaultResolvedButtons,
terminalCommandSourcePaths: [:]
terminalCommandSourcePaths: [:],
actionReferenceIDs: [:]
)
let resolvedWorkspaceButtons = resolvedSurfaceTabBarWorkspaceCommands(
resolvedButtons.buttons,
Expand Down Expand Up @@ -2136,6 +2143,10 @@ final class CmuxConfigStore: ObservableObject {
workspaceGroupConfigs = resolvedGroupConfigs
surfaceTabBarButtonSourcePath = configuredSurfaceTabBarButtonSourcePath
surfaceTabBarCommandSourcePaths = resolvedButtons.terminalCommandSourcePaths
let visibleSurfaceButtonIDs = Set(resolvedWorkspaceButtons.buttons.map(\.id))
surfaceTabBarActionReferenceIDs = resolvedButtons.actionReferenceIDs.filter {
visibleSurfaceButtonIDs.contains($0.key)
}
surfaceTabBarWorkspaceCommands = resolvedWorkspaceButtons.workspaceCommands
surfaceTabBarButtons = resolvedWorkspaceButtons.buttons
notificationHooks = resolvedNotificationHooks
Expand Down Expand Up @@ -2330,12 +2341,16 @@ final class CmuxConfigStore: ObservableObject {
) -> ResolvedSurfaceTabBarButtons? {
var resolvedButtons: [CmuxSurfaceTabBarButton] = []
var terminalCommandSourcePaths: [String: String] = [:]
var actionReferenceIDs: [String: String] = [:]
resolvedButtons.reserveCapacity(buttons.count)

for button in buttons {
do {
let resolved = try resolvedSurfaceTabBarButton(button, actions: actions)
resolvedButtons.append(resolved.button)
if let actionReferenceID = resolved.actionReferenceID {
actionReferenceIDs[resolved.button.id] = actionReferenceID
}
guard resolved.button.terminalCommand != nil else { continue }
if let commandSourcePath = resolved.terminalCommandSourcePath {
terminalCommandSourcePaths[resolved.button.id] = commandSourcePath
Expand All @@ -2348,7 +2363,8 @@ final class CmuxConfigStore: ObservableObject {

return ResolvedSurfaceTabBarButtons(
buttons: resolvedButtons,
terminalCommandSourcePaths: terminalCommandSourcePaths
terminalCommandSourcePaths: terminalCommandSourcePaths,
actionReferenceIDs: actionReferenceIDs
)
}

Expand All @@ -2357,7 +2373,11 @@ final class CmuxConfigStore: ObservableObject {
actions: [String: CmuxResolvedConfigAction]
) throws -> ResolvedSurfaceTabBarButtonEntry {
guard case .actionReference(let identifier) = button.action else {
return ResolvedSurfaceTabBarButtonEntry(button: button, terminalCommandSourcePath: nil)
return ResolvedSurfaceTabBarButtonEntry(
button: button,
terminalCommandSourcePath: nil,
actionReferenceID: nil
)
}

let resolvedIdentifier = canonicalActionID(identifier)
Expand All @@ -2375,7 +2395,8 @@ final class CmuxConfigStore: ObservableObject {
)
return ResolvedSurfaceTabBarButtonEntry(
button: resolvedButton,
terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath
terminalCommandSourcePath: resolvedButton.terminalCommand == nil ? nil : entry.actionSourcePath,
actionReferenceID: resolvedIdentifier
)
}

Expand All @@ -2390,7 +2411,8 @@ final class CmuxConfigStore: ObservableObject {
confirm: button.confirm,
terminalCommandTarget: button.terminalCommandTarget
),
terminalCommandSourcePath: nil
terminalCommandSourcePath: nil,
actionReferenceID: builtIn.configID
)
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,11 @@ struct cmuxApp: App {
splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) {
appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma")
}
Button(AppDelegate.actionsAndLaunchersMenuTitle) {
appDelegate.presentActionsAndLaunchersCustomization(
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
)
}
Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux.json")) {
openCmuxSettingsFileInEditor()
}
Expand Down
Loading
Loading