diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 78d8e1bc..d3793987 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -639,6 +639,31 @@ enum AppShortcuts { return resolvedBinding.binding?.appShortcut } + static func display(for commandID: String, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { + resolvedShortcut(for: commandID, in: resolvedKeybindings)?.display + } + + static func helpText( + title: String, + commandID: String, + in resolvedKeybindings: ResolvedKeybindingMap + ) -> String { + if let shortcut = display(for: commandID, in: resolvedKeybindings) { + return "\(title) (\(shortcut))" + } + return title + } + + static func worktreeSelectionDisplay(at index: Int, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { + guard worktreeSelectionCommandIDs.indices.contains(index) else { return nil } + return display(for: worktreeSelectionCommandIDs[index], in: resolvedKeybindings) + } + + static func terminalTabSelectionDisplay(at index: Int, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { + guard terminalTabSelectionCommandIDs.indices.contains(index) else { return nil } + return display(for: terminalTabSelectionCommandIDs[index], in: resolvedKeybindings) + } + private static let ghosttyManagedActionBindings: [(commandID: String, action: String)] = [ (CommandID.selectTerminalTab1, "goto_tab:1"), (CommandID.selectTerminalTab2, "goto_tab:2"), diff --git a/supacode/Commands/SidebarCommands.swift b/supacode/Commands/SidebarCommands.swift index d77e4dd4..7afcad9d 100644 --- a/supacode/Commands/SidebarCommands.swift +++ b/supacode/Commands/SidebarCommands.swift @@ -27,6 +27,7 @@ struct SidebarCommands: Commands { DiffWindowManager.shared.show( worktreeURL: worktree.workingDirectory, branchName: worktree.name, + resolvedKeybindings: store.resolvedKeybindings ) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) diff --git a/supacode/Features/Canvas/Views/CanvasSidebarButton.swift b/supacode/Features/Canvas/Views/CanvasSidebarButton.swift index 05f16cfd..8c631c28 100644 --- a/supacode/Features/Canvas/Views/CanvasSidebarButton.swift +++ b/supacode/Features/Canvas/Views/CanvasSidebarButton.swift @@ -5,6 +5,7 @@ struct CanvasSidebarButton: View { let store: StoreOf let isSelected: Bool @Environment(CommandKeyObserver.self) private var commandKeyObserver + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { Button { @@ -14,8 +15,10 @@ struct CanvasSidebarButton: View { Label("Canvas", systemImage: "square.grid.2x2") .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) - if commandKeyObserver.isPressed { - ShortcutHintView(text: AppShortcuts.toggleCanvas.display, color: .secondary) + if commandKeyObserver.isPressed, + let shortcut = AppShortcuts.display(for: AppShortcuts.CommandID.toggleCanvas, in: resolvedKeybindings) + { + ShortcutHintView(text: shortcut, color: .secondary) } } } @@ -23,6 +26,10 @@ struct CanvasSidebarButton: View { .padding(.horizontal, 12) .padding(.vertical, 6) .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) - .help("Canvas (\(AppShortcuts.toggleCanvas.display))") + .help(AppShortcuts.helpText( + title: "Canvas", + commandID: AppShortcuts.CommandID.toggleCanvas, + in: resolvedKeybindings + )) } } diff --git a/supacode/Features/DiffView/DiffWindowContentView.swift b/supacode/Features/DiffView/DiffWindowContentView.swift index 01e909f9..eac57eb9 100644 --- a/supacode/Features/DiffView/DiffWindowContentView.swift +++ b/supacode/Features/DiffView/DiffWindowContentView.swift @@ -5,6 +5,7 @@ struct DiffWindowContentView: View { var state: DiffWindowState @State private var columnVisibility: NavigationSplitViewVisibility = .automatic @AppStorage("diffViewStyle") private var diffStyleRaw = DiffStyle.split.rawValue + @Environment(\.resolvedKeybindings) private var resolvedKeybindings private var diffStyle: DiffStyle { DiffStyle(rawValue: diffStyleRaw) ?? .split @@ -37,7 +38,11 @@ struct DiffWindowContentView: View { Image(systemName: "sidebar.left") .accessibilityLabel("Toggle Sidebar") } - .help("Toggle Sidebar (\(AppShortcuts.toggleLeftSidebar.display))") + .help(AppShortcuts.helpText( + title: "Toggle Sidebar", + commandID: AppShortcuts.CommandID.toggleLeftSidebar, + in: resolvedKeybindings + )) } ToolbarItem(id: "diffStyle", placement: .primaryAction) { Picker("Diff Style", selection: $diffStyleRaw) { diff --git a/supacode/Features/DiffView/DiffWindowManager.swift b/supacode/Features/DiffView/DiffWindowManager.swift index 17c04f28..6676ab91 100644 --- a/supacode/Features/DiffView/DiffWindowManager.swift +++ b/supacode/Features/DiffView/DiffWindowManager.swift @@ -12,11 +12,22 @@ final class DiffWindowManager { private init() {} - func show(worktreeURL: URL, branchName: String) { + func show( + worktreeURL: URL, + branchName: String, + resolvedKeybindings: ResolvedKeybindingMap = .appDefaults + ) { state.load(worktreeURL: worktreeURL, branchName: branchName) skipNextFocusRefresh = true + let rootView = AnyView( + DiffWindowContentView(state: state) + .environment(\.resolvedKeybindings, resolvedKeybindings) + ) if let existingWindow = window { + if let hostingController = existingWindow.contentViewController as? NSHostingController { + hostingController.rootView = rootView + } existingWindow.title = windowTitle(branchName: branchName) if existingWindow.isMiniaturized { existingWindow.deminiaturize(nil) @@ -25,8 +36,7 @@ final class DiffWindowManager { return } - let contentView = DiffWindowContentView(state: state) - let hostingController = NSHostingController(rootView: contentView) + let hostingController = NSHostingController(rootView: rootView) let newWindow = NSWindow(contentViewController: hostingController) newWindow.title = windowTitle(branchName: branchName) diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 0dca8aa7..53e7e245 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -3,31 +3,40 @@ import SwiftUI struct EmptyStateView: View { let store: StoreOf + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { + let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) VStack { Image(systemName: "tray") .font(.title2) .accessibilityHidden(true) Text("Open a repository or folder") .font(.headline) - Text( - "Press \(AppShortcuts.openRepository.display) " - + "or click Open Repository to choose a folder." - ) + Text(promptText(shortcutDisplay: shortcutDisplay)) .font(.subheadline) .foregroundStyle(.secondary) Button("Open Repository...") { store.send(.setOpenPanelPresented(true)) } - .keyboardShortcut( - AppShortcuts.openRepository.keyEquivalent, - modifiers: AppShortcuts.openRepository.modifiers - ) - .help("Open Repository (\(AppShortcuts.openRepository.display))") + .modifier(KeyboardShortcutModifier( + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.openRepository) + )) + .help(AppShortcuts.helpText( + title: "Open Repository", + commandID: AppShortcuts.CommandID.openRepository, + in: resolvedKeybindings + )) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .multilineTextAlignment(.center) } + + private func promptText(shortcutDisplay: String?) -> String { + if let shortcutDisplay { + return "Press \(shortcutDisplay) or click Open Repository to choose a folder." + } + return "Click Open Repository to choose a folder." + } } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 1c0f5bc0..a9af3b46 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -12,6 +12,7 @@ struct RepositorySectionView: View { @Bindable var store: StoreOf let terminalManager: WorktreeTerminalManager @Environment(\.colorScheme) private var colorScheme + @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var isHovering = false var body: some View { @@ -122,7 +123,11 @@ struct RepositorySectionView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("New Worktree (\(AppShortcuts.newWorktree.display))") + .help(AppShortcuts.helpText( + title: "New Worktree", + commandID: AppShortcuts.CommandID.newWorktree, + in: resolvedKeybindings + )) .disabled(isRemovingRepository) } if repository.capabilities.supportsWorktrees { diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 101d6bdf..7396e5fa 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -6,6 +6,7 @@ struct SidebarFooterView: View { @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity @Environment(\.openURL) private var openURL @Environment(CommandKeyObserver.self) private var commandKeyObserver + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { HStack { @@ -15,12 +16,18 @@ struct SidebarFooterView: View { HStack(spacing: 6) { Label("Add Repository", systemImage: "folder.badge.plus") .font(.callout) - if commandKeyObserver.isPressed { - ShortcutHintView(text: AppShortcuts.openRepository.display, color: .secondary) + if commandKeyObserver.isPressed, + let shortcut = shortcutDisplay(for: AppShortcuts.CommandID.openRepository) + { + ShortcutHintView(text: shortcut, color: .secondary) } } } - .help("Add Repository (\(AppShortcuts.openRepository.display))") + .help(AppShortcuts.helpText( + title: "Add Repository", + commandID: AppShortcuts.CommandID.openRepository, + in: resolvedKeybindings + )) Spacer() Menu { Button("Submit GitHub issue", systemImage: "exclamationmark.bubble") { @@ -42,7 +49,11 @@ struct SidebarFooterView: View { .symbolEffect(.rotate, options: .repeating, isActive: store.state.isRefreshingWorktrees) .accessibilityLabel("Refresh Worktrees") } - .help("Refresh Worktrees (\(AppShortcuts.refreshWorktrees.display))") + .help(AppShortcuts.helpText( + title: "Refresh Worktrees", + commandID: AppShortcuts.CommandID.refreshWorktrees, + in: resolvedKeybindings + )) .disabled(store.state.repositoryRoots.isEmpty && !store.state.isRefreshingWorktrees) Button { store.send(.selectArchivedWorktrees) @@ -50,12 +61,20 @@ struct SidebarFooterView: View { Image(systemName: "archivebox") .accessibilityLabel("Archived Worktrees") } - .help("Archived Worktrees (\(AppShortcuts.archivedWorktrees.display))") + .help(AppShortcuts.helpText( + title: "Archived Worktrees", + commandID: AppShortcuts.CommandID.archivedWorktrees, + in: resolvedKeybindings + )) Button("Settings", systemImage: "gearshape") { SettingsWindowManager.shared.show() } .labelStyle(.iconOnly) - .help("Settings (\(AppShortcuts.openSettings.display))") + .help(AppShortcuts.helpText( + title: "Settings", + commandID: AppShortcuts.CommandID.openSettings, + in: resolvedKeybindings + )) } .buttonStyle(.plain) .font(.callout) @@ -67,4 +86,8 @@ struct SidebarFooterView: View { Divider() } } + + private func shortcutDisplay(for commandID: String) -> String? { + AppShortcuts.display(for: commandID, in: resolvedKeybindings) + } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f6a5f31d..44c56480 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -390,14 +390,6 @@ struct WorktreeDetailView: View { let runScriptEnabled: Bool let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] - - var runScriptHelpText: String { - "Run Script (\(AppShortcuts.runScript.display))" - } - - var stopRunScriptHelpText: String { - "Stop Script (\(AppShortcuts.stopRunScript.display))" - } } fileprivate struct WorktreeToolbarContent: ToolbarContent { @@ -411,6 +403,7 @@ struct WorktreeDetailView: View { let onRunScript: () -> Void let onStopRunScript: () -> Void let onRunCustomCommand: (Int) -> Void + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { ToolbarItem { @@ -462,7 +455,7 @@ struct WorktreeDetailView: View { } label: { OpenWorktreeActionMenuLabelView( action: resolvedOpenActionSelection, - shortcutHint: showExtras ? AppShortcuts.openFinder.display : nil + shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.CommandID.openWorktree) : nil ) } .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) @@ -497,9 +490,12 @@ struct WorktreeDetailView: View { } private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { - isDefault - ? "\(action.title) (\(AppShortcuts.openFinder.display))" - : action.title + guard isDefault else { return action.title } + return AppShortcuts.helpText( + title: action.title, + commandID: AppShortcuts.CommandID.openWorktree, + in: resolvedKeybindings + ) } @ToolbarContentBuilder @@ -509,10 +505,18 @@ struct WorktreeDetailView: View { RunScriptToolbarButton( isRunning: toolbarState.runScriptIsRunning, isEnabled: toolbarState.runScriptEnabled, - runHelpText: toolbarState.runScriptHelpText, - stopHelpText: toolbarState.stopRunScriptHelpText, - runShortcut: AppShortcuts.runScript.display, - stopShortcut: AppShortcuts.stopRunScript.display, + runHelpText: AppShortcuts.helpText( + title: "Run Script", + commandID: AppShortcuts.CommandID.runScript, + in: resolvedKeybindings + ), + stopHelpText: AppShortcuts.helpText( + title: "Stop Script", + commandID: AppShortcuts.CommandID.stopScript, + in: resolvedKeybindings + ), + runShortcut: shortcutDisplay(for: AppShortcuts.CommandID.runScript), + stopShortcut: shortcutDisplay(for: AppShortcuts.CommandID.stopScript), runAction: onRunScript, stopAction: onStopRunScript ) @@ -553,6 +557,10 @@ struct WorktreeDetailView: View { } ) } + + private func shortcutDisplay(for commandID: String) -> String? { + AppShortcuts.display(for: commandID, in: resolvedKeybindings) + } } private func loadingInfo( @@ -660,8 +668,8 @@ private struct RunScriptToolbarButton: View { let isEnabled: Bool let runHelpText: String let stopHelpText: String - let runShortcut: String - let stopShortcut: String + let runShortcut: String? + let stopShortcut: String? let runAction: () -> Void let stopAction: () -> Void @Environment(CommandKeyObserver.self) private var commandKeyObserver @@ -700,8 +708,8 @@ private struct RunScriptToolbarButton: View { .accessibilityHidden(true) Text(config.title) - if commandKeyObserver.isPressed { - Text(config.shortcut) + if commandKeyObserver.isPressed, let shortcut = config.shortcut { + Text(shortcut) .font(.caption) .foregroundStyle(.secondary) } @@ -716,7 +724,7 @@ private struct RunScriptToolbarButton: View { let title: String let systemImage: String let helpText: String - let shortcut: String + let shortcut: String? let isEnabled: Bool let action: () -> Void } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index 1aa451a2..aa39d9ac 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -21,6 +21,7 @@ struct WorktreeRow: View { let archiveAction: (() -> Void)? let onDiffTap: (() -> Void)? @Environment(\.colorScheme) private var colorScheme + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { let showsSpinner = isLoading || taskStatus == .running @@ -90,7 +91,11 @@ struct WorktreeRow: View { ) } .buttonStyle(.plain) - .help("Show Diff (\(AppShortcuts.showDiff.display))") + .help(AppShortcuts.helpText( + title: "Show Diff", + commandID: AppShortcuts.CommandID.showDiff, + in: resolvedKeybindings + )) } if isHovered { Button { diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index fc15445c..5c73ed7b 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -11,6 +11,7 @@ struct WorktreeRowsView: View { let terminalManager: WorktreeTerminalManager @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.colorScheme) private var colorScheme + @Environment(\.resolvedKeybindings) private var resolvedKeybindings @State private var draggingWorktreeIDs: Set = [] @State private var hoveredWorktreeID: Worktree.ID? @@ -123,6 +124,7 @@ struct WorktreeRowsView: View { DiffWindowManager.shared.show( worktreeURL: worktree.workingDirectory, branchName: worktree.name, + resolvedKeybindings: resolvedKeybindings ) } let config = WorktreeRowViewConfig( @@ -286,8 +288,8 @@ struct WorktreeRowsView: View { } private func worktreeShortcutHint(for index: Int?) -> String? { - guard let index, AppShortcuts.worktreeSelection.indices.contains(index) else { return nil } - return AppShortcuts.worktreeSelection[index].display + guard let index else { return nil } + return AppShortcuts.worktreeSelectionDisplay(at: index, in: resolvedKeybindings) } private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 48dbae90..7ba90640 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -15,6 +15,7 @@ struct TerminalTabView: View { @State private var isHoveringClose = false @State private var isPressing = false @Environment(CommandKeyObserver.self) private var commandKeyObserver + @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some View { ZStack(alignment: .trailing) { @@ -73,9 +74,7 @@ struct TerminalTabView: View { } private var shortcutHint: String? { - let number = tabIndex + 1 - guard number > 0 && number <= 9 else { return nil } - return "⌘\(number)" + AppShortcuts.terminalTabSelectionDisplay(at: tabIndex, in: resolvedKeybindings) } private var showsShortcutHint: Bool { diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 16453d03..93002fa6 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -40,6 +40,57 @@ struct AppShortcutsTests { } } + @Test func selectionDisplayUsesResolvedOverrides() { + let overrides = KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.selectWorktree1: KeybindingUserOverride( + binding: Keybinding(key: "m", modifiers: .init(control: true)) + ), + AppShortcuts.CommandID.selectTerminalTab1: KeybindingUserOverride( + binding: Keybinding(key: "j", modifiers: .init(command: true)) + ), + ] + ) + let resolved = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: overrides + ) + + #expect(AppShortcuts.worktreeSelectionDisplay(at: 0, in: resolved) == "⌃M") + #expect(AppShortcuts.terminalTabSelectionDisplay(at: 0, in: resolved) == "⌘J") + #expect(AppShortcuts.worktreeSelectionDisplay(at: 10, in: resolved) == nil) + #expect(AppShortcuts.terminalTabSelectionDisplay(at: 10, in: resolved) == nil) + } + + @Test func helpTextUsesResolvedShortcutAndHandlesDisabledBinding() { + let defaultHelpText = AppShortcuts.helpText( + title: "Run Script", + commandID: AppShortcuts.CommandID.runScript, + in: .appDefaults + ) + #expect(defaultHelpText == "Run Script (⌘R)") + + let disabledOverrides = KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.runScript: KeybindingUserOverride( + binding: nil, + isEnabled: false + ) + ] + ) + let resolvedDisabled = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: disabledOverrides + ) + + let disabledHelpText = AppShortcuts.helpText( + title: "Run Script", + commandID: AppShortcuts.CommandID.runScript, + in: resolvedDisabled + ) + #expect(disabledHelpText == "Run Script") + } + @Test func defaultGlobalShortcutTableMatchesPlan() { expectNoDifference( [