native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge remote-tracking branch 'upstream/main'

# Conflicts:
# supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
# supacode/Features/Settings/Views/RepositorySettingsView.swift
# supacodeTests/RepositoryPathsTests.swift

onevcat 44ab7157 0dd40d6a

+859 -91
+8 -8
supacode.xcodeproj/project.pbxproj
··· 440 440 CODE_SIGN_STYLE = Automatic; 441 441 COMBINE_HIDPI_IMAGES = YES; 442 442 COMPILATION_CACHE_ENABLE_CACHING = YES; 443 - CURRENT_PROJECT_VERSION = 110; 443 + CURRENT_PROJECT_VERSION = 112; 444 444 DEAD_CODE_STRIPPING = YES; 445 445 DEBUG_INFORMATION_FORMAT = dwarf; 446 446 DEVELOPMENT_TEAM = ""; ··· 462 462 "@executable_path/../Frameworks", 463 463 ); 464 464 MACOSX_DEPLOYMENT_TARGET = 26.0; 465 - MARKETING_VERSION = 0.6.5; 465 + MARKETING_VERSION = 0.6.7; 466 466 OTHER_LDFLAGS = ( 467 467 "$(inherited)", 468 468 "-lc++", ··· 496 496 CODE_SIGN_STYLE = Automatic; 497 497 COMBINE_HIDPI_IMAGES = YES; 498 498 COMPILATION_CACHE_ENABLE_CACHING = NO; 499 - CURRENT_PROJECT_VERSION = 110; 499 + CURRENT_PROJECT_VERSION = 112; 500 500 DEAD_CODE_STRIPPING = YES; 501 501 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 502 502 DEVELOPMENT_TEAM = ""; ··· 518 518 "@executable_path/../Frameworks", 519 519 ); 520 520 MACOSX_DEPLOYMENT_TARGET = 26.0; 521 - MARKETING_VERSION = 0.6.5; 521 + MARKETING_VERSION = 0.6.7; 522 522 OTHER_LDFLAGS = ( 523 523 "$(inherited)", 524 524 "-lc++", ··· 547 547 buildSettings = { 548 548 BUNDLE_LOADER = "$(TEST_HOST)"; 549 549 CODE_SIGN_STYLE = Automatic; 550 - CURRENT_PROJECT_VERSION = 110; 550 + CURRENT_PROJECT_VERSION = 112; 551 551 DEAD_CODE_STRIPPING = YES; 552 552 DEVELOPMENT_TEAM = ""; 553 553 GENERATE_INFOPLIST_FILE = YES; 554 554 MACOSX_DEPLOYMENT_TARGET = 26.1; 555 - MARKETING_VERSION = 0.6.5; 555 + MARKETING_VERSION = 0.6.7; 556 556 PRODUCT_BUNDLE_IDENTIFIER = app.supabit.supacodeTests; 557 557 PRODUCT_NAME = "$(TARGET_NAME)"; 558 558 STRING_CATALOG_GENERATE_SYMBOLS = NO; ··· 569 569 buildSettings = { 570 570 BUNDLE_LOADER = "$(TEST_HOST)"; 571 571 CODE_SIGN_STYLE = Automatic; 572 - CURRENT_PROJECT_VERSION = 110; 572 + CURRENT_PROJECT_VERSION = 112; 573 573 DEAD_CODE_STRIPPING = YES; 574 574 DEVELOPMENT_TEAM = ""; 575 575 GENERATE_INFOPLIST_FILE = YES; 576 576 MACOSX_DEPLOYMENT_TARGET = 26.1; 577 - MARKETING_VERSION = 0.6.5; 577 + MARKETING_VERSION = 0.6.7; 578 578 PRODUCT_BUNDLE_IDENTIFIER = app.supabit.supacodeTests; 579 579 PRODUCT_NAME = "$(TARGET_NAME)"; 580 580 STRING_CATALOG_GENERATE_SYMBOLS = NO;
+6 -2
supacode/App/ContentView.swift
··· 14 14 @Bindable var repositoriesStore: StoreOf<RepositoriesFeature> 15 15 let terminalManager: WorktreeTerminalManager 16 16 @Environment(\.scenePhase) private var scenePhase 17 + @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 17 18 @State private var leftSidebarVisibility: NavigationSplitViewVisibility = .all 18 19 19 20 init(store: StoreOf<AppFeature>, terminalManager: WorktreeTerminalManager) { ··· 90 91 .overlay { 91 92 CommandPaletteOverlayView( 92 93 store: store.scope(state: \.commandPalette, action: \.commandPalette), 93 - items: store.commandPaletteItems 94 + items: CommandPaletteFeature.commandPaletteItems( 95 + from: store.repositories, 96 + ghosttyCommands: ghosttyShortcuts.commandPaletteEntries 97 + ) 94 98 ) 95 99 } 96 100 .background(WindowTabbingDisabler()) ··· 164 168 text: $script, 165 169 isMonospaced: true 166 170 ) 167 - .frame(minHeight: 160) 171 + .frame(minHeight: 160) 168 172 if script.isEmpty { 169 173 Text("npm run dev") 170 174 .foregroundStyle(.secondary)
+14 -16
supacode/Clients/Git/GitClient.swift
··· 241 241 nonisolated func createWorktree( 242 242 named name: String, 243 243 in repoRoot: URL, 244 - copyIgnored: Bool, 245 - copyUntracked: Bool, 244 + baseDirectory: URL, 245 + copyFiles: (ignored: Bool, untracked: Bool), 246 246 baseRef: String 247 247 ) async throws -> Worktree { 248 248 var createdWorktree: Worktree? 249 249 for try await event in createWorktreeStream( 250 250 named: name, 251 251 in: repoRoot, 252 - copyIgnored: copyIgnored, 253 - copyUntracked: copyUntracked, 252 + baseDirectory: baseDirectory, 253 + copyFiles: copyFiles, 254 254 baseRef: baseRef 255 255 ) { 256 256 if case .finished(let worktree) = event { ··· 258 258 } 259 259 } 260 260 guard let createdWorktree else { 261 - let repositoryRootURL = repoRoot.standardizedFileURL 262 261 let wtURL = try wtScriptURL() 263 262 let command = 264 263 ([wtURL.lastPathComponent] 265 264 + createWorktreeArguments( 266 - repositoryRootURL: repositoryRootURL, 265 + baseDirectory: baseDirectory, 267 266 name: name, 268 - copyIgnored: copyIgnored, 269 - copyUntracked: copyUntracked, 267 + copyIgnored: copyFiles.ignored, 268 + copyUntracked: copyFiles.untracked, 270 269 baseRef: baseRef 271 270 )).joined(separator: " ") 272 271 throw GitClientError.commandFailed(command: command, message: "Empty output") ··· 277 276 nonisolated func createWorktreeStream( 278 277 named name: String, 279 278 in repoRoot: URL, 280 - copyIgnored: Bool, 281 - copyUntracked: Bool, 279 + baseDirectory: URL, 280 + copyFiles: (ignored: Bool, untracked: Bool), 282 281 baseRef: String 283 282 ) -> AsyncThrowingStream<GitWorktreeCreateEvent, Error> { 284 283 AsyncThrowingStream { continuation in ··· 287 286 do { 288 287 let wtURL = try wtScriptURL() 289 288 let arguments = createWorktreeArguments( 290 - repositoryRootURL: repositoryRootURL, 289 + baseDirectory: baseDirectory, 291 290 name: name, 292 - copyIgnored: copyIgnored, 293 - copyUntracked: copyUntracked, 291 + copyIgnored: copyFiles.ignored, 292 + copyUntracked: copyFiles.untracked, 294 293 baseRef: baseRef 295 294 ) 296 295 let envURL = URL(fileURLWithPath: "/usr/bin/env") ··· 358 357 } 359 358 360 359 nonisolated private func createWorktreeArguments( 361 - repositoryRootURL: URL, 360 + baseDirectory: URL, 362 361 name: String, 363 362 copyIgnored: Bool, 364 363 copyUntracked: Bool, 365 364 baseRef: String 366 365 ) -> [String] { 367 - let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL) 368 - var arguments = ["--base-dir", baseDir.path(percentEncoded: false), "sw"] 366 + var arguments = ["--base-dir", baseDirectory.path(percentEncoded: false), "sw"] 369 367 if copyIgnored { 370 368 arguments.append("--copy-ignored") 371 369 }
+2 -1
supacode/Clients/Notifications/SystemNotificationClient.swift
··· 8 8 _ center: UNUserNotificationCenter, 9 9 willPresent notification: UNNotification 10 10 ) async -> UNNotificationPresentationOptions { 11 - [.badge, .sound, .banner] 11 + await Task.yield() 12 + return [.badge, .sound, .banner] 12 13 } 13 14 } 14 15
+8 -6
supacode/Clients/Repositories/GitClientDependency.swift
··· 16 16 @Sendable ( 17 17 _ name: String, 18 18 _ repoRoot: URL, 19 + _ baseDirectory: URL, 19 20 _ copyIgnored: Bool, 20 21 _ copyUntracked: Bool, 21 22 _ baseRef: String ··· 25 26 @Sendable ( 26 27 _ name: String, 27 28 _ repoRoot: URL, 29 + _ baseDirectory: URL, 28 30 _ copyIgnored: Bool, 29 31 _ copyUntracked: Bool, 30 32 _ baseRef: String ··· 51 53 automaticWorktreeBaseRef: { await GitClient().automaticWorktreeBaseRef(for: $0) }, 52 54 ignoredFileCount: { try await GitClient().ignoredFileCount(for: $0) }, 53 55 untrackedFileCount: { try await GitClient().untrackedFileCount(for: $0) }, 54 - createWorktree: { name, repoRoot, copyIgnored, copyUntracked, baseRef in 56 + createWorktree: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef in 55 57 try await GitClient().createWorktree( 56 58 named: name, 57 59 in: repoRoot, 58 - copyIgnored: copyIgnored, 59 - copyUntracked: copyUntracked, 60 + baseDirectory: baseDirectory, 61 + copyFiles: (ignored: copyIgnored, untracked: copyUntracked), 60 62 baseRef: baseRef 61 63 ) 62 64 }, 63 - createWorktreeStream: { name, repoRoot, copyIgnored, copyUntracked, baseRef in 65 + createWorktreeStream: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef in 64 66 GitClient().createWorktreeStream( 65 67 named: name, 66 68 in: repoRoot, 67 - copyIgnored: copyIgnored, 68 - copyUntracked: copyUntracked, 69 + baseDirectory: baseDirectory, 70 + copyFiles: (ignored: copyIgnored, untracked: copyUntracked), 69 71 baseRef: baseRef 70 72 ) 71 73 },
+1
supacode/Clients/Terminal/TerminalClient.swift
··· 14 14 case stopRunScript(Worktree) 15 15 case closeFocusedTab(Worktree) 16 16 case closeFocusedSurface(Worktree) 17 + case performBindingAction(Worktree, action: String) 17 18 case startSearch(Worktree) 18 19 case searchSelection(Worktree) 19 20 case navigateSearchNext(Worktree)
+15 -5
supacode/Commands/TerminalCommands.swift
··· 37 37 Button("Find...") { 38 38 startSearchAction?() 39 39 } 40 - .keyboardShortcut("f", modifiers: .command) 40 + .modifier( 41 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "start_search")) 42 + ) 41 43 .disabled(startSearchAction == nil) 42 44 43 45 Button("Find Next") { 44 46 navigateSearchNextAction?() 45 47 } 46 - .keyboardShortcut("g", modifiers: .command) 48 + .modifier( 49 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:next")) 50 + ) 47 51 .disabled(navigateSearchNextAction == nil) 48 52 49 53 Button("Find Previous") { 50 54 navigateSearchPreviousAction?() 51 55 } 52 - .keyboardShortcut("g", modifiers: [.command, .shift]) 56 + .modifier( 57 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search:previous")) 58 + ) 53 59 .disabled(navigateSearchPreviousAction == nil) 54 60 55 61 Divider() ··· 57 63 Button("Hide Find Bar") { 58 64 endSearchAction?() 59 65 } 60 - .keyboardShortcut("f", modifiers: [.command, .shift]) 66 + .modifier( 67 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "end_search")) 68 + ) 61 69 .disabled(endSearchAction == nil) 62 70 63 71 Divider() ··· 65 73 Button("Use Selection for Find") { 66 74 searchSelectionAction?() 67 75 } 68 - .keyboardShortcut("e", modifiers: .command) 76 + .modifier( 77 + KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "search_selection")) 78 + ) 69 79 .disabled(searchSelectionAction == nil) 70 80 } 71 81 }
+8 -3
supacode/Features/App/Reducer/AppFeature.swift
··· 25 25 var notificationIndicatorCount: Int = 0 26 26 var lastKnownSystemNotificationsEnabled: Bool 27 27 @Presents var alert: AlertState<Alert>? 28 - var commandPaletteItems: [CommandPaletteItem] { 29 - CommandPaletteFeature.commandPaletteItems(from: repositories) 30 - } 31 28 32 29 init( 33 30 repositories: RepositoriesFeature.State = .init(), ··· 695 692 696 693 case .commandPalette(.delegate(.refreshWorktrees)): 697 694 return .send(.repositories(.refreshWorktrees)) 695 + 696 + case .commandPalette(.delegate(.ghosttyCommand(let action))): 697 + guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { 698 + return .none 699 + } 700 + return .run { _ in 701 + await terminalClient.send(.performBindingAction(worktree, action: action)) 702 + } 698 703 699 704 case .commandPalette(.delegate(.openPullRequest(let worktreeID))): 700 705 return .send(.repositories(.pullRequestAction(worktreeID, .openOnGithub)))
+7
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 30 30 case removeWorktree(Worktree.ID, Repository.ID) 31 31 case archiveWorktree(Worktree.ID, Repository.ID) 32 32 case refreshWorktrees 33 + case ghosttyCommand(String) 33 34 case openPullRequest(Worktree.ID) 34 35 case markPullRequestReady(Worktree.ID) 35 36 case mergePullRequest(Worktree.ID) ··· 47 48 switch kind { 48 49 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 49 50 return true 51 + case .ghosttyCommand: 52 + return false 50 53 case .openPullRequest, 51 54 .markPullRequestReady, 52 55 .mergePullRequest, ··· 69 72 switch kind { 70 73 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 71 74 return true 75 + case .ghosttyCommand: 76 + return false 72 77 case .openPullRequest, 73 78 .markPullRequestReady, 74 79 .mergePullRequest, ··· 100 105 return AppShortcuts.newWorktree 101 106 case .refreshWorktrees: 102 107 return AppShortcuts.refreshWorktrees 108 + case .ghosttyCommand: 109 + return nil 103 110 case .openPullRequest: 104 111 return AppShortcuts.openPullRequest 105 112 case .markPullRequestReady,
+28 -2
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 39 39 case removeWorktree(Worktree.ID, Repository.ID) 40 40 case archiveWorktree(Worktree.ID, Repository.ID) 41 41 case refreshWorktrees 42 + case ghosttyCommand(String) 42 43 case openPullRequest(Worktree.ID) 43 44 case markPullRequestReady(Worktree.ID) 44 45 case mergePullRequest(Worktree.ID) ··· 160 161 } 161 162 162 163 static func commandPaletteItems( 163 - from repositories: RepositoriesFeature.State 164 + from repositories: RepositoriesFeature.State, 165 + ghosttyCommands: [GhosttyCommand] = [] 164 166 ) -> [CommandPaletteItem] { 165 167 var items: [CommandPaletteItem] = [ 166 168 CommandPaletteItem( ··· 194 196 kind: .refreshWorktrees 195 197 ), 196 198 ] 199 + if repositories.selectedWorktreeID != nil { 200 + items.append(contentsOf: ghosttyCommandItems(ghosttyCommands)) 201 + } 197 202 if let selectedWorktreeID = repositories.selectedWorktreeID, 198 203 let repositoryID = repositories.repositoryID(containing: selectedWorktreeID), 199 204 let pullRequest = repositories.worktreeInfo(for: selectedWorktreeID)?.pullRequest, ··· 408 413 #endif 409 414 410 415 private enum CommandPaletteItemID { 416 + static let ghosttyPrefix = "ghostty." 411 417 static let globalCheckForUpdates = "global.check-for-updates" 412 418 static let globalOpenSettings = "global.open-settings" 413 419 static let globalOpenRepository = "global.open-repository" ··· 428 434 "worktree.\(worktreeID).select" 429 435 } 430 436 437 + static func ghosttyCommand(_ command: GhosttyCommand) -> CommandPaletteItem.ID { 438 + "\(ghosttyPrefix)\(command.action)|\(command.title)" 439 + } 440 + 431 441 static func pullRequestIDs(repositoryID: Repository.ID) -> [CommandPaletteItem.ID] { 432 442 [ 433 443 pullRequestOpen(repositoryID), ··· 524 534 return .archiveWorktree(worktreeID, repositoryID) 525 535 case .refreshWorktrees: 526 536 return .refreshWorktrees 537 + case .ghosttyCommand(let action): 538 + return .ghosttyCommand(action) 527 539 case .openPullRequest, 528 540 .markPullRequestReady, 529 541 .mergePullRequest, ··· 567 579 .openRepository, 568 580 .removeWorktree, 569 581 .archiveWorktree, 570 - .refreshWorktrees: 582 + .refreshWorktrees, 583 + .ghosttyCommand: 571 584 return nil 572 585 #if DEBUG 573 586 case .debugTestToast: 574 587 return nil 575 588 #endif 589 + } 590 + } 591 + 592 + private func ghosttyCommandItems(_ commands: [GhosttyCommand]) -> [CommandPaletteItem] { 593 + commands.map { command in 594 + let subtitle = command.description.trimmingCharacters(in: .whitespacesAndNewlines) 595 + return CommandPaletteItem( 596 + id: CommandPaletteItemID.ghosttyCommand(command), 597 + title: command.title, 598 + subtitle: subtitle.isEmpty ? nil : subtitle, 599 + kind: .ghosttyCommand(command.action), 600 + priorityTier: CommandPaletteItem.defaultPriorityTier + 100 601 + ) 576 602 } 577 603 } 578 604
+6
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 341 341 private var badge: String? { 342 342 switch row.kind { 343 343 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 344 + .ghosttyCommand, 344 345 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 345 346 .copyCiFailureLogs, 346 347 .rerunFailedJobs, .openFailingCheckDetails, .worktreeSelect: ··· 368 369 return "plus" 369 370 case .refreshWorktrees: 370 371 return "arrow.clockwise" 372 + case .ghosttyCommand: 373 + return "terminal" 371 374 case .openPullRequest: 372 375 return "arrow.up.right.square" 373 376 case .markPullRequestReady: ··· 400 403 private var emphasis: Bool { 401 404 switch row.kind { 402 405 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees, 406 + .ghosttyCommand, 403 407 .openPullRequest, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, 404 408 .copyCiFailureLogs, 405 409 .rerunFailedJobs, .openFailingCheckDetails: ··· 492 496 base = "New Worktree" 493 497 case .refreshWorktrees: 494 498 base = "Refresh Worktrees" 499 + case .ghosttyCommand: 500 + base = row.title 495 501 case .removeWorktree: 496 502 base = "Remove \(row.title)" 497 503 case .archiveWorktree:
+32 -14
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 181 181 pendingID: Worktree.ID, 182 182 previousSelection: Worktree.ID?, 183 183 repositoryID: Repository.ID, 184 - name: String? 184 + name: String?, 185 + baseDirectory: URL 185 186 ) 186 187 case consumeSetupScript(Worktree.ID) 187 188 case consumeTerminalFocus(Worktree.ID) ··· 812 813 } 813 814 let previousSelection = state.selectedWorktreeID 814 815 let pendingID = "pending:\(uuid().uuidString)" 816 + @Shared(.settingsFile) var settingsFile 815 817 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 818 + let globalDefaultWorktreeBaseDirectoryPath = settingsFile.global.defaultWorktreeBaseDirectoryPath 819 + let worktreeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 820 + for: repository.rootURL, 821 + globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 822 + repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath 823 + ) 816 824 let selectedBaseRef = repositorySettings.worktreeBaseRef 817 825 let copyIgnoredOnWorktreeCreate = repositorySettings.copyIgnoredOnWorktreeCreate 818 826 let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate ··· 866 874 pendingID: pendingID, 867 875 previousSelection: previousSelection, 868 876 repositoryID: repository.id, 869 - name: nil 877 + name: nil, 878 + baseDirectory: worktreeBaseDirectory 870 879 ) 871 880 ) 872 881 return ··· 882 891 pendingID: pendingID, 883 892 previousSelection: previousSelection, 884 893 repositoryID: repository.id, 885 - name: nil 894 + name: nil, 895 + baseDirectory: worktreeBaseDirectory 886 896 ) 887 897 ) 888 898 return ··· 895 905 pendingID: pendingID, 896 906 previousSelection: previousSelection, 897 907 repositoryID: repository.id, 898 - name: nil 908 + name: nil, 909 + baseDirectory: worktreeBaseDirectory 899 910 ) 900 911 ) 901 912 return ··· 908 919 pendingID: pendingID, 909 920 previousSelection: previousSelection, 910 921 repositoryID: repository.id, 911 - name: nil 922 + name: nil, 923 + baseDirectory: worktreeBaseDirectory 912 924 ) 913 925 ) 914 926 return ··· 921 933 pendingID: pendingID, 922 934 previousSelection: previousSelection, 923 935 repositoryID: repository.id, 924 - name: nil 936 + name: nil, 937 + baseDirectory: worktreeBaseDirectory 925 938 ) 926 939 ) 927 940 return ··· 971 984 copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 972 985 progress.stage = .creatingWorktree 973 986 progress.commandText = worktreeCreateCommand( 974 - repositoryRootURL: repository.rootURL, 987 + baseDirectoryURL: worktreeBaseDirectory, 975 988 name: name, 976 989 copyIgnored: copyIgnored, 977 990 copyUntracked: copyUntracked, ··· 986 999 let stream = createWorktreeStream( 987 1000 name, 988 1001 repository.rootURL, 1002 + worktreeBaseDirectory, 989 1003 copyIgnored, 990 1004 copyUntracked, 991 1005 resolvedBaseRef ··· 1045 1059 pendingID: pendingID, 1046 1060 previousSelection: previousSelection, 1047 1061 repositoryID: repository.id, 1048 - name: newWorktreeName 1062 + name: newWorktreeName, 1063 + baseDirectory: worktreeBaseDirectory 1049 1064 ) 1050 1065 ) 1051 1066 } ··· 1091 1106 let pendingID, 1092 1107 let previousSelection, 1093 1108 let repositoryID, 1094 - let name 1109 + let name, 1110 + let baseDirectory 1095 1111 ): 1096 1112 let previousSelectedWorktree = state.worktree(for: previousSelection) 1097 1113 removePendingWorktree(pendingID, state: &state) ··· 1099 1115 let cleanup = cleanupFailedWorktree( 1100 1116 repositoryID: repositoryID, 1101 1117 name: name, 1118 + baseDirectory: baseDirectory, 1102 1119 state: &state 1103 1120 ) 1104 1121 state.alert = messageAlert(title: title, message: message) ··· 3196 3213 private func cleanupFailedWorktree( 3197 3214 repositoryID: Repository.ID, 3198 3215 name: String?, 3216 + baseDirectory: URL, 3199 3217 state: inout RepositoriesFeature.State 3200 3218 ) -> FailedWorktreeCleanup { 3201 3219 guard let name, !name.isEmpty else { ··· 3207 3225 ) 3208 3226 } 3209 3227 let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL 3210 - let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL).standardizedFileURL 3228 + let normalizedBaseDirectory = baseDirectory.standardizedFileURL 3211 3229 let worktreeURL = 3212 - baseDirectory 3230 + normalizedBaseDirectory 3213 3231 .appending(path: name, directoryHint: .isDirectory) 3214 3232 .standardizedFileURL 3215 - guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: baseDirectory) else { 3233 + guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: normalizedBaseDirectory) else { 3216 3234 return FailedWorktreeCleanup( 3217 3235 didRemoveWorktree: false, 3218 3236 didUpdatePinned: false, ··· 3301 3319 } 3302 3320 3303 3321 private nonisolated func worktreeCreateCommand( 3304 - repositoryRootURL: URL, 3322 + baseDirectoryURL: URL, 3305 3323 name: String, 3306 3324 copyIgnored: Bool, 3307 3325 copyUntracked: Bool, 3308 3326 baseRef: String 3309 3327 ) -> String { 3310 - let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL).path(percentEncoded: false) 3328 + let baseDir = baseDirectoryURL.path(percentEncoded: false) 3311 3329 var parts = ["wt", "--base-dir", baseDir, "sw"] 3312 3330 if copyIgnored { 3313 3331 parts.append("--copy-ignored")
+42 -5
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 8 8 var rootURL: URL 9 9 var settings: RepositorySettings 10 10 var onevcatSettings: OnevcatRepositorySettings 11 + var globalDefaultWorktreeBaseDirectoryPath: String? 11 12 var isBareRepository = false 12 13 var branchOptions: [String] = [] 13 14 var defaultWorktreeBaseRef = "origin/main" 14 15 var isBranchDataLoaded = false 16 + 17 + var exampleWorktreePath: String { 18 + SupacodePaths.exampleWorktreePath( 19 + for: rootURL, 20 + globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 21 + repositoryOverridePath: settings.worktreeBaseDirectoryPath 22 + ) 23 + } 15 24 } 16 25 17 26 enum Action: BindableAction { 18 27 case task 19 - case settingsLoaded(RepositorySettings, OnevcatRepositorySettings, isBareRepository: Bool) 28 + case settingsLoaded( 29 + RepositorySettings, 30 + OnevcatRepositorySettings, 31 + isBareRepository: Bool, 32 + globalDefaultWorktreeBaseDirectoryPath: String? 33 + ) 20 34 case branchDataLoaded([String], defaultBaseRef: String) 21 35 case delegate(Delegate) 22 36 case binding(BindingAction<State>) ··· 37 51 let rootURL = state.rootURL 38 52 @Shared(.repositorySettings(rootURL)) var repositorySettings 39 53 @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 54 + @Shared(.settingsFile) var settingsFile 40 55 let settings = repositorySettings 41 56 let onevcatSettings = onevcatRepositorySettings 57 + let globalDefaultWorktreeBaseDirectoryPath = 58 + settingsFile.global.defaultWorktreeBaseDirectoryPath 42 59 let gitClient = gitClient 43 60 return .run { send in 44 61 let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false 45 - await send(.settingsLoaded(settings, onevcatSettings, isBareRepository: isBareRepository)) 62 + await send( 63 + .settingsLoaded( 64 + settings, 65 + onevcatSettings, 66 + isBareRepository: isBareRepository, 67 + globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 68 + ) 69 + ) 46 70 let branches: [String] 47 71 do { 48 72 branches = try await gitClient.branchRefs(rootURL) ··· 57 81 await send(.branchDataLoaded(branches, defaultBaseRef: defaultBaseRef)) 58 82 } 59 83 60 - case .settingsLoaded(let settings, let onevcatSettings, let isBareRepository): 84 + case .settingsLoaded( 85 + let settings, let onevcatSettings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath 86 + ): 61 87 var updatedSettings = settings 88 + updatedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 89 + updatedSettings.worktreeBaseDirectoryPath, 90 + repositoryRootURL: state.rootURL 91 + ) 62 92 if isBareRepository { 63 93 updatedSettings.copyIgnoredOnWorktreeCreate = false 64 94 updatedSettings.copyUntrackedOnWorktreeCreate = false 65 95 } 66 96 state.settings = updatedSettings 67 97 state.onevcatSettings = onevcatSettings.normalized() 98 + state.globalDefaultWorktreeBaseDirectoryPath = 99 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) 68 100 state.isBareRepository = isBareRepository 69 - guard isBareRepository, updatedSettings != settings else { return .none } 101 + guard updatedSettings != settings else { return .none } 70 102 let rootURL = state.rootURL 71 103 @Shared(.repositorySettings(rootURL)) var repositorySettings 72 104 $repositorySettings.withLock { $0 = updatedSettings } ··· 92 124 } 93 125 state.onevcatSettings = state.onevcatSettings.normalized() 94 126 let rootURL = state.rootURL 127 + var normalizedSettings = state.settings 128 + normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 129 + normalizedSettings.worktreeBaseDirectoryPath, 130 + repositoryRootURL: rootURL 131 + ) 95 132 @Shared(.repositorySettings(rootURL)) var repositorySettings 96 133 @Shared(.onevcatRepositorySettings(rootURL)) var onevcatRepositorySettings 97 - $repositorySettings.withLock { $0 = state.settings } 134 + $repositorySettings.withLock { $0 = normalizedSettings } 98 135 $onevcatRepositorySettings.withLock { $0 = state.onevcatSettings } 99 136 return .send(.delegate(.settingsChanged(rootURL))) 100 137
+9 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 15 15 var deleteBranchOnDeleteWorktree: Bool 16 16 var automaticallyArchiveMergedWorktrees: Bool 17 17 var promptForWorktreeCreation: Bool 18 + var defaultWorktreeBaseDirectoryPath: String? 18 19 19 20 static let `default` = GlobalSettings( 20 21 appearanceMode: .dark, ··· 32 33 githubIntegrationEnabled: true, 33 34 deleteBranchOnDeleteWorktree: true, 34 35 automaticallyArchiveMergedWorktrees: false, 35 - promptForWorktreeCreation: true 36 + promptForWorktreeCreation: true, 37 + defaultWorktreeBaseDirectoryPath: nil 36 38 ) 37 39 38 40 init( ··· 51 53 githubIntegrationEnabled: Bool, 52 54 deleteBranchOnDeleteWorktree: Bool, 53 55 automaticallyArchiveMergedWorktrees: Bool, 54 - promptForWorktreeCreation: Bool 56 + promptForWorktreeCreation: Bool, 57 + defaultWorktreeBaseDirectoryPath: String? = nil 55 58 ) { 56 59 self.appearanceMode = appearanceMode 57 60 self.defaultEditorID = defaultEditorID ··· 69 72 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 70 73 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 71 74 self.promptForWorktreeCreation = promptForWorktreeCreation 75 + self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 72 76 } 73 77 74 78 init(from decoder: any Decoder) throws { ··· 115 119 promptForWorktreeCreation = 116 120 try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 117 121 ?? Self.default.promptForWorktreeCreation 122 + defaultWorktreeBaseDirectoryPath = 123 + try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 124 + ?? Self.default.defaultWorktreeBaseDirectoryPath 118 125 } 119 126 }
+7
supacode/Features/Settings/Models/RepositorySettings.swift
··· 6 6 var runScript: String 7 7 var openActionID: String 8 8 var worktreeBaseRef: String? 9 + var worktreeBaseDirectoryPath: String? 9 10 var copyIgnoredOnWorktreeCreate: Bool 10 11 var copyUntrackedOnWorktreeCreate: Bool 11 12 var pullRequestMergeStrategy: PullRequestMergeStrategy ··· 16 17 case runScript 17 18 case openActionID 18 19 case worktreeBaseRef 20 + case worktreeBaseDirectoryPath 19 21 case copyIgnoredOnWorktreeCreate 20 22 case copyUntrackedOnWorktreeCreate 21 23 case pullRequestMergeStrategy ··· 27 29 runScript: "", 28 30 openActionID: OpenWorktreeAction.automaticSettingsID, 29 31 worktreeBaseRef: nil, 32 + worktreeBaseDirectoryPath: nil, 30 33 copyIgnoredOnWorktreeCreate: false, 31 34 copyUntrackedOnWorktreeCreate: false, 32 35 pullRequestMergeStrategy: .merge ··· 38 41 runScript: String, 39 42 openActionID: String, 40 43 worktreeBaseRef: String?, 44 + worktreeBaseDirectoryPath: String? = nil, 41 45 copyIgnoredOnWorktreeCreate: Bool, 42 46 copyUntrackedOnWorktreeCreate: Bool, 43 47 pullRequestMergeStrategy: PullRequestMergeStrategy ··· 47 51 self.runScript = runScript 48 52 self.openActionID = openActionID 49 53 self.worktreeBaseRef = worktreeBaseRef 54 + self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath 50 55 self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate 51 56 self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 52 57 self.pullRequestMergeStrategy = pullRequestMergeStrategy ··· 68 73 ?? Self.default.openActionID 69 74 worktreeBaseRef = 70 75 try container.decodeIfPresent(String.self, forKey: .worktreeBaseRef) 76 + worktreeBaseDirectoryPath = 77 + try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) 71 78 copyIgnoredOnWorktreeCreate = 72 79 try container.decodeIfPresent( 73 80 Bool.self,
+22 -2
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 21 21 var deleteBranchOnDeleteWorktree: Bool 22 22 var automaticallyArchiveMergedWorktrees: Bool 23 23 var promptForWorktreeCreation: Bool 24 + var defaultWorktreeBaseDirectoryPath: String 24 25 var selection: SettingsSection? = .general 25 26 var repositorySettings: RepositorySettingsFeature.State? 26 27 ··· 42 43 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 43 44 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 44 45 promptForWorktreeCreation = settings.promptForWorktreeCreation 46 + defaultWorktreeBaseDirectoryPath = 47 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 45 48 } 46 49 47 50 var globalSettings: GlobalSettings { ··· 61 64 githubIntegrationEnabled: githubIntegrationEnabled, 62 65 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 63 66 automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 64 - promptForWorktreeCreation: promptForWorktreeCreation 67 + promptForWorktreeCreation: promptForWorktreeCreation, 68 + defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 69 + defaultWorktreeBaseDirectoryPath 70 + ) 65 71 ) 66 72 } 67 73 } ··· 93 99 94 100 case .settingsLoaded(let settings): 95 101 let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID(settings.defaultEditorID) 102 + let normalizedWorktreeBaseDirPath = 103 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) 96 104 let normalizedSettings: GlobalSettings 97 - if normalizedDefaultEditorID == settings.defaultEditorID { 105 + if normalizedDefaultEditorID == settings.defaultEditorID, 106 + normalizedWorktreeBaseDirPath == settings.defaultWorktreeBaseDirectoryPath 107 + { 98 108 normalizedSettings = settings 99 109 } else { 100 110 var updatedSettings = settings 101 111 updatedSettings.defaultEditorID = normalizedDefaultEditorID 112 + updatedSettings.defaultWorktreeBaseDirectoryPath = normalizedWorktreeBaseDirPath 102 113 normalizedSettings = updatedSettings 103 114 @Shared(.settingsFile) var settingsFile 104 115 $settingsFile.withLock { $0.global = normalizedSettings } ··· 119 130 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 120 131 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 121 132 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 133 + state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 134 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 135 + normalizedSettings.defaultWorktreeBaseDirectoryPath 122 136 return .send(.delegate(.settingsChanged(normalizedSettings))) 123 137 124 138 case .binding: 139 + let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 140 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 141 + defaultWorktreeBaseDirectoryPath 125 142 return persist(state) 126 143 127 144 case .setSystemNotificationsEnabled(let isEnabled): 128 145 state.systemNotificationsEnabled = isEnabled 146 + let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 147 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 148 + defaultWorktreeBaseDirectoryPath 129 149 return persist(state) 130 150 131 151 case .setSelection(let selection):
+18
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 11 11 store.branchOptions.isEmpty ? [store.defaultWorktreeBaseRef] : store.branchOptions 12 12 let settings = $store.settings 13 13 let onevcatSettings = $store.onevcatSettings 14 + let worktreeBaseDirectoryPath = Binding( 15 + get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, 16 + set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, 17 + ) 18 + let exampleWorktreePath = store.exampleWorktreePath 14 19 Form { 15 20 Section { 16 21 if store.isBranchDataLoaded { ··· 54 59 } 55 60 } 56 61 Section { 62 + VStack(alignment: .leading) { 63 + TextField( 64 + "Inherit global default", 65 + text: worktreeBaseDirectoryPath 66 + ) 67 + .textFieldStyle(.roundedBorder) 68 + Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") 69 + .foregroundStyle(.secondary) 70 + Text("Example new worktree path: \(exampleWorktreePath)") 71 + .foregroundStyle(.secondary) 72 + .monospaced() 73 + } 74 + .frame(maxWidth: .infinity, alignment: .leading) 57 75 Toggle( 58 76 "Copy ignored files to new worktrees", 59 77 isOn: settings.copyIgnoredOnWorktreeCreate
+20
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 5 5 @Bindable var store: StoreOf<SettingsFeature> 6 6 7 7 var body: some View { 8 + let exampleRepositoryRoot = FileManager.default.homeDirectoryForCurrentUser 9 + .appending(path: "code/my-repo", directoryHint: .isDirectory) 10 + let exampleWorktreePath = SupacodePaths.exampleWorktreePath( 11 + for: exampleRepositoryRoot, 12 + globalDefaultPath: store.defaultWorktreeBaseDirectoryPath, 13 + repositoryOverridePath: nil 14 + ) 8 15 VStack(alignment: .leading) { 9 16 Form { 10 17 Section("Worktree") { 18 + VStack(alignment: .leading) { 19 + TextField( 20 + "Default: current behavior", 21 + text: $store.defaultWorktreeBaseDirectoryPath 22 + ) 23 + .textFieldStyle(.roundedBorder) 24 + Text("Default directory for new worktrees across repositories. Leave empty to keep current behavior.") 25 + .foregroundStyle(.secondary) 26 + Text("Example new worktree path: \(exampleWorktreePath)") 27 + .foregroundStyle(.secondary) 28 + .monospaced() 29 + } 30 + .frame(maxWidth: .infinity, alignment: .leading) 11 31 VStack(alignment: .leading) { 12 32 Toggle( 13 33 "Also delete local branch when deleting a worktree",
+13
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 22 22 if handleTabCommand(command) { 23 23 return 24 24 } 25 + if handleBindingActionCommand(command) { 26 + return 27 + } 25 28 if handleSearchCommand(command) { 26 29 return 27 30 } ··· 75 78 state(for: worktree).navigateSearchOnFocusedSurface(.previous) 76 79 case .endSearch(let worktree): 77 80 state(for: worktree).performBindingActionOnFocusedSurface("end_search") 81 + default: 82 + return false 83 + } 84 + return true 85 + } 86 + 87 + private func handleBindingActionCommand(_ command: TerminalClient.Command) -> Bool { 88 + switch command { 89 + case .performBindingAction(let worktree, let action): 90 + state(for: worktree).performBindingActionOnFocusedSurface(action) 78 91 default: 79 92 return false 80 93 }
+22 -5
supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift
··· 4 4 struct GhosttySurfaceSearchOverlay: View { 5 5 let surfaceView: GhosttySurfaceView 6 6 @Bindable var state: GhosttySurfaceState 7 + @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 7 8 8 9 @State private var searchText: String 9 10 @State private var corner: GhosttySearchCorner = .topRight ··· 48 49 Button { 49 50 navigateSearch(.next) 50 51 } label: { 51 - SearchButtonLabel(title: "Next", shortcut: "⌘G", systemImage: "chevron.up") 52 + SearchButtonLabel( 53 + title: "Next", 54 + shortcut: ghosttyShortcuts.display(for: "search:next"), 55 + systemImage: "chevron.up" 56 + ) 52 57 } 53 58 .buttonStyle(GhosttySearchButtonStyle()) 54 59 55 60 Button { 56 61 navigateSearch(.previous) 57 62 } label: { 58 - SearchButtonLabel(title: "Previous", shortcut: "⇧⌘G", systemImage: "chevron.down") 63 + SearchButtonLabel( 64 + title: "Previous", 65 + shortcut: ghosttyShortcuts.display(for: "search:previous"), 66 + systemImage: "chevron.down" 67 + ) 59 68 } 60 69 .buttonStyle(GhosttySearchButtonStyle()) 61 70 62 71 Button { 63 72 closeSearch() 64 73 } label: { 65 - SearchButtonLabel(title: "Close", shortcut: "⇧⌘F", systemImage: "xmark") 74 + SearchButtonLabel( 75 + title: "Close", 76 + shortcut: ghosttyShortcuts.display(for: "end_search"), 77 + systemImage: "xmark" 78 + ) 66 79 } 67 80 .buttonStyle(GhosttySearchButtonStyle()) 68 81 } ··· 245 258 246 259 private struct SearchButtonLabel: View { 247 260 let title: String 248 - let shortcut: String 261 + let shortcut: String? 249 262 let systemImage: String 250 263 251 264 var body: some View { 252 265 Label { 253 - Text("\(title) \(Text("(\(shortcut))").foregroundColor(.secondary.opacity(0.7)))") 266 + if let shortcut { 267 + Text("\(title) \(Text("(\(shortcut))").foregroundColor(.secondary.opacity(0.7)))") 268 + } else { 269 + Text(title) 270 + } 254 271 } icon: { 255 272 Image(systemName: systemImage) 256 273 .accessibilityHidden(true)
+29
supacode/Infrastructure/Ghostty/GhosttyCommand.swift
··· 1 + import GhosttyKit 2 + 3 + struct GhosttyCommand: Equatable, Sendable { 4 + let title: String 5 + let description: String 6 + let action: String 7 + let actionKey: String 8 + 9 + init( 10 + title: String, 11 + description: String, 12 + action: String, 13 + actionKey: String 14 + ) { 15 + self.title = title 16 + self.description = description 17 + self.action = action 18 + self.actionKey = actionKey 19 + } 20 + 21 + init(cValue: ghostty_command_s) { 22 + self.init( 23 + title: String(cString: cValue.title), 24 + description: String(cString: cValue.description), 25 + action: String(cString: cValue.action), 26 + actionKey: String(cString: cValue.action_key) 27 + ) 28 + } 29 + }
+12
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 452 452 return Self.keyboardShortcut(for: trigger) 453 453 } 454 454 455 + func commandPaletteEntries() -> [GhosttyCommand] { 456 + guard let config else { return [] } 457 + var value = ghostty_config_command_list_s() 458 + let key = "command-palette-entry" 459 + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))) else { 460 + return [] 461 + } 462 + guard value.len > 0, let commands = value.commands else { return [] } 463 + let buffer = UnsafeBufferPointer(start: commands, count: Int(value.len)) 464 + return buffer.map(GhosttyCommand.init(cValue:)) 465 + } 466 + 455 467 func focusFollowsMouse() -> Bool { 456 468 guard let config else { return false } 457 469 var value = false
+5
supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift
··· 19 19 generation += 1 20 20 } 21 21 22 + var commandPaletteEntries: [GhosttyCommand] { 23 + _ = generation 24 + return runtime.commandPaletteEntries() 25 + } 26 + 22 27 func keyboardShortcut(for action: String) -> KeyboardShortcut? { 23 28 _ = generation 24 29 return runtime.keyboardShortcut(for: action)
+62
supacode/Support/SupacodePaths.swift
··· 19 19 return reposDirectory.appending(path: name, directoryHint: .isDirectory) 20 20 } 21 21 22 + static func normalizedWorktreeBaseDirectoryPath( 23 + _ rawPath: String?, 24 + repositoryRootURL: URL? = nil 25 + ) -> String? { 26 + guard let rawPath else { 27 + return nil 28 + } 29 + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 30 + guard !trimmed.isEmpty else { 31 + return nil 32 + } 33 + let expanded = NSString(string: trimmed).expandingTildeInPath 34 + let directoryURL: URL 35 + if expanded.hasPrefix("/") { 36 + directoryURL = URL(filePath: expanded, directoryHint: .isDirectory) 37 + } else if let repositoryRootURL { 38 + directoryURL = repositoryRootURL.standardizedFileURL 39 + .appending(path: expanded, directoryHint: .isDirectory) 40 + } else { 41 + directoryURL = FileManager.default.homeDirectoryForCurrentUser 42 + .appending(path: expanded, directoryHint: .isDirectory) 43 + } 44 + return directoryURL.standardizedFileURL.path(percentEncoded: false) 45 + } 46 + 47 + static func worktreeBaseDirectory( 48 + for repositoryRootURL: URL, 49 + globalDefaultPath: String?, 50 + repositoryOverridePath: String? 51 + ) -> URL { 52 + let rootURL = repositoryRootURL.standardizedFileURL 53 + if let repositoryOverridePath = normalizedWorktreeBaseDirectoryPath( 54 + repositoryOverridePath, 55 + repositoryRootURL: rootURL 56 + ) { 57 + return URL(filePath: repositoryOverridePath, directoryHint: .isDirectory).standardizedFileURL 58 + } 59 + if let globalDefaultPath = normalizedWorktreeBaseDirectoryPath(globalDefaultPath) { 60 + return URL(filePath: globalDefaultPath, directoryHint: .isDirectory) 61 + .standardizedFileURL 62 + .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 63 + .standardizedFileURL 64 + } 65 + return repositoryDirectory(for: rootURL) 66 + } 67 + 68 + static func exampleWorktreePath( 69 + for repositoryRootURL: URL, 70 + globalDefaultPath: String?, 71 + repositoryOverridePath: String?, 72 + branchName: String = "swift-otter" 73 + ) -> String { 74 + worktreeBaseDirectory( 75 + for: repositoryRootURL, 76 + globalDefaultPath: globalDefaultPath, 77 + repositoryOverridePath: repositoryOverridePath 78 + ) 79 + .appending(path: branchName, directoryHint: .isDirectory) 80 + .standardizedFileURL 81 + .path(percentEncoded: false) 82 + } 83 + 22 84 static var settingsURL: URL { 23 85 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 24 86 }
+34
supacodeTests/AppFeatureCommandPaletteTests.swift
··· 79 79 await store.receive(\.updates.checkForUpdates) 80 80 } 81 81 82 + @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToTerminalClient() async { 83 + let worktree = makeWorktree( 84 + id: "/tmp/repo-ghostty/wt-1", 85 + name: "wt-1", 86 + repoRoot: "/tmp/repo-ghostty" 87 + ) 88 + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) 89 + var repositoriesState = RepositoriesFeature.State() 90 + repositoriesState.repositories = [repository] 91 + repositoriesState.selection = .worktree(worktree.id) 92 + let sent = LockIsolated<[TerminalClient.Command]>([]) 93 + let store = TestStore( 94 + initialState: AppFeature.State( 95 + repositories: repositoriesState, 96 + settings: SettingsFeature.State() 97 + ) 98 + ) { 99 + AppFeature() 100 + } withDependencies: { 101 + $0.terminalClient.send = { command in 102 + sent.withValue { $0.append(command) } 103 + } 104 + } 105 + 106 + await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) 107 + await store.finish() 108 + 109 + #expect( 110 + sent.value == [ 111 + .performBindingAction(worktree, action: "goto_split:right") 112 + ] 113 + ) 114 + } 115 + 82 116 @Test(.dependencies) func closePullRequestDispatchesAction() async { 83 117 let store = TestStore(initialState: AppFeature.State()) { 84 118 AppFeature()
+125
supacodeTests/CommandPaletteFeatureTests.swift
··· 58 58 #expect(ids.contains { $0.contains("wt-pending") } == false) 59 59 } 60 60 61 + @Test func commandPaletteItems_includeGhosttyCommandsWhenWorktreeSelected() { 62 + let rootPath = "/tmp/repo" 63 + let worktree = makeWorktree(id: rootPath, name: "repo", repoRoot: rootPath) 64 + let repository = makeRepository(rootPath: rootPath, name: "Repo", worktrees: [worktree]) 65 + var state = RepositoriesFeature.State(repositories: [repository]) 66 + state.selection = .worktree(worktree.id) 67 + 68 + let items = CommandPaletteFeature.commandPaletteItems( 69 + from: state, 70 + ghosttyCommands: [ 71 + GhosttyCommand( 72 + title: "Focus Split Right", 73 + description: "Focus the split to the right.", 74 + action: "goto_split:right", 75 + actionKey: "goto_split" 76 + ), 77 + ] 78 + ) 79 + 80 + let ghosttyItem = items.first { 81 + if case .ghosttyCommand(let action) = $0.kind { 82 + return action == "goto_split:right" 83 + } 84 + return false 85 + } 86 + 87 + #expect(ghosttyItem?.title == "Focus Split Right") 88 + #expect(ghosttyItem?.subtitle == "Focus the split to the right.") 89 + } 90 + 91 + @Test func commandPaletteItems_omitGhosttyCommandsWithoutSelectedWorktree() { 92 + let items = CommandPaletteFeature.commandPaletteItems( 93 + from: RepositoriesFeature.State(), 94 + ghosttyCommands: [ 95 + GhosttyCommand( 96 + title: "Focus Split Right", 97 + description: "", 98 + action: "goto_split:right", 99 + actionKey: "goto_split" 100 + ), 101 + ] 102 + ) 103 + 104 + #expect( 105 + items.contains { 106 + if case .ghosttyCommand = $0.kind { 107 + return true 108 + } 109 + return false 110 + } == false 111 + ) 112 + } 113 + 114 + @Test func emptyQueryHidesGhosttyCommands() { 115 + let ghosttyItem = CommandPaletteItem( 116 + id: "ghostty.goto_split:right|Focus Split Right", 117 + title: "Focus Split Right", 118 + subtitle: nil, 119 + kind: .ghosttyCommand("goto_split:right") 120 + ) 121 + let prAction = CommandPaletteItem( 122 + id: "pr.open", 123 + title: "Open PR on GitHub", 124 + subtitle: "PR title", 125 + kind: .openPullRequest("wt-1"), 126 + priorityTier: 2 127 + ) 128 + 129 + let result = CommandPaletteFeature.filterItems( 130 + items: [ghosttyItem, prAction], 131 + query: "" 132 + ) 133 + 134 + #expect(!result.contains { $0.id == ghosttyItem.id }) 135 + #expect(result.contains { $0.id == prAction.id }) 136 + } 137 + 61 138 @Test func commandPaletteItems_omitsSubActionsForMainWorktree() { 62 139 let rootPath = "/tmp/repo" 63 140 let main = makeWorktree( ··· 545 622 ) 546 623 } 547 624 625 + @Test func supacodeItemsBeatGhosttyItemsWhenScoresTie() { 626 + let supacodeItem = CommandPaletteItem( 627 + id: "global.open-settings", 628 + title: "Open Settings", 629 + subtitle: nil, 630 + kind: .openSettings 631 + ) 632 + let ghosttyItem = CommandPaletteItem( 633 + id: "ghostty.open-settings|Open Settings", 634 + title: "Open Settings", 635 + subtitle: nil, 636 + kind: .ghosttyCommand("open_settings"), 637 + priorityTier: CommandPaletteItem.defaultPriorityTier + 100 638 + ) 639 + 640 + expectNoDifference( 641 + CommandPaletteFeature.filterItems( 642 + items: [ghosttyItem, supacodeItem], 643 + query: "open settings" 644 + ), 645 + [supacodeItem, ghosttyItem] 646 + ) 647 + } 648 + 548 649 // MARK: - Unified Ranking Tests 549 650 550 651 @Test func worktreeOutranksGlobalWhenBetterMatch() { ··· 874 975 $0.recencyByItemID[item.id] = now.timeIntervalSince1970 875 976 } 876 977 await store.receive(.delegate(.openRepository)) 978 + } 979 + 980 + @Test func activateGhosttyCommandDispatchesDelegate() async { 981 + let now = Date(timeIntervalSince1970: 7_654_321) 982 + let item = CommandPaletteItem( 983 + id: "ghostty.goto_split:right|Focus Split Right", 984 + title: "Focus Split Right", 985 + subtitle: nil, 986 + kind: .ghosttyCommand("goto_split:right") 987 + ) 988 + var state = CommandPaletteFeature.State() 989 + state.isPresented = true 990 + let store = TestStore(initialState: state) { 991 + CommandPaletteFeature() 992 + } 993 + store.dependencies.date = .constant(now) 994 + 995 + await store.send(.activateItem(item)) { 996 + $0.isPresented = false 997 + $0.query = "" 998 + $0.selectedIndex = nil 999 + $0.recencyByItemID[item.id] = now.timeIntervalSince1970 1000 + } 1001 + await store.receive(.delegate(.ghosttyCommand("goto_split:right"))) 877 1002 } 878 1003 } 879 1004
+22 -16
supacodeTests/GitClientCreateWorktreeStreamTests.swift
··· 66 66 for try await _ in client.createWorktreeStream( 67 67 named: "swift-otter", 68 68 in: repoRoot, 69 - copyIgnored: true, 70 - copyUntracked: false, 69 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 70 + copyFiles: (ignored: true, untracked: false), 71 71 baseRef: "origin/main" 72 72 ) {} 73 73 74 74 let snapshot = recorder.snapshot() 75 75 #expect(snapshot.currentDirectoryURL == repoRoot) 76 76 #expect(snapshot.arguments.contains("sw")) 77 + if let baseDirFlagIndex = snapshot.arguments.firstIndex(of: "--base-dir") { 78 + #expect(snapshot.arguments.count > baseDirFlagIndex + 1) 79 + #expect(snapshot.arguments[baseDirFlagIndex + 1] == "/tmp/repo/.worktrees") 80 + } else { 81 + Issue.record("Expected --base-dir in createWorktreeStream arguments") 82 + } 77 83 #expect(snapshot.arguments.contains("--copy-ignored")) 78 84 #expect(snapshot.arguments.contains("--verbose")) 79 85 #expect(snapshot.arguments.contains("--from")) ··· 108 114 for try await event in client.createWorktreeStream( 109 115 named: "swift-otter", 110 116 in: repoRoot, 111 - copyIgnored: true, 112 - copyUntracked: true, 117 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 118 + copyFiles: (ignored: true, untracked: true), 113 119 baseRef: "" 114 120 ) { 115 121 switch event { ··· 153 159 for try await event in client.createWorktreeStream( 154 160 named: "new-wt", 155 161 in: repoRoot, 156 - copyIgnored: false, 157 - copyUntracked: false, 162 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 163 + copyFiles: (ignored: false, untracked: false), 158 164 baseRef: "" 159 165 ) { 160 166 if case .finished(let worktree) = event { ··· 184 190 for try await event in client.createWorktreeStream( 185 191 named: "new-wt", 186 192 in: repoRoot, 187 - copyIgnored: false, 188 - copyUntracked: false, 193 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 194 + copyFiles: (ignored: false, untracked: false), 189 195 baseRef: "" 190 196 ) { 191 197 switch event { ··· 226 232 for try await _ in client.createWorktreeStream( 227 233 named: "new-wt", 228 234 in: repoRoot, 229 - copyIgnored: false, 230 - copyUntracked: false, 235 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 236 + copyFiles: (ignored: false, untracked: false), 231 237 baseRef: "" 232 238 ) {} 233 239 Issue.record("Expected createWorktreeStream to throw when stdout path is missing") ··· 266 272 _ = try await client.createWorktree( 267 273 named: "new-wt", 268 274 in: repoRoot, 269 - copyIgnored: false, 270 - copyUntracked: false, 275 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 276 + copyFiles: (ignored: false, untracked: false), 271 277 baseRef: "" 272 278 ) 273 279 Issue.record("Expected createWorktree to throw") ··· 302 308 let worktree = try await client.createWorktree( 303 309 named: "new-wt", 304 310 in: repoRoot, 305 - copyIgnored: false, 306 - copyUntracked: false, 311 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 312 + copyFiles: (ignored: false, untracked: false), 307 313 baseRef: "" 308 314 ) 309 315 ··· 329 335 let worktree = try await client.createWorktree( 330 336 named: "new-wt", 331 337 in: repoRoot, 332 - copyIgnored: false, 333 - copyUntracked: false, 338 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 339 + copyFiles: (ignored: false, untracked: false), 334 340 baseRef: "" 335 341 ) 336 342
+180 -4
supacodeTests/RepositoriesFeatureTests.swift
··· 413 413 pendingID: "pending:1", 414 414 previousSelection: nil, 415 415 repositoryID: repository.id, 416 - name: "../../Desktop" 416 + name: "../../Desktop", 417 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 417 418 ) 418 419 ) { 419 420 $0.alert = expectedAlert ··· 442 443 $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 443 444 $0.gitClient.ignoredFileCount = { _ in 2 } 444 445 $0.gitClient.untrackedFileCount = { _ in 1 } 445 - $0.gitClient.createWorktreeStream = { _, _, _, _, _ in 446 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 446 447 AsyncThrowingStream { continuation in 447 448 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) 448 449 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) ··· 467 468 #expect(store.state.alert == nil) 468 469 } 469 470 471 + @Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { 472 + let repoRoot = "/tmp/repo" 473 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 474 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 475 + let createdWorktree = makeWorktree( 476 + id: "/tmp/repo/swift-otter", 477 + name: "swift-otter", 478 + repoRoot: repoRoot 479 + ) 480 + let observedBaseDirectory = LockIsolated<URL?>(nil) 481 + @Shared(.settingsFile) var settingsFile 482 + $settingsFile.withLock { 483 + $0.global.promptForWorktreeCreation = false 484 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 485 + } 486 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 487 + $repositorySettings.withLock { 488 + $0.worktreeBaseDirectoryPath = "/tmp/repo-override" 489 + } 490 + let store = TestStore(initialState: makeState(repositories: [repository])) { 491 + RepositoriesFeature() 492 + } withDependencies: { 493 + $0.uuid = .incrementing 494 + $0.gitClient.localBranchNames = { _ in [] } 495 + $0.gitClient.isBareRepository = { _ in false } 496 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 497 + $0.gitClient.ignoredFileCount = { _ in 0 } 498 + $0.gitClient.untrackedFileCount = { _ in 0 } 499 + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in 500 + observedBaseDirectory.withValue { $0 = baseDirectory } 501 + return AsyncThrowingStream { continuation in 502 + continuation.yield(.finished(createdWorktree)) 503 + continuation.finish() 504 + } 505 + } 506 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 507 + } 508 + store.exhaustivity = .off 509 + 510 + await store.send(.createRandomWorktreeInRepository(repository.id)) 511 + await store.receive(\.createRandomWorktreeSucceeded) 512 + await store.finish() 513 + 514 + let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 515 + for: repository.rootURL, 516 + globalDefaultPath: "/tmp/global-worktrees", 517 + repositoryOverridePath: "/tmp/repo-override" 518 + ) 519 + #expect(observedBaseDirectory.value == expectedBaseDirectory) 520 + } 521 + 522 + @Test(.dependencies) func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { 523 + let repoRoot = "/tmp/repo" 524 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 525 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 526 + let createdWorktree = makeWorktree( 527 + id: "/tmp/repo/swift-otter", 528 + name: "swift-otter", 529 + repoRoot: repoRoot 530 + ) 531 + let observedBaseDirectory = LockIsolated<URL?>(nil) 532 + @Shared(.settingsFile) var settingsFile 533 + $settingsFile.withLock { 534 + $0.global.promptForWorktreeCreation = false 535 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 536 + } 537 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 538 + $repositorySettings.withLock { 539 + $0.worktreeBaseDirectoryPath = nil 540 + } 541 + let store = TestStore(initialState: makeState(repositories: [repository])) { 542 + RepositoriesFeature() 543 + } withDependencies: { 544 + $0.uuid = .incrementing 545 + $0.gitClient.localBranchNames = { _ in [] } 546 + $0.gitClient.isBareRepository = { _ in false } 547 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 548 + $0.gitClient.ignoredFileCount = { _ in 0 } 549 + $0.gitClient.untrackedFileCount = { _ in 0 } 550 + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in 551 + observedBaseDirectory.withValue { $0 = baseDirectory } 552 + return AsyncThrowingStream { continuation in 553 + continuation.yield(.finished(createdWorktree)) 554 + continuation.finish() 555 + } 556 + } 557 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 558 + } 559 + store.exhaustivity = .off 560 + 561 + await store.send(.createRandomWorktreeInRepository(repository.id)) 562 + await store.receive(\.createRandomWorktreeSucceeded) 563 + await store.finish() 564 + 565 + let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 566 + for: repository.rootURL, 567 + globalDefaultPath: "/tmp/global-worktrees", 568 + repositoryOverridePath: nil 569 + ) 570 + #expect(observedBaseDirectory.value == expectedBaseDirectory) 571 + } 572 + 470 573 @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { 471 574 let repoRoot = "/tmp/repo" 472 575 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 482 585 $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 483 586 $0.gitClient.ignoredFileCount = { _ in 2 } 484 587 $0.gitClient.untrackedFileCount = { _ in 1 } 485 - $0.gitClient.createWorktreeStream = { _, _, _, _, _ in 588 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 486 589 AsyncThrowingStream { continuation in 487 590 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) 488 591 continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) ··· 511 614 #expect(store.state.repositories[id: repository.id]?.worktrees[id: mainWorktree.id] != nil) 512 615 } 513 616 617 + @Test(.dependencies) func createRandomWorktreeFailureUsesProvidedBaseDirectoryForCleanup() async { 618 + let repoRoot = "/tmp/repo" 619 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 620 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 621 + let createTimeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 622 + for: repository.rootURL, 623 + globalDefaultPath: "/tmp/worktrees-original", 624 + repositoryOverridePath: nil 625 + ) 626 + let changedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 627 + for: repository.rootURL, 628 + globalDefaultPath: "/tmp/worktrees-changed", 629 + repositoryOverridePath: nil 630 + ) 631 + let removedWorktreePath = LockIsolated<String?>(nil) 632 + @Shared(.settingsFile) var settingsFile 633 + $settingsFile.withLock { 634 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/worktrees-changed" 635 + } 636 + let store = TestStore(initialState: makeState(repositories: [repository])) { 637 + RepositoriesFeature() 638 + } withDependencies: { 639 + $0.gitClient.removeWorktree = { worktree, _ in 640 + let workingDirectory = await MainActor.run { worktree.workingDirectory } 641 + removedWorktreePath.withValue { $0 = workingDirectory.path(percentEncoded: false) } 642 + return workingDirectory 643 + } 644 + $0.gitClient.pruneWorktrees = { _ in } 645 + } 646 + store.exhaustivity = .off 647 + 648 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 649 + TextState("Unable to create worktree") 650 + } actions: { 651 + ButtonState(role: .cancel) { 652 + TextState("OK") 653 + } 654 + } message: { 655 + TextState("boom") 656 + } 657 + 658 + await store.send( 659 + .createRandomWorktreeFailed( 660 + title: "Unable to create worktree", 661 + message: "boom", 662 + pendingID: "pending:test", 663 + previousSelection: nil, 664 + repositoryID: repository.id, 665 + name: "new-branch", 666 + baseDirectory: createTimeBaseDirectory 667 + ) 668 + ) { 669 + $0.alert = expectedAlert 670 + } 671 + await store.finish() 672 + 673 + #expect(changedBaseDirectory != createTimeBaseDirectory) 674 + #expect(removedWorktreePath.value != nil) 675 + #expect( 676 + removedWorktreePath.value 677 + == createTimeBaseDirectory 678 + .appending(path: "new-branch", directoryHint: .isDirectory) 679 + .path(percentEncoded: false) 680 + ) 681 + #expect( 682 + removedWorktreePath.value 683 + != changedBaseDirectory 684 + .appending(path: "new-branch", directoryHint: .isDirectory) 685 + .path(percentEncoded: false) 686 + ) 687 + } 688 + 514 689 @Test func pendingProgressUpdateUpdatesPendingWorktreeState() async { 515 690 let repoRoot = "/tmp/repo" 516 691 let repository = makeRepository( ··· 585 760 pendingID: pendingID, 586 761 previousSelection: nil, 587 762 repositoryID: repository.id, 588 - name: nil 763 + name: nil, 764 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 589 765 ) 590 766 ) { 591 767 $0.pendingWorktrees = []
+51
supacodeTests/RepositoryPathsTests.swift
··· 59 59 #expect(settingsURL.deletingLastPathComponent().lastPathComponent == ".bare") 60 60 #expect(settingsURL.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "repo") 61 61 } 62 + 63 + @Test func worktreeBaseDirectoryDefaultsToLegacyRepositoryDirectory() { 64 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 65 + let directory = SupacodePaths.worktreeBaseDirectory( 66 + for: root, 67 + globalDefaultPath: nil, 68 + repositoryOverridePath: nil 69 + ) 70 + 71 + #expect(directory == SupacodePaths.repositoryDirectory(for: root)) 72 + } 73 + 74 + @Test func worktreeBaseDirectoryUsesGlobalParentDirectory() { 75 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 76 + let directory = SupacodePaths.worktreeBaseDirectory( 77 + for: root, 78 + globalDefaultPath: "/tmp/worktrees", 79 + repositoryOverridePath: nil 80 + ) 81 + let expectedDirectory = URL(filePath: "/tmp/worktrees/repo-alpha", directoryHint: .isDirectory) 82 + .standardizedFileURL 83 + 84 + #expect(directory == expectedDirectory) 85 + } 86 + 87 + @Test func worktreeBaseDirectoryRepositoryOverrideTakesPrecedence() { 88 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 89 + let directory = SupacodePaths.worktreeBaseDirectory( 90 + for: root, 91 + globalDefaultPath: "/tmp/worktrees", 92 + repositoryOverridePath: "/tmp/repo-alpha-worktrees" 93 + ) 94 + let expectedDirectory = URL(filePath: "/tmp/repo-alpha-worktrees", directoryHint: .isDirectory) 95 + .standardizedFileURL 96 + 97 + #expect(directory == expectedDirectory) 98 + } 99 + 100 + @Test func exampleWorktreePathUsesResolvedBaseDirectory() { 101 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 102 + let path = SupacodePaths.exampleWorktreePath( 103 + for: root, 104 + globalDefaultPath: "/tmp/worktrees", 105 + repositoryOverridePath: nil 106 + ) 107 + let expectedPath = URL(filePath: "/tmp/worktrees/repo-alpha/swift-otter", directoryHint: .isDirectory) 108 + .standardizedFileURL 109 + .path(percentEncoded: false) 110 + 111 + #expect(path == expectedPath) 112 + } 62 113 }
+1
supacodeTests/RepositorySettingsKeyTests.swift
··· 12 12 let json = String(bytes: data, encoding: .utf8) ?? "" 13 13 14 14 #expect(!json.contains("worktreeBaseRef")) 15 + #expect(!json.contains("worktreeBaseDirectoryPath")) 15 16 } 16 17 17 18 @Test(.dependencies) func loadCreatesDefaultAndPersists() throws {
+49
supacodeTests/SettingsFeatureTests.swift
··· 200 200 } 201 201 await store.receive(\.delegate.settingsChanged) 202 202 } 203 + 204 + @Test(.dependencies) func settingsLoadedNormalizesDefaultWorktreeBaseDirectoryPath() async { 205 + var loaded = GlobalSettings.default 206 + loaded.defaultWorktreeBaseDirectoryPath = " ~/worktrees " 207 + let expectedPath = FileManager.default.homeDirectoryForCurrentUser 208 + .appending(path: "worktrees", directoryHint: .isDirectory) 209 + .standardizedFileURL 210 + .path(percentEncoded: false) 211 + let storage = SettingsTestStorage() 212 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 213 + let store = TestStore(initialState: SettingsFeature.State()) { 214 + SettingsFeature() 215 + } withDependencies: { 216 + $0.settingsFileStorage = storage.storage 217 + $0.settingsFileURL = settingsFileURL 218 + } 219 + 220 + await store.send(.settingsLoaded(loaded)) { 221 + $0.defaultWorktreeBaseDirectoryPath = expectedPath 222 + } 223 + await store.receive(\.delegate.settingsChanged) 224 + #expect(store.state.defaultWorktreeBaseDirectoryPath == expectedPath) 225 + } 226 + 227 + @Test(.dependencies) func changingDefaultWorktreeBaseDirectoryUpdatesRepositorySettingsState() async { 228 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 229 + let expectedPath = FileManager.default.homeDirectoryForCurrentUser 230 + .appending(path: "worktrees", directoryHint: .isDirectory) 231 + .standardizedFileURL 232 + .path(percentEncoded: false) 233 + @Shared(.settingsFile) var settingsFile 234 + $settingsFile.withLock { $0.global = .default } 235 + var state = SettingsFeature.State() 236 + state.repositorySettings = RepositorySettingsFeature.State( 237 + rootURL: rootURL, 238 + settings: .default 239 + ) 240 + let store = TestStore(initialState: state) { 241 + SettingsFeature() 242 + } 243 + 244 + await store.send(.binding(.set(\.defaultWorktreeBaseDirectoryPath, " ~/worktrees "))) { 245 + $0.defaultWorktreeBaseDirectoryPath = " ~/worktrees " 246 + $0.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = expectedPath 247 + } 248 + await store.receive(\.delegate.settingsChanged) 249 + #expect(store.state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath == expectedPath) 250 + #expect(settingsFile.global.defaultWorktreeBaseDirectoryPath == expectedPath) 251 + } 203 252 }
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 110 110 #expect(settings.global.deleteBranchOnDeleteWorktree == true) 111 111 #expect(settings.global.automaticallyArchiveMergedWorktrees == false) 112 112 #expect(settings.global.promptForWorktreeCreation == true) 113 + #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) 113 114 #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) 114 115 #expect(settings.repositoryRoots.isEmpty) 115 116 #expect(settings.pinnedWorktreeIDs.isEmpty)