native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #79 from onevcat/feat/shortcut-defaults-conflicts

Align default shortcuts and harden custom shortcut conflict fallback

authored by

Wei Wang and committed by
GitHub
691a5f58 504141ff

+981 -289
+271 -4
supacode/App/AppShortcuts.swift
··· 1 1 import SwiftUI 2 2 3 - struct AppShortcut { 3 + struct AppShortcut: Equatable { 4 4 let keyEquivalent: KeyEquivalent 5 5 let modifiers: EventModifiers 6 6 private let ghosttyKeyName: String ··· 39 39 display.map { String($0) } 40 40 } 41 41 42 + fileprivate var normalizedConflictKey: String? { 43 + guard ghosttyKeyName.count == 1 else { return nil } 44 + return ghosttyKeyName 45 + } 46 + 42 47 private var ghosttyModifierParts: [String] { 43 48 var parts: [String] = [] 44 49 if modifiers.contains(.control) { parts.append("ctrl") } ··· 59 64 } 60 65 61 66 enum AppShortcuts { 67 + enum Scope: String { 68 + case configurableAppAction 69 + case systemFixedAppAction 70 + case localInteraction 71 + } 72 + 73 + struct Binding: Equatable { 74 + let id: String 75 + let title: String 76 + let scope: Scope 77 + let shortcut: AppShortcut 78 + } 79 + 80 + struct CustomCommandOverrideConflict: Equatable { 81 + let commandTitle: String 82 + let commandShortcutDisplay: String 83 + let appActionTitle: String 84 + let appShortcutDisplay: String 85 + } 86 + 87 + private struct ReservedCustomCommandBinding { 88 + let actionTitle: String 89 + let shortcut: AppShortcut 90 + } 91 + 62 92 private struct TabSelectionBinding { 63 93 let unicode: String 64 94 let physical: String ··· 79 109 ] 80 110 81 111 static let newWorktree = AppShortcut(key: "n", modifiers: .command) 112 + static let commandPalette = AppShortcut(key: "p", modifiers: .command) 113 + static let quitApplication = AppShortcut(key: "q", modifiers: .command) 82 114 static let openSettings = AppShortcut(key: ",", modifiers: .command) 83 115 static let openFinder = AppShortcut(key: "o", modifiers: .command) 84 116 static let copyPath = AppShortcut(key: "c", modifiers: [.command, .shift]) 85 117 static let openRepository = AppShortcut(key: "o", modifiers: [.command, .shift]) 86 118 static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) 87 - static let toggleLeftSidebar = AppShortcut(key: "[", modifiers: .command) 119 + static let toggleLeftSidebar = AppShortcut(key: "s", modifiers: [.command, .control]) 88 120 static let refreshWorktrees = AppShortcut(key: "r", modifiers: [.command, .shift]) 89 121 static let runScript = AppShortcut(key: "r", modifiers: .command) 90 122 static let stopRunScript = AppShortcut(key: ".", modifiers: .command) 91 - static let checkForUpdates = AppShortcut(key: "u", modifiers: .command) 92 - static let showDiff = AppShortcut(key: "]", modifiers: .command) 123 + static let checkForUpdates = AppShortcut(key: "u", modifiers: [.command, .shift]) 124 + static let showDiff = AppShortcut(key: "y", modifiers: [.command, .shift]) 93 125 static let toggleCanvas = AppShortcut( 94 126 keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .option] 95 127 ) ··· 110 142 static let selectWorktree8 = AppShortcut(key: "8", modifiers: [.control]) 111 143 static let selectWorktree9 = AppShortcut(key: "9", modifiers: [.control]) 112 144 static let selectWorktree0 = AppShortcut(key: "0", modifiers: [.control]) 145 + static let renameBranch = AppShortcut(key: "m", modifiers: [.command, .shift]) 146 + static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) 113 147 static let worktreeSelection: [AppShortcut] = [ 114 148 selectWorktree1, 115 149 selectWorktree2, ··· 123 157 selectWorktree0, 124 158 ] 125 159 160 + private static let reservedCustomCommandBindings: [ReservedCustomCommandBinding] = [ 161 + .init(actionTitle: "Open Settings", shortcut: openSettings), 162 + .init(actionTitle: "Toggle Left Sidebar", shortcut: toggleLeftSidebar), 163 + .init(actionTitle: "Run Script", shortcut: runScript), 164 + .init(actionTitle: "Stop Script", shortcut: stopRunScript), 165 + .init(actionTitle: "Check for Updates", shortcut: checkForUpdates), 166 + .init(actionTitle: "Show Diff", shortcut: showDiff), 167 + .init(actionTitle: "Open Worktree", shortcut: openFinder), 168 + .init(actionTitle: "Open Repository", shortcut: openRepository), 169 + ] 170 + 171 + static let bindings: [Binding] = [ 172 + .init( 173 + id: "new_worktree", 174 + title: "New Worktree", 175 + scope: .configurableAppAction, 176 + shortcut: newWorktree 177 + ), 178 + .init( 179 + id: "open_settings", 180 + title: "Open Settings", 181 + scope: .configurableAppAction, 182 + shortcut: openSettings 183 + ), 184 + .init( 185 + id: "open_worktree", 186 + title: "Open Worktree", 187 + scope: .configurableAppAction, 188 + shortcut: openFinder 189 + ), 190 + .init( 191 + id: "copy_path", 192 + title: "Copy Path", 193 + scope: .configurableAppAction, 194 + shortcut: copyPath 195 + ), 196 + .init( 197 + id: "open_repository", 198 + title: "Open Repository", 199 + scope: .configurableAppAction, 200 + shortcut: openRepository 201 + ), 202 + .init( 203 + id: "open_pull_request", 204 + title: "Open Pull Request", 205 + scope: .configurableAppAction, 206 + shortcut: openPullRequest 207 + ), 208 + .init( 209 + id: "toggle_left_sidebar", 210 + title: "Toggle Left Sidebar", 211 + scope: .configurableAppAction, 212 + shortcut: toggleLeftSidebar 213 + ), 214 + .init( 215 + id: "refresh_worktrees", 216 + title: "Refresh Worktrees", 217 + scope: .configurableAppAction, 218 + shortcut: refreshWorktrees 219 + ), 220 + .init( 221 + id: "run_script", 222 + title: "Run Script", 223 + scope: .configurableAppAction, 224 + shortcut: runScript 225 + ), 226 + .init( 227 + id: "stop_script", 228 + title: "Stop Script", 229 + scope: .configurableAppAction, 230 + shortcut: stopRunScript 231 + ), 232 + .init( 233 + id: "check_for_updates", 234 + title: "Check for Updates", 235 + scope: .configurableAppAction, 236 + shortcut: checkForUpdates 237 + ), 238 + .init( 239 + id: "show_diff", 240 + title: "Show Diff", 241 + scope: .configurableAppAction, 242 + shortcut: showDiff 243 + ), 244 + .init( 245 + id: "toggle_canvas", 246 + title: "Toggle Canvas", 247 + scope: .configurableAppAction, 248 + shortcut: toggleCanvas 249 + ), 250 + .init( 251 + id: "archived_worktrees", 252 + title: "Archived Worktrees", 253 + scope: .configurableAppAction, 254 + shortcut: archivedWorktrees 255 + ), 256 + .init( 257 + id: "select_next_worktree", 258 + title: "Select Next Worktree", 259 + scope: .configurableAppAction, 260 + shortcut: selectNextWorktree 261 + ), 262 + .init( 263 + id: "select_previous_worktree", 264 + title: "Select Previous Worktree", 265 + scope: .configurableAppAction, 266 + shortcut: selectPreviousWorktree 267 + ), 268 + .init( 269 + id: "select_worktree_1", 270 + title: "Select Worktree 1", 271 + scope: .configurableAppAction, 272 + shortcut: selectWorktree1 273 + ), 274 + .init( 275 + id: "select_worktree_2", 276 + title: "Select Worktree 2", 277 + scope: .configurableAppAction, 278 + shortcut: selectWorktree2 279 + ), 280 + .init( 281 + id: "select_worktree_3", 282 + title: "Select Worktree 3", 283 + scope: .configurableAppAction, 284 + shortcut: selectWorktree3 285 + ), 286 + .init( 287 + id: "select_worktree_4", 288 + title: "Select Worktree 4", 289 + scope: .configurableAppAction, 290 + shortcut: selectWorktree4 291 + ), 292 + .init( 293 + id: "select_worktree_5", 294 + title: "Select Worktree 5", 295 + scope: .configurableAppAction, 296 + shortcut: selectWorktree5 297 + ), 298 + .init( 299 + id: "select_worktree_6", 300 + title: "Select Worktree 6", 301 + scope: .configurableAppAction, 302 + shortcut: selectWorktree6 303 + ), 304 + .init( 305 + id: "select_worktree_7", 306 + title: "Select Worktree 7", 307 + scope: .configurableAppAction, 308 + shortcut: selectWorktree7 309 + ), 310 + .init( 311 + id: "select_worktree_8", 312 + title: "Select Worktree 8", 313 + scope: .configurableAppAction, 314 + shortcut: selectWorktree8 315 + ), 316 + .init( 317 + id: "select_worktree_9", 318 + title: "Select Worktree 9", 319 + scope: .configurableAppAction, 320 + shortcut: selectWorktree9 321 + ), 322 + .init( 323 + id: "select_worktree_0", 324 + title: "Select Worktree 0", 325 + scope: .configurableAppAction, 326 + shortcut: selectWorktree0 327 + ), 328 + .init( 329 + id: "command_palette", 330 + title: "Command Palette", 331 + scope: .systemFixedAppAction, 332 + shortcut: commandPalette 333 + ), 334 + .init( 335 + id: "quit_application", 336 + title: "Quit Application", 337 + scope: .systemFixedAppAction, 338 + shortcut: quitApplication 339 + ), 340 + .init( 341 + id: "rename_branch", 342 + title: "Rename Branch", 343 + scope: .localInteraction, 344 + shortcut: renameBranch 345 + ), 346 + .init( 347 + id: "select_all_canvas_cards", 348 + title: "Select All Canvas Cards", 349 + scope: .localInteraction, 350 + shortcut: selectAllCanvasCards 351 + ), 352 + ] 353 + 354 + static func userOverrideConflicts( 355 + in commands: [UserCustomCommand] 356 + ) -> [CustomCommandOverrideConflict] { 357 + var seen = Set<String>() 358 + return commands.compactMap { command in 359 + guard let shortcut = command.shortcut?.normalized(), shortcut.isValid else { return nil } 360 + guard let appBinding = matchingReservedBinding(for: shortcut) else { return nil } 361 + 362 + let signature = 363 + "\(command.id)|\(shortcut.display)|\(appBinding.actionTitle)|\(appBinding.shortcut.display)" 364 + guard seen.insert(signature).inserted else { return nil } 365 + 366 + return CustomCommandOverrideConflict( 367 + commandTitle: command.resolvedTitle, 368 + commandShortcutDisplay: shortcut.display, 369 + appActionTitle: appBinding.actionTitle, 370 + appShortcutDisplay: appBinding.shortcut.display 371 + ) 372 + } 373 + } 374 + 375 + private static func matchingReservedBinding( 376 + for shortcut: UserCustomShortcut 377 + ) -> ReservedCustomCommandBinding? { 378 + guard let key = shortcut.normalizedConflictKey else { return nil } 379 + let modifiers = shortcut.modifiers.eventModifiers 380 + return reservedCustomCommandBindings.first { 381 + $0.shortcut.normalizedConflictKey == key && $0.shortcut.modifiers == modifiers 382 + } 383 + } 384 + 126 385 static let tabSelectionGhosttyKeybindArguments: [String] = tabSelectionBindings.flatMap { binding in 127 386 [ 128 387 "--keybind=ctrl+\(binding.unicode)=goto_tab:\(binding.tabIndex)", ··· 163 422 selectWorktree0, 164 423 ] 165 424 } 425 + 426 + extension UserCustomShortcut { 427 + fileprivate var normalizedConflictKey: String? { 428 + let normalized = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 429 + guard normalized.count == 1 else { return nil } 430 + return normalized 431 + } 432 + }
+2 -2
supacode/App/OnevcatCustomShortcut+SwiftUI.swift
··· 1 1 import AppKit 2 2 import SwiftUI 3 3 4 - extension OnevcatCustomShortcut { 4 + extension UserCustomShortcut { 5 5 var keyboardShortcut: KeyboardShortcut? { 6 6 guard let keyEquivalent else { return nil } 7 7 return KeyboardShortcut(keyEquivalent, modifiers: modifiers.eventModifiers) ··· 30 30 } 31 31 } 32 32 33 - extension OnevcatCustomShortcutModifiers { 33 + extension UserCustomShortcutModifiers { 34 34 var eventModifiers: EventModifiers { 35 35 var modifiers: EventModifiers = [] 36 36 if command {
+10 -23
supacode/App/supacodeApp.swift
··· 191 191 Button("Command Palette") { 192 192 store.send(.commandPalette(.togglePresented)) 193 193 } 194 - .keyboardShortcut("p", modifiers: .command) 195 - .help("Command Palette (⌘P)") 194 + .keyboardShortcut( 195 + AppShortcuts.commandPalette.keyEquivalent, 196 + modifiers: AppShortcuts.commandPalette.modifiers 197 + ) 198 + .help("Command Palette (\(AppShortcuts.commandPalette.display))") 196 199 } 197 200 UpdateCommands(store: store.scope(state: \.updates, action: \.updates)) 198 - CommandGroup(replacing: .windowArrangement) { 199 - Button("Prowl") { 200 - if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { 201 - window.makeKeyAndOrderFront(nil) 202 - NSApp.activate(ignoringOtherApps: true) 203 - } 204 - } 205 - .help("Show main window") 206 - Divider() 207 - Button("Minimize") { 208 - NSApp.keyWindow?.miniaturize(nil) 209 - } 210 - .keyboardShortcut("m") 211 - .help("Minimize (⌘M)") 212 - Button("Zoom") { 213 - NSApp.keyWindow?.zoom(nil) 214 - } 215 - .help("Zoom (no shortcut)") 216 - } 217 201 CommandGroup(replacing: .appSettings) { 218 202 Button("Settings...") { 219 203 SettingsWindowManager.shared.show() ··· 227 211 Button("Quit Prowl") { 228 212 store.send(.requestQuit) 229 213 } 230 - .keyboardShortcut("q") 231 - .help("Quit Prowl (⌘Q)") 214 + .keyboardShortcut( 215 + AppShortcuts.quitApplication.keyEquivalent, 216 + modifiers: AppShortcuts.quitApplication.modifiers 217 + ) 218 + .help("Quit Prowl (\(AppShortcuts.quitApplication.display))") 232 219 } 233 220 } 234 221 }
+24
supacode/Clients/Shortcuts/CustomShortcutRegistryClient.swift
··· 1 + import ComposableArchitecture 2 + 3 + struct CustomShortcutRegistryClient { 4 + var setShortcuts: @MainActor @Sendable ([UserCustomShortcut]) -> Void 5 + } 6 + 7 + extension CustomShortcutRegistryClient: DependencyKey { 8 + static let liveValue = Self( 9 + setShortcuts: { shortcuts in 10 + UserCustomShortcutRegistry.shared.setShortcuts(shortcuts) 11 + } 12 + ) 13 + 14 + static let testValue = Self( 15 + setShortcuts: { _ in } 16 + ) 17 + } 18 + 19 + extension DependencyValues { 20 + var customShortcutRegistryClient: CustomShortcutRegistryClient { 21 + get { self[CustomShortcutRegistryClient.self] } 22 + set { self[CustomShortcutRegistryClient.self] = newValue } 23 + } 24 + }
+5 -1
supacode/Commands/UpdateCommands.swift
··· 9 9 Button("Check for Updates...") { 10 10 store.send(.checkForUpdates) 11 11 } 12 - .help("Check for updates") 12 + .keyboardShortcut( 13 + AppShortcuts.checkForUpdates.keyEquivalent, 14 + modifiers: AppShortcuts.checkForUpdates.modifiers 15 + ) 16 + .help("Check for Updates (\(AppShortcuts.checkForUpdates.display))") 13 17 } 14 18 } 15 19 }
+167
supacode/Commands/WindowCommands.swift
··· 3 3 struct WindowCommands: Commands { 4 4 let ghosttyShortcuts: GhosttyShortcutManager 5 5 @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction 6 + @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction 7 + @FocusedValue(\.selectNextTerminalTabAction) private var selectNextTerminalTabAction 8 + @FocusedValue(\.selectPreviousTerminalPaneAction) private var selectPreviousTerminalPaneAction 9 + @FocusedValue(\.selectNextTerminalPaneAction) private var selectNextTerminalPaneAction 10 + @FocusedValue(\.selectTerminalPaneAboveAction) private var selectTerminalPaneAboveAction 11 + @FocusedValue(\.selectTerminalPaneBelowAction) private var selectTerminalPaneBelowAction 12 + @FocusedValue(\.selectTerminalPaneLeftAction) private var selectTerminalPaneLeftAction 13 + @FocusedValue(\.selectTerminalPaneRightAction) private var selectTerminalPaneRightAction 6 14 7 15 var body: some Commands { 8 16 let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") ··· 18 26 ) 19 27 ) 20 28 } 29 + 30 + CommandGroup(replacing: .windowArrangement) { 31 + Button("Prowl") { 32 + if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) { 33 + window.makeKeyAndOrderFront(nil) 34 + NSApp.activate(ignoringOtherApps: true) 35 + } 36 + } 37 + .help("Show main window") 38 + Divider() 39 + 40 + Button("Select Previous Tab") { 41 + selectPreviousTerminalTabAction?() 42 + } 43 + .modifier( 44 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "previous_tab")) 45 + ) 46 + .disabled(selectPreviousTerminalTabAction == nil) 47 + 48 + Button("Select Next Tab") { 49 + selectNextTerminalTabAction?() 50 + } 51 + .modifier( 52 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "next_tab")) 53 + ) 54 + .disabled(selectNextTerminalTabAction == nil) 55 + 56 + Divider() 57 + 58 + Button("Select Previous Pane") { 59 + selectPreviousTerminalPaneAction?() 60 + } 61 + .modifier( 62 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "goto_split:previous")) 63 + ) 64 + .disabled(selectPreviousTerminalPaneAction == nil) 65 + 66 + Button("Select Next Pane") { 67 + selectNextTerminalPaneAction?() 68 + } 69 + .modifier( 70 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "goto_split:next")) 71 + ) 72 + .disabled(selectNextTerminalPaneAction == nil) 73 + 74 + Menu("Select Pane") { 75 + Button("Select Pane Above") { 76 + selectTerminalPaneAboveAction?() 77 + } 78 + .keyboardShortcut(.upArrow, modifiers: [.command, .option]) 79 + .disabled(selectTerminalPaneAboveAction == nil) 80 + 81 + Button("Select Pane Below") { 82 + selectTerminalPaneBelowAction?() 83 + } 84 + .keyboardShortcut(.downArrow, modifiers: [.command, .option]) 85 + .disabled(selectTerminalPaneBelowAction == nil) 86 + 87 + Button("Select Pane Left") { 88 + selectTerminalPaneLeftAction?() 89 + } 90 + .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) 91 + .disabled(selectTerminalPaneLeftAction == nil) 92 + 93 + Button("Select Pane Right") { 94 + selectTerminalPaneRightAction?() 95 + } 96 + .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) 97 + .disabled(selectTerminalPaneRightAction == nil) 98 + } 99 + } 21 100 } 22 101 } 23 102 ··· 32 111 } 33 112 } 34 113 } 114 + 115 + private struct SelectPreviousTerminalTabActionKey: FocusedValueKey { 116 + typealias Value = () -> Void 117 + } 118 + 119 + extension FocusedValues { 120 + var selectPreviousTerminalTabAction: (() -> Void)? { 121 + get { self[SelectPreviousTerminalTabActionKey.self] } 122 + set { self[SelectPreviousTerminalTabActionKey.self] = newValue } 123 + } 124 + } 125 + 126 + private struct SelectNextTerminalTabActionKey: FocusedValueKey { 127 + typealias Value = () -> Void 128 + } 129 + 130 + extension FocusedValues { 131 + var selectNextTerminalTabAction: (() -> Void)? { 132 + get { self[SelectNextTerminalTabActionKey.self] } 133 + set { self[SelectNextTerminalTabActionKey.self] = newValue } 134 + } 135 + } 136 + 137 + private struct SelectPreviousTerminalPaneActionKey: FocusedValueKey { 138 + typealias Value = () -> Void 139 + } 140 + 141 + extension FocusedValues { 142 + var selectPreviousTerminalPaneAction: (() -> Void)? { 143 + get { self[SelectPreviousTerminalPaneActionKey.self] } 144 + set { self[SelectPreviousTerminalPaneActionKey.self] = newValue } 145 + } 146 + } 147 + 148 + private struct SelectNextTerminalPaneActionKey: FocusedValueKey { 149 + typealias Value = () -> Void 150 + } 151 + 152 + extension FocusedValues { 153 + var selectNextTerminalPaneAction: (() -> Void)? { 154 + get { self[SelectNextTerminalPaneActionKey.self] } 155 + set { self[SelectNextTerminalPaneActionKey.self] = newValue } 156 + } 157 + } 158 + 159 + private struct SelectTerminalPaneAboveActionKey: FocusedValueKey { 160 + typealias Value = () -> Void 161 + } 162 + 163 + extension FocusedValues { 164 + var selectTerminalPaneAboveAction: (() -> Void)? { 165 + get { self[SelectTerminalPaneAboveActionKey.self] } 166 + set { self[SelectTerminalPaneAboveActionKey.self] = newValue } 167 + } 168 + } 169 + 170 + private struct SelectTerminalPaneBelowActionKey: FocusedValueKey { 171 + typealias Value = () -> Void 172 + } 173 + 174 + extension FocusedValues { 175 + var selectTerminalPaneBelowAction: (() -> Void)? { 176 + get { self[SelectTerminalPaneBelowActionKey.self] } 177 + set { self[SelectTerminalPaneBelowActionKey.self] = newValue } 178 + } 179 + } 180 + 181 + private struct SelectTerminalPaneLeftActionKey: FocusedValueKey { 182 + typealias Value = () -> Void 183 + } 184 + 185 + extension FocusedValues { 186 + var selectTerminalPaneLeftAction: (() -> Void)? { 187 + get { self[SelectTerminalPaneLeftActionKey.self] } 188 + set { self[SelectTerminalPaneLeftActionKey.self] = newValue } 189 + } 190 + } 191 + 192 + private struct SelectTerminalPaneRightActionKey: FocusedValueKey { 193 + typealias Value = () -> Void 194 + } 195 + 196 + extension FocusedValues { 197 + var selectTerminalPaneRightAction: (() -> Void)? { 198 + get { self[SelectTerminalPaneRightActionKey.self] } 199 + set { self[SelectTerminalPaneRightActionKey.self] = newValue } 200 + } 201 + }
+1 -1
supacode/Commands/WorktreeCommands.swift
··· 187 187 @ViewBuilder 188 188 private func customCommandButton( 189 189 index: Int, 190 - command: OnevcatCustomCommand, 190 + command: UserCustomCommand, 191 191 hasActiveWorktree: Bool 192 192 ) -> some View { 193 193 let title = command.resolvedTitle
+24 -20
supacode/Features/App/Reducer/AppFeature.swift
··· 18 18 var commandPalette = CommandPaletteFeature.State() 19 19 var openActionSelection: OpenWorktreeAction = .finder 20 20 var selectedRunScript: String = "" 21 - var selectedCustomCommands: [OnevcatCustomCommand] = [] 21 + var selectedCustomCommands: [UserCustomCommand] = [] 22 22 var runScriptDraft: String = "" 23 23 var isRunScriptPromptPresented = false 24 24 var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] ··· 45 45 case commandPalette(CommandPaletteFeature.Action) 46 46 case openActionSelectionChanged(OpenWorktreeAction) 47 47 case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) 48 - case worktreeOnevcatSettingsLoaded(OnevcatRepositorySettings, worktreeID: Worktree.ID) 48 + case worktreeUserSettingsLoaded(UserRepositorySettings, worktreeID: Worktree.ID) 49 49 case openSelectedWorktree 50 50 case openWorktree(OpenWorktreeAction) 51 51 case openWorktreeFailed(OpenActionError) ··· 82 82 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 83 83 @Dependency(TerminalClient.self) private var terminalClient 84 84 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 85 + @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient 85 86 86 87 var body: some Reducer<State, Action> { 87 88 let core = Reduce<State, Action> { state, action in ··· 159 160 return .merge( 160 161 .merge(effects), 161 162 .run { _ in 162 - await MainActor.run { 163 - OnevcatCustomShortcutRegistry.shared.setShortcuts([]) 164 - } 163 + await customShortcutRegistryClient.setShortcuts([]) 165 164 } 166 165 ) 167 166 } ··· 171 170 state.runScriptDraft = "" 172 171 state.isRunScriptPromptPresented = false 173 172 @Shared(.repositorySettings(rootURL)) var repositorySettings 174 - @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 173 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 175 174 let settings = repositorySettings 176 - let onevcatSettings = onevcatRepositorySettings 175 + let userSettings = userRepositorySettings 177 176 var effects: [Effect<Action>] = [] 178 177 if !isPlainFolderSelection { 179 178 effects.append( ··· 194 193 ) 195 194 effects.append( 196 195 .run { _ in 197 - await MainActor.run { 198 - OnevcatCustomShortcutRegistry.shared.setShortcuts([]) 199 - } 196 + await customShortcutRegistryClient.setShortcuts([]) 200 197 } 201 198 ) 202 199 effects.append( 203 200 .concatenate( 204 201 .send(.worktreeSettingsLoaded(settings, worktreeID: worktreeID)), 205 - .send(.worktreeOnevcatSettingsLoaded(onevcatSettings, worktreeID: worktreeID)) 202 + .send(.worktreeUserSettingsLoaded(userSettings, worktreeID: worktreeID)) 206 203 ) 207 204 ) 208 205 return .merge(effects) ··· 271 268 return .none 272 269 } 273 270 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 274 - @Shared(.onevcatRepositorySettings(repository.rootURL)) var onevcatRepositorySettings 271 + @Shared(.userRepositorySettings(repository.rootURL)) var userRepositorySettings 275 272 state.settings.repositorySettings = RepositorySettingsFeature.State( 276 273 rootURL: repository.rootURL, 277 274 repositoryKind: repository.kind, 278 275 settings: repositorySettings, 279 - onevcatSettings: onevcatRepositorySettings 276 + userSettings: userRepositorySettings 280 277 ) 281 278 case .general, .notifications, .worktree, .updates, .advanced, .github: 282 279 state.settings.repositorySettings = nil ··· 599 596 } 600 597 let worktreeID = selectedWorktree.id 601 598 @Shared(.repositorySettings(rootURL)) var repositorySettings 602 - @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 599 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 603 600 return .concatenate( 604 601 .send(.worktreeSettingsLoaded(repositorySettings, worktreeID: worktreeID)), 605 - .send(.worktreeOnevcatSettingsLoaded(onevcatRepositorySettings, worktreeID: worktreeID)) 602 + .send(.worktreeUserSettingsLoaded(userRepositorySettings, worktreeID: worktreeID)) 606 603 ) 607 604 608 605 case .worktreeSettingsLoaded(let settings, let worktreeID): ··· 620 617 state.selectedRunScript = settings.runScript 621 618 return .none 622 619 623 - case .worktreeOnevcatSettingsLoaded(let settings, let worktreeID): 620 + case .worktreeUserSettingsLoaded(let settings, let worktreeID): 624 621 guard state.repositories.selectedTerminalWorktree?.id == worktreeID else { 625 622 return .none 626 623 } 627 - state.selectedCustomCommands = OnevcatRepositorySettings.normalizedCommands(settings.customCommands) 624 + state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) 628 625 .filter(\.hasRunnableCommand) 629 - let shortcuts: [OnevcatCustomShortcut] = state.selectedCustomCommands.compactMap { command in 626 + let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) 627 + let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in 630 628 guard let shortcut = command.shortcut, shortcut.isValid else { return nil } 631 629 return shortcut.normalized() 632 630 } 633 631 return .run { _ in 634 - await MainActor.run { 635 - OnevcatCustomShortcutRegistry.shared.setShortcuts(shortcuts) 632 + let logger = SupaLogger("Shortcuts") 633 + for conflict in userOverrideConflicts { 634 + logger.warning( 635 + "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " 636 + + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " 637 + + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" 638 + ) 636 639 } 640 + await customShortcutRegistryClient.setShortcuts(shortcuts) 637 641 } 638 642 639 643 case .systemNotificationsPermissionFailed(let errorMessage):
+3 -3
supacode/Features/Canvas/Views/CanvasView.swift
··· 153 153 clearSelection(states: terminalManager.activeWorktreeStates) 154 154 return .handled 155 155 } 156 - .onKeyPress("a", phases: .down) { keyPress in 157 - guard keyPress.modifiers == [.command, .option] else { return .ignored } 156 + .onKeyPress(AppShortcuts.selectAllCanvasCards.keyEquivalent, phases: .down) { keyPress in 157 + guard keyPress.modifiers == AppShortcuts.selectAllCanvasCards.modifiers else { return .ignored } 158 158 selectAllCards() 159 159 return .handled 160 160 } ··· 423 423 .accessibilityLabel("Select All") 424 424 } 425 425 .buttonStyle(.bordered) 426 - .help("Select all cards for broadcast (⌘⌥A)") 426 + .help("Select all cards for broadcast (\(AppShortcuts.selectAllCanvasCards.display))") 427 427 428 428 Button { 429 429 withAnimation(.easeInOut(duration: 0.2)) {
+1 -1
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 96 96 var appShortcut: AppShortcut? { 97 97 switch kind { 98 98 case .checkForUpdates: 99 - return nil 99 + return AppShortcuts.checkForUpdates 100 100 case .openRepository: 101 101 return AppShortcuts.openRepository 102 102 case .openSettings:
+1 -1
supacode/Features/Repositories/Views/DetailToolbarTitle.swift
··· 27 27 var helpText: String? { 28 28 switch kind { 29 29 case .branch: 30 - return "Rename branch (⌘M)" 30 + return "Rename branch (\(AppShortcuts.renameBranch.display))" 31 31 case .folder: 32 32 return nil 33 33 }
+4 -1
supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift
··· 18 18 labelContent 19 19 } 20 20 .help(title.helpText ?? "") 21 - .keyboardShortcut("m", modifiers: .command) 21 + .keyboardShortcut( 22 + AppShortcuts.renameBranch.keyEquivalent, 23 + modifiers: AppShortcuts.renameBranch.modifiers 24 + ) 22 25 } else { 23 26 labelContent 24 27 }
+44 -9
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 12 12 let showExtras: Bool 13 13 let runScriptEnabled: Bool 14 14 let runScriptIsRunning: Bool 15 - let customCommands: [OnevcatCustomCommand] 15 + let customCommands: [UserCustomCommand] 16 16 } 17 17 18 18 @Bindable var store: StoreOf<AppFeature> ··· 244 244 .focusedSceneValue(\.navigateSearchNextAction, actions.navigateSearchNext) 245 245 .focusedSceneValue(\.navigateSearchPreviousAction, actions.navigateSearchPrevious) 246 246 .focusedSceneValue(\.endSearchAction, actions.endSearch) 247 + .focusedSceneValue(\.selectPreviousTerminalTabAction, actions.selectPreviousTerminalTab) 248 + .focusedSceneValue(\.selectNextTerminalTabAction, actions.selectNextTerminalTab) 249 + .focusedSceneValue(\.selectPreviousTerminalPaneAction, actions.selectPreviousTerminalPane) 250 + .focusedSceneValue(\.selectNextTerminalPaneAction, actions.selectNextTerminalPane) 251 + .focusedSceneValue(\.selectTerminalPaneAboveAction, actions.selectTerminalPaneAbove) 252 + .focusedSceneValue(\.selectTerminalPaneBelowAction, actions.selectTerminalPaneBelow) 253 + .focusedSceneValue(\.selectTerminalPaneLeftAction, actions.selectTerminalPaneLeft) 254 + .focusedSceneValue(\.selectTerminalPaneRightAction, actions.selectTerminalPaneRight) 247 255 .focusedSceneValue(\.runScriptAction, actions.runScript) 248 256 .focusedSceneValue(\.stopRunScriptAction, actions.stopRunScript) 249 257 } ··· 287 295 } 288 296 } 289 297 298 + func terminalBindingAction(_ bindingAction: String) -> (() -> Void)? { 299 + if let action = canvasAction({ $0.performBindingActionOnFocusedSurface(bindingAction) }) { 300 + return action 301 + } 302 + guard hasActiveWorktree, let selectedWorktree = repositories.selectedTerminalWorktree else { return nil } 303 + return { 304 + guard let state = terminalManager.stateIfExists(for: selectedWorktree.id) else { return } 305 + _ = state.performBindingActionOnFocusedSurface(bindingAction) 306 + } 307 + } 308 + 290 309 return FocusedActions( 291 310 openSelectedWorktree: action(.openSelectedWorktree), 292 311 newTerminal: action(.newTerminal), ··· 300 319 navigateSearchNext: action(.navigateSearchNext), 301 320 navigateSearchPrevious: action(.navigateSearchPrevious), 302 321 endSearch: action(.endSearch), 322 + selectPreviousTerminalTab: terminalBindingAction("previous_tab"), 323 + selectNextTerminalTab: terminalBindingAction("next_tab"), 324 + selectPreviousTerminalPane: terminalBindingAction("goto_split:previous"), 325 + selectNextTerminalPane: terminalBindingAction("goto_split:next"), 326 + selectTerminalPaneAbove: terminalBindingAction("goto_split:up"), 327 + selectTerminalPaneBelow: terminalBindingAction("goto_split:down"), 328 + selectTerminalPaneLeft: terminalBindingAction("goto_split:left"), 329 + selectTerminalPaneRight: terminalBindingAction("goto_split:right"), 303 330 runScript: runScriptEnabled ? { store.send(.runScript) } : nil, 304 331 stopRunScript: runScriptIsRunning ? { store.send(.stopRunScript) } : nil 305 332 ) ··· 336 363 let navigateSearchNext: (() -> Void)? 337 364 let navigateSearchPrevious: (() -> Void)? 338 365 let endSearch: (() -> Void)? 366 + let selectPreviousTerminalTab: (() -> Void)? 367 + let selectNextTerminalTab: (() -> Void)? 368 + let selectPreviousTerminalPane: (() -> Void)? 369 + let selectNextTerminalPane: (() -> Void)? 370 + let selectTerminalPaneAbove: (() -> Void)? 371 + let selectTerminalPaneBelow: (() -> Void)? 372 + let selectTerminalPaneLeft: (() -> Void)? 373 + let selectTerminalPaneRight: (() -> Void)? 339 374 let runScript: (() -> Void)? 340 375 let stopRunScript: (() -> Void)? 341 376 } ··· 350 385 let showExtras: Bool 351 386 let runScriptEnabled: Bool 352 387 let runScriptIsRunning: Bool 353 - let customCommands: [OnevcatCustomCommand] 388 + let customCommands: [UserCustomCommand] 354 389 355 390 var runScriptHelpText: String { 356 391 "Run Script (\(AppShortcuts.runScript.display))" ··· 497 532 } 498 533 } 499 534 500 - private func customCommand(at index: Int) -> OnevcatCustomCommand? { 535 + private func customCommand(at index: Int) -> UserCustomCommand? { 501 536 guard toolbarState.customCommands.indices.contains(index) else { 502 537 return nil 503 538 } 504 539 return toolbarState.customCommands[index] 505 540 } 506 541 507 - private func customCommandButton(_ command: OnevcatCustomCommand, index: Int) -> some View { 508 - OnevcatCustomCommandToolbarButton( 542 + private func customCommandButton(_ command: UserCustomCommand, index: Int) -> some View { 543 + UserCustomCommandToolbarButton( 509 544 title: command.resolvedTitle, 510 545 systemImage: command.resolvedSystemImage, 511 546 shortcut: command.shortcut?.isValid == true ? command.shortcut?.display : nil, ··· 683 718 } 684 719 } 685 720 686 - private struct OnevcatCustomCommandToolbarButton: View { 721 + private struct UserCustomCommandToolbarButton: View { 687 722 let title: String 688 723 let systemImage: String 689 724 let shortcut: String? ··· 734 769 runScriptEnabled: true, 735 770 runScriptIsRunning: false, 736 771 customCommands: [ 737 - OnevcatCustomCommand( 772 + UserCustomCommand( 738 773 title: "Test", 739 774 systemImage: "checkmark.circle.fill", 740 775 command: "swift test", 741 776 execution: .shellScript, 742 - shortcut: OnevcatCustomShortcut( 777 + shortcut: UserCustomShortcut( 743 778 key: "u", 744 - modifiers: OnevcatCustomShortcutModifiers() 779 + modifiers: UserCustomShortcutModifiers() 745 780 ) 746 781 ), 747 782 ]
+22 -11
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 8 8 var rootURL: URL 9 9 var repositoryKind: Repository.Kind 10 10 var settings: RepositorySettings 11 - var onevcatSettings: OnevcatRepositorySettings 11 + var userSettings: UserRepositorySettings 12 12 var globalDefaultWorktreeBaseDirectoryPath: String? 13 13 var isBareRepository = false 14 14 var branchOptions: [String] = [] ··· 61 61 case task 62 62 case settingsLoaded( 63 63 RepositorySettings, 64 - OnevcatRepositorySettings, 64 + UserRepositorySettings, 65 65 isBareRepository: Bool, 66 66 globalDefaultWorktreeBaseDirectoryPath: String? 67 67 ) ··· 84 84 case .task: 85 85 let rootURL = state.rootURL 86 86 @Shared(.repositorySettings(rootURL)) var repositorySettings 87 - @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 87 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 88 88 @Shared(.settingsFile) var settingsFile 89 89 let settings = repositorySettings 90 - let onevcatSettings = onevcatRepositorySettings 90 + let userSettings = userRepositorySettings 91 91 let globalDefaultWorktreeBaseDirectoryPath = 92 92 settingsFile.global.defaultWorktreeBaseDirectoryPath 93 93 guard state.capabilities.supportsRepositoryGitSettings else { 94 94 return .send( 95 95 .settingsLoaded( 96 96 settings, 97 - onevcatSettings, 97 + userSettings, 98 98 isBareRepository: false, 99 99 globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 100 100 ) ··· 106 106 await send( 107 107 .settingsLoaded( 108 108 settings, 109 - onevcatSettings, 109 + userSettings, 110 110 isBareRepository: isBareRepository, 111 111 globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 112 112 ) ··· 126 126 } 127 127 128 128 case .settingsLoaded( 129 - let settings, let onevcatSettings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath 129 + let settings, let userSettings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath 130 130 ): 131 131 var updatedSettings = settings 132 132 updatedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( ··· 138 138 updatedSettings.copyUntrackedOnWorktreeCreate = false 139 139 } 140 140 state.settings = updatedSettings 141 - state.onevcatSettings = onevcatSettings.normalized() 141 + state.userSettings = userSettings.normalized() 142 142 state.globalDefaultWorktreeBaseDirectoryPath = 143 143 SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) 144 144 state.isBareRepository = isBareRepository ··· 166 166 state.settings.copyIgnoredOnWorktreeCreate = false 167 167 state.settings.copyUntrackedOnWorktreeCreate = false 168 168 } 169 - state.onevcatSettings = state.onevcatSettings.normalized() 169 + state.userSettings = state.userSettings.normalized() 170 170 let rootURL = state.rootURL 171 171 var normalizedSettings = state.settings 172 172 normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( ··· 174 174 repositoryRootURL: rootURL 175 175 ) 176 176 @Shared(.repositorySettings(rootURL)) var repositorySettings 177 - @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 177 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 178 + let previousUserSettings = userRepositorySettings 178 179 $repositorySettings.withLock { $0 = normalizedSettings } 179 - $onevcatRepositorySettings.withLock { $0 = state.onevcatSettings } 180 + $userRepositorySettings.withLock { $0 = state.userSettings } 181 + if previousUserSettings != state.userSettings { 182 + let logger = SupaLogger("Settings") 183 + for conflict in AppShortcuts.userOverrideConflicts(in: state.userSettings.customCommands) { 184 + logger.warning( 185 + "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " 186 + + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " 187 + + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" 188 + ) 189 + } 190 + } 180 191 return .send(.delegate(.settingsChanged(rootURL))) 181 192 182 193 case .delegate:
+21 -21
supacode/Features/Settings/BusinessLogic/OnevcatRepositorySettingsKey.swift supacode/Features/Settings/BusinessLogic/UserRepositorySettingsKey.swift
··· 2 2 import Foundation 3 3 import Sharing 4 4 5 - nonisolated struct OnevcatRepositorySettingsKeyID: Hashable, Sendable { 5 + nonisolated struct UserRepositorySettingsKeyID: Hashable, Sendable { 6 6 let repositoryID: String 7 7 } 8 8 9 - nonisolated struct OnevcatRepositorySettingsKey: SharedKey { 9 + nonisolated struct UserRepositorySettingsKey: SharedKey { 10 10 let repositoryID: String 11 11 let rootURL: URL 12 12 ··· 15 15 repositoryID = self.rootURL.path(percentEncoded: false) 16 16 } 17 17 18 - var id: OnevcatRepositorySettingsKeyID { 19 - OnevcatRepositorySettingsKeyID(repositoryID: repositoryID) 18 + var id: UserRepositorySettingsKeyID { 19 + UserRepositorySettingsKeyID(repositoryID: repositoryID) 20 20 } 21 21 22 22 func load( 23 - context: LoadContext<OnevcatRepositorySettings>, 24 - continuation: LoadContinuation<OnevcatRepositorySettings> 23 + context: LoadContext<UserRepositorySettings>, 24 + continuation: LoadContinuation<UserRepositorySettings> 25 25 ) { 26 26 @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 27 - let settingsURL = SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 27 + let settingsURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 28 28 let decoder = JSONDecoder() 29 29 if let localData = try? repositoryLocalSettingsStorage.load(settingsURL) { 30 - if let settings = try? decoder.decode(OnevcatRepositorySettings.self, from: localData) { 30 + if let settings = try? decoder.decode(UserRepositorySettings.self, from: localData) { 31 31 continuation.resume(returning: settings.normalized()) 32 32 return 33 33 } 34 34 let path = settingsURL.path(percentEncoded: false) 35 35 SupaLogger("Settings").warning( 36 - "Unable to decode onevcat repository settings at \(path); trying legacy settings." 36 + "Unable to decode user repository settings at \(path); trying legacy settings." 37 37 ) 38 38 } 39 39 40 - let legacySettingsURL = SupacodePaths.legacyOnevcatRepositorySettingsURL(for: rootURL) 40 + let legacySettingsURL = SupacodePaths.legacyUserRepositorySettingsURL(for: rootURL) 41 41 if let legacyData = try? repositoryLocalSettingsStorage.load(legacySettingsURL) { 42 - if let legacySettings = try? decoder.decode(OnevcatRepositorySettings.self, from: legacyData) { 42 + if let legacySettings = try? decoder.decode(UserRepositorySettings.self, from: legacyData) { 43 43 let normalized = legacySettings.normalized() 44 44 do { 45 45 let encoder = JSONEncoder() ··· 49 49 } catch { 50 50 let path = settingsURL.path(percentEncoded: false) 51 51 SupaLogger("Settings").warning( 52 - "Unable to write onevcat repository settings to \(path): \(error.localizedDescription)" 52 + "Unable to write user repository settings to \(path): \(error.localizedDescription)" 53 53 ) 54 54 } 55 55 continuation.resume(returning: normalized) ··· 57 57 } 58 58 let path = legacySettingsURL.path(percentEncoded: false) 59 59 SupaLogger("Settings").warning( 60 - "Unable to decode legacy onevcat repository settings at \(path); using defaults." 60 + "Unable to decode legacy user repository settings at \(path); using defaults." 61 61 ) 62 62 } 63 63 ··· 70 70 } catch { 71 71 let path = settingsURL.path(percentEncoded: false) 72 72 SupaLogger("Settings").warning( 73 - "Unable to write onevcat repository settings to \(path): \(error.localizedDescription)" 73 + "Unable to write user repository settings to \(path): \(error.localizedDescription)" 74 74 ) 75 75 } 76 76 ··· 78 78 } 79 79 80 80 func subscribe( 81 - context _: LoadContext<OnevcatRepositorySettings>, 82 - subscriber _: SharedSubscriber<OnevcatRepositorySettings> 81 + context _: LoadContext<UserRepositorySettings>, 82 + subscriber _: SharedSubscriber<UserRepositorySettings> 83 83 ) -> SharedSubscription { 84 84 SharedSubscription {} 85 85 } 86 86 87 87 func save( 88 - _ value: OnevcatRepositorySettings, 88 + _ value: UserRepositorySettings, 89 89 context _: SaveContext, 90 90 continuation: SaveContinuation 91 91 ) { 92 92 @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 93 - let settingsURL = SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 93 + let settingsURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 94 94 do { 95 95 let encoder = JSONEncoder() 96 96 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] ··· 103 103 } 104 104 } 105 105 106 - nonisolated extension SharedReaderKey where Self == OnevcatRepositorySettingsKey.Default { 107 - static func onevcatRepositorySettings(_ rootURL: URL) -> Self { 108 - Self[OnevcatRepositorySettingsKey(rootURL: rootURL), default: .default] 106 + nonisolated extension SharedReaderKey where Self == UserRepositorySettingsKey.Default { 107 + static func userRepositorySettings(_ rootURL: URL) -> Self { 108 + Self[UserRepositorySettingsKey(rootURL: rootURL), default: .default] 109 109 } 110 110 }
+24 -24
supacode/Features/Settings/Models/OnevcatRepositorySettings.swift supacode/Features/Settings/Models/UserRepositorySettings.swift
··· 1 1 import Foundation 2 2 3 - nonisolated struct OnevcatRepositorySettings: Codable, Equatable, Sendable { 3 + nonisolated struct UserRepositorySettings: Codable, Equatable, Sendable { 4 4 static let maxCustomCommands = 3 5 5 6 - var customCommands: [OnevcatCustomCommand] 6 + var customCommands: [UserCustomCommand] 7 7 8 - static let `default` = OnevcatRepositorySettings(customCommands: []) 8 + static let `default` = UserRepositorySettings(customCommands: []) 9 9 10 10 private enum CodingKeys: String, CodingKey { 11 11 case customCommands 12 12 } 13 13 14 - init(customCommands: [OnevcatCustomCommand]) { 14 + init(customCommands: [UserCustomCommand]) { 15 15 self.customCommands = Self.normalizedCommands(customCommands) 16 16 } 17 17 18 18 init(from decoder: Decoder) throws { 19 19 let container = try decoder.container(keyedBy: CodingKeys.self) 20 - let commands = try container.decodeIfPresent([OnevcatCustomCommand].self, forKey: .customCommands) ?? [] 20 + let commands = try container.decodeIfPresent([UserCustomCommand].self, forKey: .customCommands) ?? [] 21 21 customCommands = Self.normalizedCommands(commands) 22 22 } 23 23 24 - func normalized() -> OnevcatRepositorySettings { 25 - OnevcatRepositorySettings(customCommands: customCommands) 24 + func normalized() -> UserRepositorySettings { 25 + UserRepositorySettings(customCommands: customCommands) 26 26 } 27 27 28 - static func normalizedCommands(_ commands: [OnevcatCustomCommand]) -> [OnevcatCustomCommand] { 28 + static func normalizedCommands(_ commands: [UserCustomCommand]) -> [UserCustomCommand] { 29 29 Array(commands.prefix(maxCustomCommands)).map { $0.normalized() } 30 30 } 31 31 } 32 32 33 - nonisolated struct OnevcatCustomCommand: Codable, Equatable, Sendable, Identifiable { 33 + nonisolated struct UserCustomCommand: Codable, Equatable, Sendable, Identifiable { 34 34 var id: String 35 35 var title: String 36 36 var systemImage: String 37 37 var command: String 38 - var execution: OnevcatCustomCommandExecution 39 - var shortcut: OnevcatCustomShortcut? 38 + var execution: UserCustomCommandExecution 39 + var shortcut: UserCustomShortcut? 40 40 41 41 init( 42 42 id: String = UUID().uuidString, 43 43 title: String, 44 44 systemImage: String, 45 45 command: String, 46 - execution: OnevcatCustomCommandExecution, 47 - shortcut: OnevcatCustomShortcut? 46 + execution: UserCustomCommandExecution, 47 + shortcut: UserCustomShortcut? 48 48 ) { 49 49 self.id = id 50 50 self.title = title ··· 54 54 self.shortcut = shortcut?.normalized() 55 55 } 56 56 57 - static func `default`(index: Int) -> OnevcatCustomCommand { 58 - OnevcatCustomCommand( 57 + static func `default`(index: Int) -> UserCustomCommand { 58 + UserCustomCommand( 59 59 title: "Command \(index + 1)", 60 60 systemImage: "terminal", 61 61 command: "", ··· 64 64 ) 65 65 } 66 66 67 - func normalized() -> OnevcatCustomCommand { 68 - OnevcatCustomCommand( 67 + func normalized() -> UserCustomCommand { 68 + UserCustomCommand( 69 69 id: id, 70 70 title: title, 71 71 systemImage: systemImage, ··· 96 96 } 97 97 } 98 98 99 - nonisolated enum OnevcatCustomCommandExecution: String, Codable, CaseIterable, Identifiable, Sendable { 99 + nonisolated enum UserCustomCommandExecution: String, Codable, CaseIterable, Identifiable, Sendable { 100 100 case shellScript 101 101 case terminalInput 102 102 ··· 112 112 } 113 113 } 114 114 115 - nonisolated struct OnevcatCustomShortcut: Codable, Equatable, Sendable { 115 + nonisolated struct UserCustomShortcut: Codable, Equatable, Sendable { 116 116 var key: String 117 - var modifiers: OnevcatCustomShortcutModifiers 117 + var modifiers: UserCustomShortcutModifiers 118 118 119 - init(key: String, modifiers: OnevcatCustomShortcutModifiers) { 119 + init(key: String, modifiers: UserCustomShortcutModifiers) { 120 120 self.key = key 121 121 self.modifiers = modifiers 122 122 } 123 123 124 - func normalized() -> OnevcatCustomShortcut { 124 + func normalized() -> UserCustomShortcut { 125 125 let scalar = key.trimmingCharacters(in: .whitespacesAndNewlines).first 126 - return OnevcatCustomShortcut( 126 + return UserCustomShortcut( 127 127 key: scalar.map { String($0).lowercased() } ?? "", 128 128 modifiers: modifiers 129 129 ) ··· 145 145 } 146 146 } 147 147 148 - nonisolated struct OnevcatCustomShortcutModifiers: Codable, Equatable, Sendable { 148 + nonisolated struct UserCustomShortcutModifiers: Codable, Equatable, Sendable { 149 149 var command: Bool 150 150 var shift: Bool 151 151 var option: Bool
+14 -14
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 10 10 let baseRefOptions = 11 11 store.branchOptions.isEmpty ? [store.defaultWorktreeBaseRef] : store.branchOptions 12 12 let settings = $store.settings 13 - let onevcatSettings = $store.onevcatSettings 13 + let userSettings = $store.userSettings 14 14 let worktreeBaseDirectoryPath = Binding( 15 15 get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, 16 16 set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, ··· 186 186 } 187 187 if store.showsCustomCommandsSettings { 188 188 Section { 189 - ForEach(onevcatSettings.customCommands) { $command in 190 - OnevcatCustomCommandCard( 189 + ForEach(userSettings.customCommands) { $command in 190 + UserCustomCommandCard( 191 191 command: $command, 192 192 onRemove: { 193 193 removeCustomCommand(id: command.id) 194 194 } 195 195 ) 196 196 } 197 - if store.onevcatSettings.customCommands.count < OnevcatRepositorySettings.maxCustomCommands { 197 + if store.userSettings.customCommands.count < UserRepositorySettings.maxCustomCommands { 198 198 Button { 199 199 addCustomCommand() 200 200 } label: { ··· 219 219 } 220 220 221 221 private func addCustomCommand() { 222 - let current = store.onevcatSettings.customCommands 222 + let current = store.userSettings.customCommands 223 223 let next = current + [.default(index: current.count)] 224 - store.onevcatSettings.customCommands = OnevcatRepositorySettings.normalizedCommands(next) 224 + store.userSettings.customCommands = UserRepositorySettings.normalizedCommands(next) 225 225 } 226 226 227 - private func removeCustomCommand(id: OnevcatCustomCommand.ID) { 228 - store.onevcatSettings.customCommands.removeAll { $0.id == id } 227 + private func removeCustomCommand(id: UserCustomCommand.ID) { 228 + store.userSettings.customCommands.removeAll { $0.id == id } 229 229 } 230 230 } 231 231 ··· 292 292 } 293 293 } 294 294 295 - private struct OnevcatCustomCommandCard: View { 296 - @Binding var command: OnevcatCustomCommand 295 + private struct UserCustomCommandCard: View { 296 + @Binding var command: UserCustomCommand 297 297 let onRemove: () -> Void 298 298 299 299 var body: some View { ··· 304 304 TextField("SF Symbol", text: $command.systemImage) 305 305 .textFieldStyle(.roundedBorder) 306 306 Picker("Type", selection: $command.execution) { 307 - ForEach(OnevcatCustomCommandExecution.allCases) { execution in 307 + ForEach(UserCustomCommandExecution.allCases) { execution in 308 308 Text(execution.title) 309 309 .tag(execution) 310 310 } ··· 371 371 if enabled { 372 372 command.shortcut = 373 373 command.shortcut 374 - ?? OnevcatCustomShortcut( 374 + ?? UserCustomShortcut( 375 375 key: "", 376 - modifiers: OnevcatCustomShortcutModifiers() 376 + modifiers: UserCustomShortcutModifiers() 377 377 ) 378 378 } else { 379 379 command.shortcut = nil ··· 382 382 ) 383 383 } 384 384 385 - private func shortcutKeyBinding(_ shortcut: Binding<OnevcatCustomShortcut>) -> Binding<String> { 385 + private func shortcutKeyBinding(_ shortcut: Binding<UserCustomShortcut>) -> Binding<String> { 386 386 Binding( 387 387 get: { shortcut.wrappedValue.key }, 388 388 set: { value in
+1 -1
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 987 987 guard let surface else { return false } 988 988 guard focused else { return false } 989 989 990 - if OnevcatCustomShortcutRegistry.shared.matches(event: event), 990 + if UserCustomShortcutRegistry.shared.matches(event: event), 991 991 let menu = NSApp.mainMenu, 992 992 menu.performKeyEquivalent(with: event) 993 993 {
-21
supacode/Infrastructure/Ghostty/OnevcatCustomShortcutRegistry.swift
··· 1 - import AppKit 2 - 3 - @MainActor 4 - final class OnevcatCustomShortcutRegistry { 5 - static let shared = OnevcatCustomShortcutRegistry() 6 - 7 - private var shortcuts: [OnevcatCustomShortcut] = [] 8 - 9 - private init() {} 10 - 11 - func setShortcuts(_ shortcuts: [OnevcatCustomShortcut]) { 12 - self.shortcuts = shortcuts.compactMap { shortcut in 13 - let normalized = shortcut.normalized() 14 - return normalized.isValid ? normalized : nil 15 - } 16 - } 17 - 18 - func matches(event: NSEvent) -> Bool { 19 - shortcuts.contains { $0.matches(event: event) } 20 - } 21 - }
+27
supacode/Infrastructure/Ghostty/UserCustomShortcutRegistry.swift
··· 1 + import AppKit 2 + 3 + @MainActor 4 + final class UserCustomShortcutRegistry { 5 + static let shared = UserCustomShortcutRegistry() 6 + 7 + private var shortcuts: [UserCustomShortcut] = [] 8 + 9 + private init() {} 10 + 11 + func setShortcuts(_ shortcuts: [UserCustomShortcut]) { 12 + self.shortcuts = shortcuts.compactMap { shortcut in 13 + let normalized = shortcut.normalized() 14 + return normalized.isValid ? normalized : nil 15 + } 16 + } 17 + 18 + func matches(event: NSEvent) -> Bool { 19 + shortcuts.contains { $0.matches(event: event) } 20 + } 21 + 22 + #if DEBUG 23 + var registeredShortcutsForTesting: [UserCustomShortcut] { 24 + shortcuts 25 + } 26 + #endif 27 + }
+3 -3
supacode/Support/SupacodePaths.swift
··· 106 106 .appending(path: "prowl.json", directoryHint: .notDirectory) 107 107 } 108 108 109 - static func onevcatRepositorySettingsURL(for rootURL: URL) -> URL { 109 + static func userRepositorySettingsURL(for rootURL: URL) -> URL { 110 110 repositorySettingsDirectory(for: rootURL) 111 111 .appending(path: "prowl.onevcat.json", directoryHint: .notDirectory) 112 112 } ··· 118 118 } 119 119 120 120 /// Legacy location: ~/.prowl/repo/<name>/supacode.onevcat.json (pre-rename) 121 - static func legacyOnevcatRepositorySettingsURL(for rootURL: URL) -> URL { 121 + static func legacyUserRepositorySettingsURL(for rootURL: URL) -> URL { 122 122 repositorySettingsDirectory(for: rootURL) 123 123 .appending(path: "supacode.onevcat.json", directoryHint: .notDirectory) 124 124 } ··· 129 129 } 130 130 131 131 /// Legacy location: <repo-root>/supacode.onevcat.json (original upstream location) 132 - static func originalLegacyOnevcatRepositorySettingsURL(for rootURL: URL) -> URL { 132 + static func originalLegacyUserRepositorySettingsURL(for rootURL: URL) -> URL { 133 133 rootURL.standardizedFileURL.appending(path: "supacode.onevcat.json", directoryHint: .notDirectory) 134 134 } 135 135
+2 -2
supacodeTests/AppFeatureCustomCommandTests.swift
··· 15 15 settings: SettingsFeature.State() 16 16 ) 17 17 state.selectedCustomCommands = [ 18 - OnevcatCustomCommand( 18 + UserCustomCommand( 19 19 title: "Test", 20 20 systemImage: "checkmark.circle", 21 21 command: "swift test", ··· 50 50 settings: SettingsFeature.State() 51 51 ) 52 52 state.selectedCustomCommands = [ 53 - OnevcatCustomCommand( 53 + UserCustomCommand( 54 54 title: "Watch", 55 55 systemImage: "terminal", 56 56 command: "pnpm test --watch",
+3 -3
supacodeTests/AppFeatureDefaultEditorTests.swift
··· 34 34 35 35 await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 36 36 await store.receive(\.worktreeSettingsLoaded) 37 - await store.receive(\.worktreeOnevcatSettingsLoaded) 37 + await store.receive(\.worktreeUserSettingsLoaded) 38 38 #expect(store.state.openActionSelection == .finder) 39 39 #expect(store.state.selectedRunScript == "") 40 40 await store.finish() ··· 91 91 $0.openActionSelection = .terminal 92 92 $0.selectedRunScript = "pnpm dev" 93 93 } 94 - await store.receive(\.worktreeOnevcatSettingsLoaded) 94 + await store.receive(\.worktreeUserSettingsLoaded) 95 95 await store.finish() 96 96 } 97 97 ··· 125 125 await store.receive(\.worktreeSettingsLoaded) { 126 126 $0.openActionSelection = expectedOpenActionSelection 127 127 } 128 - await store.receive(\.worktreeOnevcatSettingsLoaded) 128 + await store.receive(\.worktreeUserSettingsLoaded) 129 129 await store.finish() 130 130 131 131 #expect(watcherCommands.value == [.setSelectedWorktreeID(worktree.id)])
+48 -7
supacodeTests/AppFeaturePlainFolderTerminalTests.swift
··· 27 27 settingsFileURL 28 28 ) 29 29 30 - let onevcatSettings = OnevcatRepositorySettings( 30 + let userSettings = UserRepositorySettings( 31 31 customCommands: [ 32 - OnevcatCustomCommand( 32 + UserCustomCommand( 33 33 title: "Watch", 34 34 systemImage: "terminal", 35 35 command: "pnpm test --watch", ··· 39 39 ] 40 40 ) 41 41 try localStorage.save( 42 - JSONEncoder().encode(onevcatSettings), 43 - at: SupacodePaths.onevcatRepositorySettingsURL(for: repository.rootURL) 42 + JSONEncoder().encode(userSettings), 43 + at: SupacodePaths.userRepositorySettingsURL(for: repository.rootURL) 44 44 ) 45 45 46 46 let store = TestStore( ··· 71 71 $0.openActionSelection = .terminal 72 72 $0.selectedRunScript = "pnpm dev" 73 73 } 74 - await store.receive(\.worktreeOnevcatSettingsLoaded) { 75 - $0.selectedCustomCommands = onevcatSettings.customCommands 74 + await store.receive(\.worktreeUserSettingsLoaded) { 75 + $0.selectedCustomCommands = userSettings.customCommands 76 76 } 77 77 await store.finish() 78 78 ··· 117 117 ) 118 118 } 119 119 120 + @Test(.dependencies) func loadingConflictingShortcutKeepsRegistration() async { 121 + let repository = makePlainRepository() 122 + let registeredShortcuts = LockIsolated<[UserCustomShortcut]>([]) 123 + var state = AppFeature.State( 124 + repositories: makeRepositoriesState(repository: repository, selected: true), 125 + settings: SettingsFeature.State() 126 + ) 127 + state.repositories.selection = .repository(repository.id) 128 + 129 + let store = TestStore(initialState: state) { 130 + AppFeature() 131 + } withDependencies: { 132 + $0.customShortcutRegistryClient.setShortcuts = { shortcuts in 133 + registeredShortcuts.setValue(shortcuts) 134 + } 135 + } 136 + 137 + let conflicted = UserRepositorySettings( 138 + customCommands: [ 139 + UserCustomCommand( 140 + title: "Build", 141 + systemImage: "hammer", 142 + command: "swift build", 143 + execution: .shellScript, 144 + shortcut: UserCustomShortcut( 145 + key: "b", 146 + modifiers: UserCustomShortcutModifiers(command: true) 147 + ) 148 + ), 149 + ] 150 + ) 151 + 152 + await store.send(.worktreeUserSettingsLoaded(conflicted, worktreeID: repository.id)) { 153 + $0.selectedCustomCommands = conflicted.customCommands 154 + } 155 + await store.finish() 156 + 157 + let expectedShortcut = conflicted.customCommands[0].shortcut?.normalized() 158 + #expect(registeredShortcuts.value == [expectedShortcut].compactMap { $0 }) 159 + } 160 + 120 161 @Test(.dependencies) func customCommandUsesPlainRepositoryTerminalTarget() async { 121 162 let repository = makePlainRepository() 122 163 let sent = LockIsolated<[TerminalClient.Command]>([]) ··· 125 166 settings: SettingsFeature.State() 126 167 ) 127 168 state.selectedCustomCommands = [ 128 - OnevcatCustomCommand( 169 + UserCustomCommand( 129 170 title: "Watch", 130 171 systemImage: "terminal", 131 172 command: "pnpm test --watch",
+3 -3
supacodeTests/AppFeatureSettingsSelectionTests.swift
··· 28 28 rootURL: repository.rootURL, 29 29 repositoryKind: repository.kind, 30 30 settings: .default, 31 - onevcatSettings: .default 31 + userSettings: .default 32 32 ) 33 33 } 34 34 } ··· 72 72 rootURL: repository.rootURL, 73 73 repositoryKind: .plain, 74 74 settings: .default, 75 - onevcatSettings: .default 75 + userSettings: .default 76 76 ) 77 77 } 78 78 } ··· 93 93 rootURL: repository.rootURL, 94 94 repositoryKind: repository.kind, 95 95 settings: .default, 96 - onevcatSettings: .default 96 + userSettings: .default 97 97 ) 98 98 let store = TestStore(initialState: state) { 99 99 AppFeature()
+97
supacodeTests/AppShortcutsTests.swift
··· 29 29 } 30 30 } 31 31 32 + @Test func defaultGlobalShortcutTableMatchesPlan() { 33 + expectNoDifference( 34 + [ 35 + "openSettings=\(AppShortcuts.openSettings.display)", 36 + "toggleLeftSidebar=\(AppShortcuts.toggleLeftSidebar.display)", 37 + "runScript=\(AppShortcuts.runScript.display)", 38 + "stopRunScript=\(AppShortcuts.stopRunScript.display)", 39 + "checkForUpdates=\(AppShortcuts.checkForUpdates.display)", 40 + "showDiff=\(AppShortcuts.showDiff.display)", 41 + "openFinder=\(AppShortcuts.openFinder.display)", 42 + "openRepository=\(AppShortcuts.openRepository.display)", 43 + ], 44 + [ 45 + "openSettings=⌘,", 46 + "toggleLeftSidebar=⌘⌃S", 47 + "runScript=⌘R", 48 + "stopRunScript=⌘.", 49 + "checkForUpdates=⌘⇧U", 50 + "showDiff=⌘⇧Y", 51 + "openFinder=⌘O", 52 + "openRepository=⌘⇧O", 53 + ] 54 + ) 55 + } 56 + 57 + @Test func systemFixedAndLocalInteractionShortcutsAreDefinedInRegistry() { 58 + let idToDisplay = Dictionary(uniqueKeysWithValues: AppShortcuts.bindings.map { ($0.id, $0.shortcut.display) }) 59 + let idToScope = Dictionary(uniqueKeysWithValues: AppShortcuts.bindings.map { ($0.id, $0.scope) }) 60 + 61 + expectNoDifference( 62 + idToDisplay["command_palette"], 63 + AppShortcuts.commandPalette.display 64 + ) 65 + expectNoDifference( 66 + idToDisplay["quit_application"], 67 + AppShortcuts.quitApplication.display 68 + ) 69 + expectNoDifference( 70 + idToDisplay["rename_branch"], 71 + AppShortcuts.renameBranch.display 72 + ) 73 + expectNoDifference( 74 + idToDisplay["select_all_canvas_cards"], 75 + AppShortcuts.selectAllCanvasCards.display 76 + ) 77 + 78 + #expect(idToScope["command_palette"] == .systemFixedAppAction) 79 + #expect(idToScope["quit_application"] == .systemFixedAppAction) 80 + #expect(idToScope["rename_branch"] == .localInteraction) 81 + #expect(idToScope["select_all_canvas_cards"] == .localInteraction) 82 + } 83 + 32 84 @Test func tabSelectionGhosttyKeybindArgumentsMatchExpected() { 33 85 expectNoDifference( 34 86 AppShortcuts.tabSelectionGhosttyKeybindArguments, ··· 57 109 ) 58 110 } 59 111 112 + @Test func userOverrideConflictsDetectsReservedAppShortcuts() { 113 + let commands = [ 114 + UserCustomCommand( 115 + title: "Build", 116 + systemImage: "hammer", 117 + command: "swift build", 118 + execution: .shellScript, 119 + shortcut: UserCustomShortcut( 120 + key: "s", 121 + modifiers: UserCustomShortcutModifiers(command: true, control: true) 122 + ) 123 + ), 124 + UserCustomCommand( 125 + title: "Deploy", 126 + systemImage: "rocket", 127 + command: "make release", 128 + execution: .shellScript, 129 + shortcut: UserCustomShortcut( 130 + key: "k", 131 + modifiers: UserCustomShortcutModifiers(command: true) 132 + ) 133 + ), 134 + ] 135 + 136 + expectNoDifference( 137 + AppShortcuts.userOverrideConflicts(in: commands).map { 138 + "\($0.commandTitle)|\($0.commandShortcutDisplay)|\($0.appActionTitle)|\($0.appShortcutDisplay)" 139 + }, 140 + [ 141 + "Build|⌘⌃S|Toggle Left Sidebar|⌘⌃S" 142 + ] 143 + ) 144 + } 145 + 60 146 @Test func ghosttyCLIArgumentsKeepWorktreeUnbindsAndTabBinds() { 61 147 let arguments = AppShortcuts.ghosttyCLIKeybindArguments 62 148 ··· 69 155 } 70 156 71 157 for argument in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].map({ "--keybind=ctrl+digit_\($0)=unbind" }) { 158 + #expect(arguments.contains(argument) == false) 159 + } 160 + 161 + for argument in [ 162 + "--keybind=super+[=unbind", 163 + "--keybind=super+]=unbind", 164 + "--keybind=super+shift+[=unbind", 165 + "--keybind=super+shift+]=unbind", 166 + "--keybind=super+d=unbind", 167 + "--keybind=super+shift+d=unbind", 168 + ] { 72 169 #expect(arguments.contains(argument) == false) 73 170 } 74 171 }
+1 -1
supacodeTests/DetailToolbarTitleTests.swift
··· 21 21 22 22 #expect(title?.kind == .branch(name: "feature/title-bar")) 23 23 #expect(title?.systemImage == "arrow.trianglehead.branch") 24 - #expect(title?.helpText == "Rename branch (⌘M)") 24 + #expect(title?.helpText == "Rename branch (⌘⇧M)") 25 25 #expect(title?.supportsRename == true) 26 26 } 27 27
-100
supacodeTests/OnevcatRepositorySettingsKeyTests.swift
··· 1 - import Dependencies 2 - import DependenciesTestSupport 3 - import Foundation 4 - import Sharing 5 - import Testing 6 - 7 - @testable import supacode 8 - 9 - struct OnevcatRepositorySettingsKeyTests { 10 - @Test(.dependencies) func loadMissingFileReturnsDefaultAndCreatesLocalFile() throws { 11 - let localStorage = RepositoryLocalSettingsTestStorage() 12 - let rootURL = URL(fileURLWithPath: "/tmp/repo") 13 - let localURL = SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 14 - 15 - let loaded = withDependencies { 16 - $0.repositoryLocalSettingsStorage = localStorage.storage 17 - } operation: { 18 - @Shared(.onevcatRepositorySettings(rootURL)) var settings: OnevcatRepositorySettings 19 - return settings 20 - } 21 - 22 - #expect(loaded == .default) 23 - 24 - let localData = try #require(localStorage.data(at: localURL)) 25 - let decoded = try JSONDecoder().decode(OnevcatRepositorySettings.self, from: localData) 26 - #expect(decoded == .default) 27 - } 28 - 29 - @Test(.dependencies) func savePersistsCustomCommandsToOnevcatFile() throws { 30 - let localStorage = RepositoryLocalSettingsTestStorage() 31 - let rootURL = URL(fileURLWithPath: "/tmp/repo") 32 - let localURL = SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 33 - 34 - let customSettings = OnevcatRepositorySettings( 35 - customCommands: [ 36 - OnevcatCustomCommand( 37 - title: "Test", 38 - systemImage: "checkmark.circle", 39 - command: "swift test", 40 - execution: .shellScript, 41 - shortcut: OnevcatCustomShortcut( 42 - key: "u", 43 - modifiers: OnevcatCustomShortcutModifiers(command: true) 44 - ) 45 - ), 46 - ] 47 - ) 48 - 49 - withDependencies { 50 - $0.repositoryLocalSettingsStorage = localStorage.storage 51 - } operation: { 52 - @Shared(.onevcatRepositorySettings(rootURL)) var settings: OnevcatRepositorySettings 53 - $settings.withLock { 54 - $0 = customSettings 55 - } 56 - } 57 - 58 - let localData = try #require(localStorage.data(at: localURL)) 59 - let decoded = try JSONDecoder().decode(OnevcatRepositorySettings.self, from: localData) 60 - #expect(decoded == customSettings) 61 - } 62 - 63 - @Test(.dependencies) func loadMigratesLegacyRepositoryRootOnevcatFile() throws { 64 - let localStorage = RepositoryLocalSettingsTestStorage() 65 - let rootURL = URL(fileURLWithPath: "/tmp/repo") 66 - let localURL = SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 67 - let legacyURL = SupacodePaths.legacyOnevcatRepositorySettingsURL(for: rootURL) 68 - 69 - let customSettings = OnevcatRepositorySettings( 70 - customCommands: [ 71 - OnevcatCustomCommand( 72 - title: "Legacy", 73 - systemImage: "terminal", 74 - command: "echo legacy", 75 - execution: .shellScript, 76 - shortcut: OnevcatCustomShortcut( 77 - key: "u", 78 - modifiers: OnevcatCustomShortcutModifiers(command: true) 79 - ) 80 - ), 81 - ] 82 - ) 83 - let encoder = JSONEncoder() 84 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 85 - try localStorage.save(try encoder.encode(customSettings), at: legacyURL) 86 - 87 - let loaded = withDependencies { 88 - $0.repositoryLocalSettingsStorage = localStorage.storage 89 - } operation: { 90 - @Shared(.onevcatRepositorySettings(rootURL)) var settings: OnevcatRepositorySettings 91 - return settings 92 - } 93 - 94 - #expect(loaded == customSettings) 95 - 96 - let localData = try #require(localStorage.data(at: localURL)) 97 - let decoded = try JSONDecoder().decode(OnevcatRepositorySettings.self, from: localData) 98 - #expect(decoded == customSettings) 99 - } 100 - }
+2 -2
supacodeTests/RepositoryPathsTests.swift
··· 51 51 #expect(settingsURL.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "repo") 52 52 } 53 53 54 - @Test func onevcatRepositorySettingsURLUsesSupacodeRepoDirectory() { 54 + @Test func userRepositorySettingsURLUsesSupacodeRepoDirectory() { 55 55 let root = URL(fileURLWithPath: "/tmp/work/repo-alpha/.bare") 56 - let settingsURL = SupacodePaths.onevcatRepositorySettingsURL(for: root) 56 + let settingsURL = SupacodePaths.userRepositorySettingsURL(for: root) 57 57 58 58 #expect(settingsURL.lastPathComponent == "prowl.onevcat.json") 59 59 #expect(settingsURL.deletingLastPathComponent().lastPathComponent == ".bare")
+53 -7
supacodeTests/RepositorySettingsFeatureTests.swift
··· 24 24 copyUntrackedOnWorktreeCreate: true, 25 25 pullRequestMergeStrategy: .squash 26 26 ) 27 - let storedOnevcatSettings = OnevcatRepositorySettings( 27 + let storedOnevcatSettings = UserRepositorySettings( 28 28 customCommands: [.default(index: 0)] 29 29 ) 30 30 let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) ··· 37 37 let settingsData = try #require(try? JSONEncoder().encode(settingsFile)) 38 38 try #require(try? settingsStorage.storage.save(settingsData, settingsFileURL)) 39 39 40 - let onevcatSettingsData = try #require(try? JSONEncoder().encode(storedOnevcatSettings)) 40 + let userSettingsData = try #require(try? JSONEncoder().encode(storedOnevcatSettings)) 41 41 try #require( 42 42 try? localStorage.save( 43 - onevcatSettingsData, 44 - at: SupacodePaths.onevcatRepositorySettingsURL(for: rootURL) 43 + userSettingsData, 44 + at: SupacodePaths.userRepositorySettingsURL(for: rootURL) 45 45 ) 46 46 ) 47 47 ··· 50 50 rootURL: rootURL, 51 51 repositoryKind: .plain, 52 52 settings: .default, 53 - onevcatSettings: .default 53 + userSettings: .default 54 54 ) 55 55 ) { 56 56 RepositorySettingsFeature() ··· 75 75 await store.send(.task) 76 76 await store.receive(\.settingsLoaded, timeout: .seconds(5)) { 77 77 $0.settings = storedSettings 78 - $0.onevcatSettings = storedOnevcatSettings 78 + $0.userSettings = storedOnevcatSettings 79 79 $0.globalDefaultWorktreeBaseDirectoryPath = expectedDefaultWorktreeBaseDirectoryPath 80 80 } 81 81 await store.finish(timeout: .seconds(5)) ··· 92 92 rootURL: URL(fileURLWithPath: "/tmp/folder"), 93 93 repositoryKind: .plain, 94 94 settings: .default, 95 - onevcatSettings: .default 95 + userSettings: .default 96 96 ) 97 97 98 98 #expect(state.showsWorktreeSettings == false) ··· 101 101 #expect(state.showsArchiveScriptSettings == false) 102 102 #expect(state.showsRunScriptSettings == true) 103 103 #expect(state.showsCustomCommandsSettings == true) 104 + } 105 + 106 + @Test(.dependencies) func conflictingCustomShortcutPersistsAsUserOverride() async throws { 107 + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 108 + let settingsStorage = SettingsTestStorage() 109 + let localStorage = RepositoryLocalSettingsTestStorage() 110 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 111 + 112 + let store = TestStore( 113 + initialState: RepositorySettingsFeature.State( 114 + rootURL: rootURL, 115 + repositoryKind: .plain, 116 + settings: .default, 117 + userSettings: .default 118 + ) 119 + ) { 120 + RepositorySettingsFeature() 121 + } withDependencies: { 122 + $0.settingsFileStorage = settingsStorage.storage 123 + $0.settingsFileURL = settingsFileURL 124 + $0.repositoryLocalSettingsStorage = localStorage.storage 125 + } 126 + 127 + let conflicted = UserRepositorySettings( 128 + customCommands: [ 129 + UserCustomCommand( 130 + title: "Run tests", 131 + systemImage: "terminal", 132 + command: "swift test", 133 + execution: .shellScript, 134 + shortcut: UserCustomShortcut( 135 + key: "b", 136 + modifiers: UserCustomShortcutModifiers(command: true) 137 + ) 138 + ), 139 + ] 140 + ) 141 + 142 + await store.send(.binding(.set(\.userSettings, conflicted))) { 143 + $0.userSettings = conflicted 144 + } 145 + await store.receive(\.delegate.settingsChanged) 146 + 147 + let savedData = try #require(localStorage.data(at: SupacodePaths.userRepositorySettingsURL(for: rootURL))) 148 + let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: savedData) 149 + #expect(decoded.customCommands.first?.shortcut == conflicted.customCommands.first?.shortcut) 104 150 } 105 151 }
+3 -3
supacodeTests/SettingsFeatureTests.swift
··· 150 150 rootURL: rootURL, 151 151 repositoryKind: .git, 152 152 settings: .default, 153 - onevcatSettings: .default 153 + userSettings: .default 154 154 ) 155 155 let store = TestStore(initialState: state) { 156 156 SettingsFeature() ··· 197 197 rootURL: rootURL, 198 198 repositoryKind: .git, 199 199 settings: .default, 200 - onevcatSettings: .default 200 + userSettings: .default 201 201 ) 202 202 } 203 203 await store.receive(\.delegate.settingsChanged) ··· 239 239 rootURL: rootURL, 240 240 repositoryKind: .git, 241 241 settings: .default, 242 - onevcatSettings: .default 242 + userSettings: .default 243 243 ) 244 244 let store = TestStore(initialState: state) { 245 245 SettingsFeature()
+100
supacodeTests/UserRepositorySettingsKeyTests.swift
··· 1 + import Dependencies 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Sharing 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct UserRepositorySettingsKeyTests { 10 + @Test(.dependencies) func loadMissingFileReturnsDefaultAndCreatesLocalFile() throws { 11 + let localStorage = RepositoryLocalSettingsTestStorage() 12 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 13 + let localURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 14 + 15 + let loaded = withDependencies { 16 + $0.repositoryLocalSettingsStorage = localStorage.storage 17 + } operation: { 18 + @Shared(.userRepositorySettings(rootURL)) var settings: UserRepositorySettings 19 + return settings 20 + } 21 + 22 + #expect(loaded == .default) 23 + 24 + let localData = try #require(localStorage.data(at: localURL)) 25 + let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: localData) 26 + #expect(decoded == .default) 27 + } 28 + 29 + @Test(.dependencies) func savePersistsCustomCommandsToUserFile() throws { 30 + let localStorage = RepositoryLocalSettingsTestStorage() 31 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 32 + let localURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 33 + 34 + let customSettings = UserRepositorySettings( 35 + customCommands: [ 36 + UserCustomCommand( 37 + title: "Test", 38 + systemImage: "checkmark.circle", 39 + command: "swift test", 40 + execution: .shellScript, 41 + shortcut: UserCustomShortcut( 42 + key: "u", 43 + modifiers: UserCustomShortcutModifiers(command: true) 44 + ) 45 + ), 46 + ] 47 + ) 48 + 49 + withDependencies { 50 + $0.repositoryLocalSettingsStorage = localStorage.storage 51 + } operation: { 52 + @Shared(.userRepositorySettings(rootURL)) var settings: UserRepositorySettings 53 + $settings.withLock { 54 + $0 = customSettings 55 + } 56 + } 57 + 58 + let localData = try #require(localStorage.data(at: localURL)) 59 + let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: localData) 60 + #expect(decoded == customSettings) 61 + } 62 + 63 + @Test(.dependencies) func loadMigratesLegacyRepositoryRootUserFile() throws { 64 + let localStorage = RepositoryLocalSettingsTestStorage() 65 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 66 + let localURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 67 + let legacyURL = SupacodePaths.legacyUserRepositorySettingsURL(for: rootURL) 68 + 69 + let customSettings = UserRepositorySettings( 70 + customCommands: [ 71 + UserCustomCommand( 72 + title: "Legacy", 73 + systemImage: "terminal", 74 + command: "echo legacy", 75 + execution: .shellScript, 76 + shortcut: UserCustomShortcut( 77 + key: "u", 78 + modifiers: UserCustomShortcutModifiers(command: true) 79 + ) 80 + ), 81 + ] 82 + ) 83 + let encoder = JSONEncoder() 84 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 85 + try localStorage.save(try encoder.encode(customSettings), at: legacyURL) 86 + 87 + let loaded = withDependencies { 88 + $0.repositoryLocalSettingsStorage = localStorage.storage 89 + } operation: { 90 + @Shared(.userRepositorySettings(rootURL)) var settings: UserRepositorySettings 91 + return settings 92 + } 93 + 94 + #expect(loaded == customSettings) 95 + 96 + let localData = try #require(localStorage.data(at: localURL)) 97 + let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: localData) 98 + #expect(decoded == customSettings) 99 + } 100 + }