native macOS codings agent orchestrator
6
fork

Configure Feed

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

Improve open worktree UX and rename openFinder to openWorktree (#247)

Separate "Reveal in Finder" from the primary open action so users get
a dedicated shortcut (⌥⌘R) for Finder while the main shortcut (⌘O)
opens with their preferred app. Add context menu open actions for
worktrees, extract shared openWorktreeEffect helper with analytics
source tracking, and add consistent warning logs on all worktree
lookup guards.

authored by

Stefano Bertagno and committed by
GitHub
bdba751f a763ac18

+419 -52
+1
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 41 41 public var claudeNotificationsState = AgentHooksInstallState.checking 42 42 public var codexProgressState = AgentHooksInstallState.checking 43 43 public var codexNotificationsState = AgentHooksInstallState.checking 44 + /// `nil` when the settings window is closed; non-nil selects the visible section. 44 45 public var selection: SettingsSection? 45 46 public var repositorySummaries: [SettingsRepositorySummary] = [] 46 47 public var repositorySettings: RepositorySettingsFeature.State?
+11 -6
SupacodeSettingsShared/App/AppShortcuts.swift
··· 11 11 case deleteWorktree, confirmWorktreeAction 12 12 case selectNextWorktree, selectPreviousWorktree 13 13 case selectWorktree(Int) 14 - case openFinder, openRepository, openPullRequest, copyPath 14 + case openWorktree, revealInFinder, openRepository, openPullRequest, copyPath 15 15 case runScript, stopRunScript 16 16 17 17 // Stable string key for JSON dictionary persistence. ··· 47 47 case .selectNextWorktree: "selectNextWorktree" 48 48 case .selectPreviousWorktree: "selectPreviousWorktree" 49 49 case .selectWorktree(let index): "selectWorktree\(index)" 50 - case .openFinder: "openFinder" 50 + case .openWorktree: "openWorktree" 51 + case .revealInFinder: "revealInFinder" 51 52 case .openRepository: "openRepository" 52 53 case .openPullRequest: "openPullRequest" 53 54 case .copyPath: "copyPath" ··· 70 71 "confirmWorktreeAction": .confirmWorktreeAction, 71 72 "selectNextWorktree": .selectNextWorktree, 72 73 "selectPreviousWorktree": .selectPreviousWorktree, 73 - "openFinder": .openFinder, 74 + "openWorktree": .openWorktree, 75 + "openFinder": .openWorktree, 76 + "revealInFinder": .revealInFinder, 74 77 "openRepository": .openRepository, 75 78 "openPullRequest": .openPullRequest, 76 79 "copyPath": .copyPath, ··· 106 109 case .selectNextWorktree: "Select Next Worktree" 107 110 case .selectPreviousWorktree: "Select Previous Worktree" 108 111 case .selectWorktree(let index): "Select Worktree \(index == 0 ? 10 : index)" 109 - case .openFinder: "Open Finder" 112 + case .openWorktree: "Open Worktree" 113 + case .revealInFinder: "Reveal in Finder" 110 114 case .openRepository: "Open Repository" 111 115 case .openPullRequest: "Open Pull Request" 112 116 case .copyPath: "Copy Path" ··· 310 314 public static let selectWorktree9 = AppShortcut(id: .selectWorktree(9), key: "9", modifiers: [.control]) 311 315 public static let selectWorktree0 = AppShortcut(id: .selectWorktree(0), key: "0", modifiers: [.control]) 312 316 313 - public static let openFinder = AppShortcut(id: .openFinder, key: "o", modifiers: .command) 317 + public static let openWorktree = AppShortcut(id: .openWorktree, key: "o", modifiers: .command) 318 + public static let revealInFinder = AppShortcut(id: .revealInFinder, key: "r", modifiers: [.command, .option]) 314 319 public static let openRepository = AppShortcut(id: .openRepository, key: "o", modifiers: [.command, .shift]) 315 320 public static let openPullRequest = AppShortcut(id: .openPullRequest, key: "g", modifiers: [.command, .control]) 316 321 public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) ··· 337 342 AppShortcutGroup(category: .worktreeSelection, shortcuts: worktreeSelection), 338 343 AppShortcutGroup( 339 344 category: .actions, 340 - shortcuts: [openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] 345 + shortcuts: [openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] 341 346 ), 342 347 ] 343 348
+1 -1
SupacodeSettingsShared/Domain/OpenWorktreeAction.swift
··· 39 39 40 40 public var title: String { 41 41 switch self { 42 - case .finder: "Open Finder" 42 + case .finder: "Reveal in Finder" 43 43 case .editor: "$EDITOR" 44 44 case .alacritty: "Alacritty" 45 45 case .antigravity: "Antigravity"
+32 -3
supacode/Commands/WorktreeCommands.swift
··· 8 8 struct WorktreeCommands: Commands { 9 9 @Bindable var store: StoreOf<AppFeature> 10 10 @FocusedValue(\.openSelectedWorktreeAction) private var openSelectedWorktreeAction 11 + @FocusedValue(\.revealInFinderAction) private var revealInFinderAction 12 + @FocusedValue(\.openActionSelection) private var openActionSelection 11 13 @FocusedValue(\.confirmWorktreeAction) private var confirmWorktreeAction 12 14 @FocusedValue(\.archiveWorktreeAction) private var archiveWorktreeAction 13 15 @FocusedValue(\.deleteWorktreeAction) private var deleteWorktreeAction ··· 31 33 let deleteWt = AppShortcuts.deleteWorktree.effective(from: overrides) 32 34 let confirm = AppShortcuts.confirmWorktreeAction.effective(from: overrides) 33 35 let openRepo = AppShortcuts.openRepository.effective(from: overrides) 34 - let openWorktree = AppShortcuts.openFinder.effective(from: overrides) 36 + let openWorktree = AppShortcuts.openWorktree.effective(from: overrides) 37 + let revealInFinder = AppShortcuts.revealInFinder.effective(from: overrides) 35 38 let openPR = AppShortcuts.openPullRequest.effective(from: overrides) 36 39 let newWt = AppShortcuts.newWorktree.effective(from: overrides) 37 40 let archived = AppShortcuts.archivedWorktrees.effective(from: overrides) ··· 46 49 .appKeyboardShortcut(newWt) 47 50 .help("New Worktree (\(newWt?.display ?? "none"))") 48 51 .disabled(!repositories.canCreateWorktree) 49 - Button("Open in Finder", systemImage: "folder") { 52 + Divider() 53 + let openLabel = openActionSelection.map { "Open in \($0.labelTitle)" } ?? "Open" 54 + Button(openLabel, systemImage: "arrow.up.right.square") { 50 55 openSelectedWorktreeAction?() 51 56 } 52 57 .appKeyboardShortcut(openWorktree) 53 - .help("Open in Finder (\(openWorktree?.display ?? "none"))") 58 + .help("\(openLabel) (\(openWorktree?.display ?? "none"))") 54 59 .disabled(openSelectedWorktreeAction == nil) 60 + Button("Reveal in Finder", systemImage: "folder") { 61 + revealInFinderAction?() 62 + } 63 + .appKeyboardShortcut(revealInFinder) 64 + .help("Reveal in Finder (\(revealInFinder?.display ?? "none"))") 65 + .disabled(revealInFinderAction == nil) 55 66 Button("Open Pull Request", systemImage: "arrow.up.forward") { 56 67 if let pullRequestURL { 57 68 NSWorkspace.shared.open(pullRequestURL) ··· 192 203 typealias Value = () -> Void 193 204 } 194 205 206 + private struct RevealInFinderActionKey: FocusedValueKey { 207 + typealias Value = () -> Void 208 + } 209 + 210 + private struct OpenActionSelectionKey: FocusedValueKey { 211 + typealias Value = OpenWorktreeAction 212 + } 213 + 195 214 private struct DeleteWorktreeActionKey: FocusedValueKey { 196 215 typealias Value = () -> Void 197 216 } ··· 204 223 var openSelectedWorktreeAction: (() -> Void)? { 205 224 get { self[OpenSelectedWorktreeActionKey.self] } 206 225 set { self[OpenSelectedWorktreeActionKey.self] = newValue } 226 + } 227 + 228 + var revealInFinderAction: (() -> Void)? { 229 + get { self[RevealInFinderActionKey.self] } 230 + set { self[RevealInFinderActionKey.self] = newValue } 231 + } 232 + 233 + var openActionSelection: OpenWorktreeAction? { 234 + get { self[OpenActionSelectionKey.self] } 235 + set { self[OpenActionSelectionKey.self] = newValue } 207 236 } 208 237 209 238 var confirmWorktreeAction: (() -> Void)? {
+54 -19
supacode/Features/App/Reducer/AppFeature.swift
··· 6 6 import SupacodeSettingsShared 7 7 import SwiftUI 8 8 9 + private nonisolated let appLogger = SupaLogger("App") 9 10 private nonisolated let deeplinkLogger = SupaLogger("Deeplink") 10 11 11 12 private enum CancelID { ··· 51 52 case openActionSelectionChanged(OpenWorktreeAction) 52 53 case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) 53 54 case openSelectedWorktree 55 + case revealInFinder 54 56 case openWorktree(OpenWorktreeAction) 55 57 case openWorktreeFailed(OpenActionError) 56 58 case requestQuit ··· 228 230 } 229 231 return .merge(effects) 230 232 233 + case .repositories(.delegate(.openWorktreeInApp(let worktreeID, let action))): 234 + guard let worktree = state.repositories.worktree(for: worktreeID) else { 235 + appLogger.warning("openWorktreeInApp: worktree \(worktreeID) not found, ignoring.") 236 + return .none 237 + } 238 + return openWorktreeEffect(worktree: worktree, action: action, source: .contextMenu, state: state) 239 + 231 240 case .repositories(.delegate(.openRepositorySettings(let repositoryID))): 232 241 guard state.repositories.repositories.contains(where: { $0.id == repositoryID }) else { 233 242 return .none ··· 322 331 case .openActionSelectionChanged(let action): 323 332 state.openActionSelection = action 324 333 guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 334 + appLogger.warning("openActionSelectionChanged: selected worktree not found, skipping persistence.") 325 335 return .none 326 336 } 327 337 let rootURL = worktree.repositoryRootURL ··· 332 342 333 343 case .openSelectedWorktree: 334 344 return .send(.openWorktree(OpenWorktreeAction.availableSelection(state.openActionSelection))) 345 + 346 + case .revealInFinder: 347 + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 348 + appLogger.warning("revealInFinder: selected worktree not found, ignoring.") 349 + return .none 350 + } 351 + return openWorktreeEffect(worktree: worktree, action: .finder, source: .revealInFinder, state: state) 335 352 336 353 case .openWorktree(let action): 337 354 guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 355 + appLogger.warning("openWorktree: selected worktree not found, ignoring.") 338 356 return .none 339 357 } 340 - analyticsClient.capture("worktree_opened", ["action": action.settingsID]) 341 - if action == .editor { 342 - let shouldRunSetupScript = 343 - state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id) 344 - return .run { _ in 345 - await terminalClient.send( 346 - .createTabWithInput( 347 - worktree, 348 - input: "$EDITOR", 349 - runSetupScriptIfNew: shouldRunSetupScript 350 - ) 351 - ) 352 - } 353 - } 354 - return .run { send in 355 - await workspaceClient.open(action, worktree) { error in 356 - send(.openWorktreeFailed(error)) 357 - } 358 - } 358 + return openWorktreeEffect(worktree: worktree, action: action, source: .toolbar, state: state) 359 359 360 360 case .openWorktreeFailed(let error): 361 361 state.alert = AlertState { ··· 824 824 } 825 825 .ifLet(\.$deeplinkInputConfirmation, action: \.deeplinkInputConfirmation) { 826 826 DeeplinkInputConfirmationFeature() 827 + } 828 + } 829 + 830 + // MARK: - Open worktree. 831 + 832 + private enum OpenWorktreeSource: String { 833 + case toolbar 834 + case contextMenu 835 + case revealInFinder 836 + } 837 + 838 + private func openWorktreeEffect( 839 + worktree: Worktree, 840 + action: OpenWorktreeAction, 841 + source: OpenWorktreeSource, 842 + state: State 843 + ) -> Effect<Action> { 844 + analyticsClient.capture("worktree_opened", ["action": action.settingsID, "source": source.rawValue]) 845 + guard action == .editor else { 846 + return .run { send in 847 + await workspaceClient.open(action, worktree) { error in 848 + send(.openWorktreeFailed(error)) 849 + } 850 + } 851 + } 852 + let shouldRunSetupScript = 853 + state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id) 854 + return .run { _ in 855 + await terminalClient.send( 856 + .createTabWithInput( 857 + worktree, 858 + input: "$EDITOR", 859 + runSetupScriptIfNew: shouldRunSetupScript 860 + ) 861 + ) 827 862 } 828 863 } 829 864
+5
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 249 249 case dismissToast 250 250 case delayedPullRequestRefresh(Worktree.ID) 251 251 case openRepositorySettings(Repository.ID) 252 + case contextMenuOpenWorktree(Worktree.ID, OpenWorktreeAction) 252 253 case worktreeCreationPrompt(PresentationAction<WorktreeCreationPromptFeature.Action>) 253 254 case alert(PresentationAction<Alert>) 254 255 case delegate(Delegate) ··· 308 309 case selectedWorktreeChanged(Worktree?) 309 310 case repositoriesChanged(IdentifiedArrayOf<Repository>) 310 311 case openRepositorySettings(Repository.ID) 312 + case openWorktreeInApp(Worktree.ID, OpenWorktreeAction) 311 313 case worktreeCreated(Worktree) 312 314 case runBlockingScript(Worktree, repositoryID: Repository.ID, kind: BlockingScriptKind, script: String) 313 315 case selectTerminalTab(Worktree.ID, tabId: TerminalTabID) ··· 2731 2733 2732 2734 case .openRepositorySettings(let repositoryID): 2733 2735 return .send(.delegate(.openRepositorySettings(repositoryID))) 2736 + 2737 + case .contextMenuOpenWorktree(let worktreeID, let action): 2738 + return .send(.delegate(.openWorktreeInApp(worktreeID, action))) 2734 2739 2735 2740 case .alert(.presented(.viewTerminalTab(let worktreeID, let tabId))): 2736 2741 return .merge(
+34 -23
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 84 84 onOpenActionSelectionChanged: { action in 85 85 store.send(.openActionSelectionChanged(action)) 86 86 }, 87 - onCopyPath: { 88 - NSPasteboard.general.clearContents() 89 - NSPasteboard.general.setString(selectedWorktree.workingDirectory.path, forType: .string) 87 + onRevealInFinder: { 88 + store.send(.revealInFinder) 90 89 }, 91 90 onSelectNotification: selectToolbarNotification, 92 91 onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, ··· 205 204 content: Content, 206 205 actions: FocusedActions 207 206 ) -> some View { 208 - content 207 + let resolvedSelection: OpenWorktreeAction? = 208 + actions.openSelectedWorktree != nil 209 + ? OpenWorktreeAction.availableSelection(store.openActionSelection) : nil 210 + return 211 + content 209 212 .focusedSceneValue(\.openSelectedWorktreeAction, actions.openSelectedWorktree) 213 + .focusedSceneValue(\.revealInFinderAction, actions.revealInFinder) 214 + .focusedSceneValue(\.openActionSelection, resolvedSelection) 210 215 .focusedSceneValue(\.newTerminalAction, actions.newTerminal) 211 216 .focusedValue(\.closeTabAction, actions.closeTab) 212 217 .focusedValue(\.closeSurfaceAction, actions.closeSurface) ··· 229 234 } 230 235 return FocusedActions( 231 236 openSelectedWorktree: action(.openSelectedWorktree), 237 + revealInFinder: action(.revealInFinder), 232 238 newTerminal: action(.newTerminal), 233 239 closeTab: action(.closeTab), 234 240 closeSurface: action(.closeSurface), ··· 262 268 263 269 private struct FocusedActions { 264 270 let openSelectedWorktree: (() -> Void)? 271 + let revealInFinder: (() -> Void)? 265 272 let newTerminal: (() -> Void)? 266 273 let closeTab: (() -> Void)? 267 274 let closeSurface: (() -> Void)? ··· 303 310 let onRenameBranch: (String) -> Void 304 311 let onOpenWorktree: (OpenWorktreeAction) -> Void 305 312 let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void 306 - let onCopyPath: () -> Void 313 + let onRevealInFinder: () -> Void 307 314 let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void 308 315 let onDismissAllNotifications: () -> Void 309 316 let onRunScript: () -> Void ··· 368 375 369 376 @ViewBuilder 370 377 private func openMenu(openActionSelection: OpenWorktreeAction, showExtras: Bool) -> some View { 371 - let availableActions = OpenWorktreeAction.availableCases 372 - let resolvedOpenActionSelection = OpenWorktreeAction.availableSelection(openActionSelection) 373 - Button { 374 - onOpenWorktree(resolvedOpenActionSelection) 375 - } label: { 376 - OpenWorktreeActionMenuLabelView( 377 - action: resolvedOpenActionSelection, 378 - shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openFinder, fallback: "") : nil 379 - ) 378 + let availableActions = OpenWorktreeAction.availableCases.filter { $0 != .finder } 379 + let resolved = OpenWorktreeAction.availableSelection(openActionSelection) 380 + let primarySelection = resolved == .finder ? availableActions.first : resolved 381 + if let primarySelection { 382 + Button { 383 + onOpenWorktree(primarySelection) 384 + } label: { 385 + OpenWorktreeActionMenuLabelView( 386 + action: primarySelection, 387 + shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openWorktree, fallback: "") : nil 388 + ) 389 + } 390 + .help(openActionHelpText(for: primarySelection, isDefault: true)) 380 391 } 381 - .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) 382 392 383 393 Menu { 384 394 ForEach(availableActions) { action in 385 - let isDefault = action == resolvedOpenActionSelection 395 + let isDefault = action == primarySelection 386 396 Button { 387 397 onOpenActionSelectionChanged(action) 388 398 onOpenWorktree(action) ··· 393 403 .help(openActionHelpText(for: action, isDefault: isDefault)) 394 404 } 395 405 Divider() 396 - Button("Copy Path") { 397 - onCopyPath() 406 + Button { 407 + onRevealInFinder() 408 + } label: { 409 + OpenWorktreeActionMenuLabelView(action: .finder, shortcutHint: nil) 398 410 } 399 - .help("Copy path") 411 + .help("Reveal in Finder (\(shortcutDisplay(for: AppShortcuts.revealInFinder)))") 400 412 } label: { 401 413 Image(systemName: "chevron.down") 402 414 .font(.caption2) ··· 405 417 .imageScale(.small) 406 418 .menuIndicator(.hidden) 407 419 .fixedSize() 408 - .help("Open in...") 409 - 420 + .help("Open in…") 410 421 } 411 422 412 423 private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { ··· 416 427 417 428 private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { 418 429 guard isDefault else { return action.title } 419 - return "\(action.title) (\(shortcutDisplay(for: AppShortcuts.openFinder)))" 430 + return "\(action.title) (\(shortcutDisplay(for: AppShortcuts.openWorktree)))" 420 431 } 421 432 } 422 433 ··· 744 755 onRenameBranch: { _ in }, 745 756 onOpenWorktree: { _ in }, 746 757 onOpenActionSelectionChanged: { _ in }, 747 - onCopyPath: {}, 758 + onRevealInFinder: {}, 748 759 onSelectNotification: { _, _ in }, 749 760 onDismissAllNotifications: {}, 750 761 onRunScript: {},
+47
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 233 233 return rows.isEmpty ? [row] : rows 234 234 } 235 235 236 + private var openActionSelection: OpenWorktreeAction { 237 + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 238 + return OpenWorktreeAction.fromSettingsID( 239 + repositorySettings.openActionID, 240 + defaultEditorID: settingsFile.global.defaultEditorID 241 + ) 242 + } 243 + 236 244 var body: some View { 237 245 let contextRows = contextRows 238 246 let isBulkSelection = contextRows.count > 1 239 247 let overrides = settingsFile.global.shortcutOverrides 240 248 let archiveShortcut = AppShortcuts.archiveWorktree.effective(from: overrides) 241 249 let deleteShortcut = AppShortcuts.deleteWorktree.effective(from: overrides) 250 + 251 + if !isBulkSelection { 252 + openActions(overrides: overrides) 253 + Divider() 254 + } 242 255 243 256 let pinnableRows = contextRows.filter { !$0.isMainWorktree } 244 257 if !pinnableRows.isEmpty { ··· 309 322 } 310 323 } 311 324 .appKeyboardShortcut(deleteShortcut) 325 + } 326 + 327 + @ViewBuilder 328 + private func openActions(overrides: [AppShortcutID: AppShortcutOverride]) -> some View { 329 + let availableActions = OpenWorktreeAction.availableCases.filter { $0 != .finder } 330 + let resolved = OpenWorktreeAction.availableSelection(openActionSelection) 331 + let primarySelection = resolved == .finder ? availableActions.first : resolved 332 + let openShortcut = AppShortcuts.openWorktree.effective(from: overrides) 333 + let revealShortcut = AppShortcuts.revealInFinder.effective(from: overrides) 334 + 335 + if let primarySelection { 336 + Button("Open with \(primarySelection.labelTitle)", systemImage: "arrow.up.right.square") { 337 + store.send(.contextMenuOpenWorktree(worktree.id, primarySelection)) 338 + } 339 + .appKeyboardShortcut(openShortcut) 340 + .help("Open with \(primarySelection.labelTitle) (\(openShortcut?.display ?? "none"))") 341 + } 342 + 343 + Menu("Open With") { 344 + ForEach(availableActions) { action in 345 + Button { 346 + store.send(.contextMenuOpenWorktree(worktree.id, action)) 347 + } label: { 348 + OpenWorktreeActionMenuLabelView(action: action, shortcutHint: nil) 349 + } 350 + .help("Open with \(action.labelTitle)") 351 + } 352 + } 353 + 354 + Button("Reveal in Finder", systemImage: "folder") { 355 + store.send(.contextMenuOpenWorktree(worktree.id, .finder)) 356 + } 357 + .appKeyboardShortcut(revealShortcut) 358 + .help("Reveal in Finder (\(revealShortcut?.display ?? "none"))") 312 359 } 313 360 314 361 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) {
+193
supacodeTests/AppFeatureOpenWorktreeTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import SupacodeSettingsFeature 7 + @testable import SupacodeSettingsShared 8 + @testable import supacode 9 + 10 + @MainActor 11 + struct AppFeatureOpenWorktreeTests { 12 + @Test(.dependencies) func revealInFinderOpensFinderAction() async { 13 + let (store, context) = makeStore() 14 + 15 + await store.send(.revealInFinder) 16 + #expect(context.openedActions.value == [.finder]) 17 + #expect(context.capturedEvents.value == [CapturedEvent(name: "worktree_opened", source: "revealInFinder")]) 18 + await store.finish() 19 + } 20 + 21 + @Test(.dependencies) func contextMenuOpenWorktreeDelegatesToAppFeature() async { 22 + let (store, context) = makeStore() 23 + 24 + await store.send(.repositories(.contextMenuOpenWorktree(context.worktree.id, .terminal))) 25 + await store.receive(\.repositories.delegate.openWorktreeInApp) 26 + #expect(context.openedActions.value == [.terminal]) 27 + #expect(context.capturedEvents.value == [CapturedEvent(name: "worktree_opened", source: "contextMenu")]) 28 + await store.finish() 29 + } 30 + 31 + @Test(.dependencies) func contextMenuEditorActionCreatesTerminalTab() async { 32 + let (store, context) = makeStore() 33 + 34 + await store.send(.repositories(.contextMenuOpenWorktree(context.worktree.id, .editor))) 35 + await store.receive(\.repositories.delegate.openWorktreeInApp) 36 + #expect(context.openedActions.value.isEmpty) 37 + #expect( 38 + context.terminalCommands.value == [ 39 + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false), 40 + ] 41 + ) 42 + await store.finish() 43 + } 44 + 45 + @Test(.dependencies) func contextMenuEditorActionRunsSetupScriptWhenPending() async { 46 + let (store, context) = makeStore { $0.pendingSetupScriptWorktreeIDs = [$1.id] } 47 + 48 + await store.send(.repositories(.contextMenuOpenWorktree(context.worktree.id, .editor))) 49 + await store.receive(\.repositories.delegate.openWorktreeInApp) 50 + #expect( 51 + context.terminalCommands.value == [ 52 + .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true), 53 + ] 54 + ) 55 + await store.finish() 56 + } 57 + 58 + @Test(.dependencies) func openWorktreeWithInvalidWorktreeIDIsIgnored() async { 59 + let (store, context) = makeStore() 60 + 61 + await store.send(.repositories(.contextMenuOpenWorktree("nonexistent-id", .terminal))) 62 + await store.receive(\.repositories.delegate.openWorktreeInApp) 63 + #expect(context.openedActions.value.isEmpty) 64 + await store.finish() 65 + } 66 + 67 + @Test(.dependencies) func openWorktreeWithNoSelectionIsIgnored() async { 68 + let (store, context) = makeStore { state, _ in state.selection = nil } 69 + 70 + await store.send(.openWorktree(.finder)) 71 + #expect(context.openedActions.value.isEmpty) 72 + await store.finish() 73 + } 74 + 75 + @Test(.dependencies) func revealInFinderWithNoSelectionIsIgnored() async { 76 + let (store, context) = makeStore { state, _ in state.selection = nil } 77 + 78 + await store.send(.revealInFinder) 79 + #expect(context.openedActions.value.isEmpty) 80 + await store.finish() 81 + } 82 + 83 + @Test(.dependencies) func openWorktreeFailedSetsAlert() async { 84 + let (store, _) = makeStore() 85 + 86 + let error = OpenActionError(title: "Failed", message: "App not found.") 87 + await store.send(.openWorktreeFailed(error)) { 88 + $0.alert = AlertState { 89 + TextState("Failed") 90 + } actions: { 91 + ButtonState(role: .cancel, action: .dismiss) { 92 + TextState("OK") 93 + } 94 + } message: { 95 + TextState("App not found.") 96 + } 97 + } 98 + await store.finish() 99 + } 100 + 101 + @Test(.dependencies) func openSelectedWorktreeRoutesToSelectedAction() async { 102 + let (store, context) = makeStore(appState: { $0.openActionSelection = .finder }) 103 + 104 + await store.send(.openSelectedWorktree) 105 + await store.receive(\.openWorktree) 106 + #expect(context.openedActions.value == [.finder]) 107 + #expect(context.capturedEvents.value == [CapturedEvent(name: "worktree_opened", source: "toolbar")]) 108 + await store.finish() 109 + } 110 + 111 + // MARK: - Helpers. 112 + 113 + private struct CapturedEvent: Equatable { 114 + let name: String 115 + let source: String? 116 + } 117 + 118 + private struct TestContext { 119 + let worktree: Worktree 120 + let openedActions: LockIsolated<[OpenWorktreeAction]> 121 + let terminalCommands: LockIsolated<[TerminalClient.Command]> 122 + let capturedEvents: LockIsolated<[CapturedEvent]> 123 + } 124 + 125 + private func makeStore( 126 + repositoriesState mutate: (inout RepositoriesFeature.State, Worktree) -> Void = { _, _ in }, 127 + appState mutateApp: (inout AppFeature.State) -> Void = { _ in } 128 + ) -> (TestStoreOf<AppFeature>, TestContext) { 129 + let worktree = makeWorktree() 130 + var repositoriesState = makeRepositoriesState(worktree: worktree) 131 + mutate(&repositoriesState, worktree) 132 + let openedActions = LockIsolated<[OpenWorktreeAction]>([]) 133 + let terminalCommands = LockIsolated<[TerminalClient.Command]>([]) 134 + let capturedEvents = LockIsolated<[CapturedEvent]>([]) 135 + let storage = SettingsTestStorage() 136 + let settingsFileURL = URL( 137 + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" 138 + ) 139 + var initialState = AppFeature.State( 140 + repositories: repositoriesState, 141 + settings: SettingsFeature.State() 142 + ) 143 + mutateApp(&initialState) 144 + let store = TestStore(initialState: initialState) { 145 + AppFeature() 146 + } withDependencies: { 147 + $0.settingsFileStorage = storage.storage 148 + $0.settingsFileURL = settingsFileURL 149 + $0.workspaceClient.open = { action, _, _ in 150 + openedActions.withValue { $0.append(action) } 151 + } 152 + $0.terminalClient.send = { command in 153 + terminalCommands.withValue { $0.append(command) } 154 + } 155 + $0.analyticsClient.capture = { event, properties in 156 + let source = properties?["source"] as? String 157 + capturedEvents.withValue { $0.append(CapturedEvent(name: event, source: source)) } 158 + } 159 + } 160 + let context = TestContext( 161 + worktree: worktree, 162 + openedActions: openedActions, 163 + terminalCommands: terminalCommands, 164 + capturedEvents: capturedEvents 165 + ) 166 + return (store, context) 167 + } 168 + 169 + private func makeWorktree() -> Worktree { 170 + let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 171 + let worktreeURL = repositoryRootURL.appending(path: "wt-1") 172 + return Worktree( 173 + id: worktreeURL.path(percentEncoded: false), 174 + name: "wt-1", 175 + detail: "detail", 176 + workingDirectory: worktreeURL, 177 + repositoryRootURL: repositoryRootURL 178 + ) 179 + } 180 + 181 + private func makeRepositoriesState(worktree: Worktree) -> RepositoriesFeature.State { 182 + let repository = Repository( 183 + id: worktree.repositoryRootURL.path(percentEncoded: false), 184 + rootURL: worktree.repositoryRootURL, 185 + name: "repo", 186 + worktrees: [worktree] 187 + ) 188 + var repositoriesState = RepositoriesFeature.State() 189 + repositoriesState.repositories = [repository] 190 + repositoriesState.selection = .worktree(worktree.id) 191 + return repositoriesState 192 + } 193 + }
+22
supacodeTests/AppShortcutsTests.swift
··· 6 6 @testable import SupacodeSettingsShared 7 7 @testable import supacode 8 8 9 + private struct PlainCodingKey: CodingKey { 10 + var stringValue: String 11 + var intValue: Int? { nil } 12 + init(_ stringValue: String) { self.stringValue = stringValue } 13 + init?(stringValue: String) { self.stringValue = stringValue } 14 + init?(intValue: Int) { nil } 15 + } 16 + 9 17 @MainActor 10 18 struct AppShortcutsTests { 11 19 @Test func displaySymbolsMatchDisplay() { ··· 173 181 @Test func groupsCategoriesMatchAllCases() { 174 182 let groupCategories = AppShortcuts.groups.map(\.category) 175 183 expectNoDifference(groupCategories, AppShortcutCategory.allCases) 184 + } 185 + 186 + // MARK: - Backward-compatible key migration. 187 + 188 + @Test func legacyOpenFinderKeyDecodesToOpenWorktree() { 189 + // Existing user settings may contain "openFinder" from before the rename. 190 + let decoded = AppShortcutID(codingKey: PlainCodingKey("openFinder")) 191 + #expect(decoded == .openWorktree) 192 + } 193 + 194 + @Test func openWorktreeKeyRoundTrips() { 195 + let decoded = AppShortcutID(codingKey: PlainCodingKey("openWorktree")) 196 + #expect(decoded == .openWorktree) 197 + #expect(decoded?.codingKey.stringValue == "openWorktree") 176 198 } 177 199 178 200 // MARK: - Override ghost keybind propagation.
+19
supacodeTests/SettingsFeatureTests.swift
··· 380 380 } 381 381 } 382 382 383 + @Test(.dependencies) func setSelectionToNonRepositoryClearsRepositorySettings() async { 384 + let summary = SettingsRepositorySummary(id: "/tmp/repo", name: "Repo") 385 + var state = SettingsFeature.State() 386 + state.selection = .repository(summary.id) 387 + state.repositorySummaries = [summary] 388 + state.repositorySettings = RepositorySettingsFeature.State( 389 + rootURL: summary.rootURL, 390 + settings: .default 391 + ) 392 + let store = TestStore(initialState: state) { 393 + SettingsFeature() 394 + } 395 + 396 + await store.send(.setSelection(.general)) { 397 + $0.selection = .general 398 + $0.repositorySettings = nil 399 + } 400 + } 401 + 383 402 // MARK: - Keyboard shortcut overrides. 384 403 385 404 @Test(.dependencies) func updateShortcutPersistsOverride() async {