native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #86 from supabitapp/feat/worktree-create-prompt-setting

Add configurable worktree creation prompt flow

authored by

khoi and committed by
GitHub
c4b47357 ed63675b

+848 -58
+2
AGENTS.md
··· 79 79 ## Code Guidelines 80 80 81 81 - Target macOS 26.0+, Swift 6.2+ 82 + - Before doing a big feature or when planning, consult with pfw (pointfree) skills on TCA, Observable best practices first. 82 83 - Use `@ObservableState` for TCA feature state; use `@Observable` for non-TCA shared stores; never `ObservableObject` 83 84 - Always mark `@Observable` classes with `@MainActor` 84 85 - Modern SwiftUI only: `foregroundStyle()`, `NavigationStack`, `Button` over `onTapGesture()` 85 86 - When a new logic changes in the Reducer, always add tests 87 + - In unit tests, never use `Task.sleep`; use `TestClock` (or an injected clock) and drive time with `advance`. 86 88 - Prefer Swift-native APIs over Foundation where they exist (e.g., `replacing()` not `replacingOccurrences()`) 87 89 - Avoid `GeometryReader` when `containerRelativeFrame()` or `visualEffect()` would work 88 90 - Do not use NSNotification to communicate between reducers.
+4
supacode/App/ContentView.swift
··· 70 70 } 71 71 .alert(store: repositoriesStore.scope(state: \.$alert, action: \.alert)) 72 72 .alert(store: store.scope(state: \.$alert, action: \.alert)) 73 + .sheet(store: repositoriesStore.scope(state: \.$worktreeCreationPrompt, action: \.worktreeCreationPrompt)) { 74 + promptStore in 75 + WorktreeCreationPromptView(store: promptStore) 76 + } 73 77 .sheet(isPresented: isRunScriptPromptPresented) { 74 78 RunScriptPromptView( 75 79 script: runScriptDraft,
+14
supacode/Clients/Git/GitClient.swift
··· 9 9 case worktreePrune = "worktree_prune" 10 10 case repoIsBare = "repo_is_bare" 11 11 case branchNames = "branch_names" 12 + case branchNameValidation = "branch_name_validation" 12 13 case branchRefs = "branch_refs" 13 14 case defaultRemoteBranchRef = "default_remote_branch_ref" 14 15 case localHeadRef = "local_head_ref" ··· 138 139 .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } 139 140 .filter { !$0.isEmpty } 140 141 return Set(names) 142 + } 143 + 144 + nonisolated func isValidBranchName(_ branchName: String, for repoRoot: URL) async -> Bool { 145 + let path = repoRoot.path(percentEncoded: false) 146 + do { 147 + _ = try await runGit( 148 + operation: .branchNameValidation, 149 + arguments: ["-C", path, "check-ref-format", "--branch", branchName] 150 + ) 151 + return true 152 + } catch { 153 + return false 154 + } 141 155 } 142 156 143 157 nonisolated func isBareRepository(for repoRoot: URL) async throws -> Bool {
+4
supacode/Clients/Repositories/GitClientDependency.swift
··· 6 6 var worktrees: @Sendable (URL) async throws -> [Worktree] 7 7 var pruneWorktrees: @Sendable (URL) async throws -> Void 8 8 var localBranchNames: @Sendable (URL) async throws -> Set<String> 9 + var isValidBranchName: @Sendable (String, URL) async -> Bool 9 10 var branchRefs: @Sendable (URL) async throws -> [String] 10 11 var defaultRemoteBranchRef: @Sendable (URL) async throws -> String? 11 12 var automaticWorktreeBaseRef: @Sendable (URL) async -> String? ··· 42 43 worktrees: { try await GitClient().worktrees(for: $0) }, 43 44 pruneWorktrees: { try await GitClient().pruneWorktrees(for: $0) }, 44 45 localBranchNames: { try await GitClient().localBranchNames(for: $0) }, 46 + isValidBranchName: { branchName, repoRoot in 47 + await GitClient().isValidBranchName(branchName, for: repoRoot) 48 + }, 45 49 branchRefs: { try await GitClient().branchRefs(for: $0) }, 46 50 defaultRemoteBranchRef: { try await GitClient().defaultRemoteBranchRef(for: $0) }, 47 51 automaticWorktreeBaseRef: { await GitClient().automaticWorktreeBaseRef(for: $0) },
+344 -28
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 10 10 static let toastAutoDismiss = "repositories.toastAutoDismiss" 11 11 static let githubIntegrationAvailability = "repositories.githubIntegrationAvailability" 12 12 static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" 13 + static let worktreePromptLoad = "repositories.worktreePromptLoad" 14 + static let worktreePromptValidation = "repositories.worktreePromptValidation" 13 15 static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 14 16 "repositories.delayedPRRefresh.\(worktreeID)" 15 17 } ··· 83 85 var inFlightPullRequestRefreshRepositoryIDs: Set<Repository.ID> = [] 84 86 var queuedPullRequestRefreshByRepositoryID: [Repository.ID: PendingPullRequestRefresh] = [:] 85 87 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 88 + @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 86 89 @Presents var alert: AlertState<Alert>? 87 90 } 88 91 ··· 99 102 var worktreeIDs: [Worktree.ID] 100 103 } 101 104 105 + enum WorktreeCreationNameSource: Equatable { 106 + case random 107 + case explicit(String) 108 + } 109 + 110 + enum WorktreeCreationBaseRefSource: Equatable { 111 + case repositorySetting 112 + case explicit(String?) 113 + } 114 + 102 115 enum Action { 103 116 case task 104 117 case setOpenPanelPresented(Bool) ··· 126 139 case requestRenameBranch(Worktree.ID, String) 127 140 case createRandomWorktree 128 141 case createRandomWorktreeInRepository(Repository.ID) 142 + case createWorktreeInRepository( 143 + repositoryID: Repository.ID, 144 + nameSource: WorktreeCreationNameSource, 145 + baseRefSource: WorktreeCreationBaseRefSource 146 + ) 147 + case promptedWorktreeCreationDataLoaded( 148 + repositoryID: Repository.ID, 149 + baseRefOptions: [String], 150 + automaticBaseRefLabel: String, 151 + selectedBaseRef: String? 152 + ) 153 + case startPromptedWorktreeCreation( 154 + repositoryID: Repository.ID, 155 + branchName: String, 156 + baseRef: String? 157 + ) 158 + case promptedWorktreeCreationChecked( 159 + repositoryID: Repository.ID, 160 + branchName: String, 161 + baseRef: String?, 162 + duplicateMessage: String? 163 + ) 129 164 case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) 130 165 case createRandomWorktreeSucceeded( 131 166 Worktree, ··· 183 218 case dismissToast 184 219 case delayedPullRequestRefresh(Worktree.ID) 185 220 case openRepositorySettings(Repository.ID) 221 + case worktreeCreationPrompt(PresentationAction<WorktreeCreationPromptFeature.Action>) 186 222 case alert(PresentationAction<Alert>) 187 223 case delegate(Delegate) 188 224 } ··· 587 623 ) 588 624 return .none 589 625 } 626 + @Shared(.settingsFile) var settingsFile 627 + if !settingsFile.global.promptForWorktreeCreation { 628 + return .merge( 629 + .cancel(id: CancelID.worktreePromptLoad), 630 + .send( 631 + .createWorktreeInRepository( 632 + repositoryID: repository.id, 633 + nameSource: .random, 634 + baseRefSource: .repositorySetting 635 + ) 636 + ) 637 + ) 638 + } 639 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 640 + let selectedBaseRef = repositorySettings.worktreeBaseRef 641 + let gitClient = gitClient 642 + let rootURL = repository.rootURL 643 + return .run { send in 644 + let automaticBaseRef = await gitClient.automaticWorktreeBaseRef(rootURL) ?? "HEAD" 645 + guard !Task.isCancelled else { 646 + return 647 + } 648 + let baseRefOptions: [String] 649 + do { 650 + let refs = try await gitClient.branchRefs(rootURL) 651 + guard !Task.isCancelled else { 652 + return 653 + } 654 + var options = refs 655 + if !automaticBaseRef.isEmpty, !options.contains(automaticBaseRef) { 656 + options.append(automaticBaseRef) 657 + } 658 + if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 659 + options.append(selectedBaseRef) 660 + } 661 + baseRefOptions = options.sorted { $0.localizedStandardCompare($1) == .orderedAscending } 662 + } catch { 663 + guard !Task.isCancelled else { 664 + return 665 + } 666 + var options: [String] = [] 667 + if !automaticBaseRef.isEmpty { 668 + options.append(automaticBaseRef) 669 + } 670 + if let selectedBaseRef, !selectedBaseRef.isEmpty, !options.contains(selectedBaseRef) { 671 + options.append(selectedBaseRef) 672 + } 673 + baseRefOptions = options 674 + } 675 + guard !Task.isCancelled else { 676 + return 677 + } 678 + let automaticBaseRefLabel = 679 + automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 680 + await send( 681 + .promptedWorktreeCreationDataLoaded( 682 + repositoryID: repositoryID, 683 + baseRefOptions: baseRefOptions, 684 + automaticBaseRefLabel: automaticBaseRefLabel, 685 + selectedBaseRef: selectedBaseRef 686 + ) 687 + ) 688 + } 689 + .cancellable(id: CancelID.worktreePromptLoad, cancelInFlight: true) 690 + 691 + case .promptedWorktreeCreationDataLoaded( 692 + let repositoryID, 693 + let baseRefOptions, 694 + let automaticBaseRefLabel, 695 + let selectedBaseRef 696 + ): 697 + guard let repository = state.repositories[id: repositoryID] else { 698 + return .none 699 + } 700 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 701 + repositoryID: repository.id, 702 + repositoryName: repository.name, 703 + automaticBaseRefLabel: automaticBaseRefLabel, 704 + baseRefOptions: baseRefOptions, 705 + branchName: "", 706 + selectedBaseRef: selectedBaseRef, 707 + validationMessage: nil 708 + ) 709 + return .none 710 + 711 + case .worktreeCreationPrompt(.presented(.delegate(.cancel))): 712 + state.worktreeCreationPrompt = nil 713 + return .merge( 714 + .cancel(id: CancelID.worktreePromptLoad), 715 + .cancel(id: CancelID.worktreePromptValidation) 716 + ) 717 + 718 + case .worktreeCreationPrompt( 719 + .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) 720 + ): 721 + return .send( 722 + .startPromptedWorktreeCreation( 723 + repositoryID: repositoryID, 724 + branchName: branchName, 725 + baseRef: baseRef 726 + ) 727 + ) 728 + 729 + case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef): 730 + guard let repository = state.repositories[id: repositoryID] else { 731 + state.worktreeCreationPrompt = nil 732 + state.alert = messageAlert( 733 + title: "Unable to create worktree", 734 + message: "Unable to resolve a repository for the new worktree." 735 + ) 736 + return .none 737 + } 738 + state.worktreeCreationPrompt?.validationMessage = nil 739 + state.worktreeCreationPrompt?.isValidating = true 740 + let normalizedBranchName = branchName.lowercased() 741 + if repository.worktrees.contains(where: { $0.name.lowercased() == normalizedBranchName }) { 742 + state.worktreeCreationPrompt?.isValidating = false 743 + state.worktreeCreationPrompt?.validationMessage = "Branch name already exists." 744 + return .none 745 + } 746 + let gitClient = gitClient 747 + let rootURL = repository.rootURL 748 + return .run { send in 749 + let localBranchNames = (try? await gitClient.localBranchNames(rootURL)) ?? [] 750 + let duplicateMessage = 751 + localBranchNames.contains(normalizedBranchName) 752 + ? "Branch name already exists." 753 + : nil 754 + await send( 755 + .promptedWorktreeCreationChecked( 756 + repositoryID: repositoryID, 757 + branchName: branchName, 758 + baseRef: baseRef, 759 + duplicateMessage: duplicateMessage 760 + ) 761 + ) 762 + } 763 + .cancellable(id: CancelID.worktreePromptValidation, cancelInFlight: true) 764 + 765 + case .promptedWorktreeCreationChecked( 766 + let repositoryID, 767 + let branchName, 768 + let baseRef, 769 + let duplicateMessage 770 + ): 771 + guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { 772 + return .none 773 + } 774 + state.worktreeCreationPrompt?.isValidating = false 775 + if let duplicateMessage { 776 + state.worktreeCreationPrompt?.validationMessage = duplicateMessage 777 + return .none 778 + } 779 + state.worktreeCreationPrompt = nil 780 + return .send( 781 + .createWorktreeInRepository( 782 + repositoryID: repositoryID, 783 + nameSource: .explicit(branchName), 784 + baseRefSource: .explicit(baseRef) 785 + ) 786 + ) 787 + 788 + case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource): 789 + guard let repository = state.repositories[id: repositoryID] else { 790 + state.alert = messageAlert( 791 + title: "Unable to create worktree", 792 + message: "Unable to resolve a repository for the new worktree." 793 + ) 794 + return .none 795 + } 796 + if state.removingRepositoryIDs.contains(repository.id) { 797 + state.alert = messageAlert( 798 + title: "Unable to create worktree", 799 + message: "This repository is being removed." 800 + ) 801 + return .none 802 + } 590 803 let previousSelection = state.selectedWorktreeID 591 804 let pendingID = "pending:\(uuid().uuidString)" 592 805 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings ··· 603 816 state.selection = .worktree(pendingID) 604 817 let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) 605 818 let createWorktreeStream = gitClient.createWorktreeStream 819 + let isValidBranchName = gitClient.isValidBranchName 606 820 return .run { send in 607 821 var newWorktreeName: String? 608 822 var progress = WorktreeCreationProgress(stage: .loadingLocalBranches) ··· 617 831 ) 618 832 ) 619 833 let branchNames = try await gitClient.localBranchNames(repository.rootURL) 620 - progress.stage = .choosingWorktreeName 621 - await send( 622 - .pendingWorktreeProgressUpdated( 623 - id: pendingID, 624 - progress: progress 625 - ) 626 - ) 627 834 let existing = existingNames.union(branchNames) 628 - let name = await MainActor.run { 629 - WorktreeNameGenerator.nextName(excluding: existing) 630 - } 631 - guard let name else { 632 - let message = 633 - "All default adjective-animal names are already in use. " 634 - + "Delete a worktree or rename a branch, then try again." 835 + let name: String 836 + switch nameSource { 837 + case .random: 838 + progress.stage = .choosingWorktreeName 635 839 await send( 636 - .createRandomWorktreeFailed( 637 - title: "No available worktree names", 638 - message: message, 639 - pendingID: pendingID, 640 - previousSelection: previousSelection, 641 - repositoryID: repository.id, 642 - name: nil 840 + .pendingWorktreeProgressUpdated( 841 + id: pendingID, 842 + progress: progress 643 843 ) 644 844 ) 645 - return 845 + let generatedName = await MainActor.run { 846 + WorktreeNameGenerator.nextName(excluding: existing) 847 + } 848 + guard let generatedName else { 849 + let message = 850 + "All default adjective-animal names are already in use. " 851 + + "Delete a worktree or rename a branch, then try again." 852 + await send( 853 + .createRandomWorktreeFailed( 854 + title: "No available worktree names", 855 + message: message, 856 + pendingID: pendingID, 857 + previousSelection: previousSelection, 858 + repositoryID: repository.id, 859 + name: nil 860 + ) 861 + ) 862 + return 863 + } 864 + name = generatedName 865 + case .explicit(let explicitName): 866 + let trimmed = explicitName.trimmingCharacters(in: .whitespacesAndNewlines) 867 + guard !trimmed.isEmpty else { 868 + await send( 869 + .createRandomWorktreeFailed( 870 + title: "Branch name required", 871 + message: "Enter a branch name to create a worktree.", 872 + pendingID: pendingID, 873 + previousSelection: previousSelection, 874 + repositoryID: repository.id, 875 + name: nil 876 + ) 877 + ) 878 + return 879 + } 880 + guard !trimmed.contains(where: \.isWhitespace) else { 881 + await send( 882 + .createRandomWorktreeFailed( 883 + title: "Branch name invalid", 884 + message: "Branch names can't contain spaces.", 885 + pendingID: pendingID, 886 + previousSelection: previousSelection, 887 + repositoryID: repository.id, 888 + name: nil 889 + ) 890 + ) 891 + return 892 + } 893 + guard await isValidBranchName(trimmed, repository.rootURL) else { 894 + await send( 895 + .createRandomWorktreeFailed( 896 + title: "Branch name invalid", 897 + message: "Enter a valid git branch name and try again.", 898 + pendingID: pendingID, 899 + previousSelection: previousSelection, 900 + repositoryID: repository.id, 901 + name: nil 902 + ) 903 + ) 904 + return 905 + } 906 + guard !existing.contains(trimmed.lowercased()) else { 907 + await send( 908 + .createRandomWorktreeFailed( 909 + title: "Branch name already exists", 910 + message: "Choose a different branch name and try again.", 911 + pendingID: pendingID, 912 + previousSelection: previousSelection, 913 + repositoryID: repository.id, 914 + name: nil 915 + ) 916 + ) 917 + return 918 + } 919 + name = trimmed 646 920 } 647 921 newWorktreeName = name 648 922 progress.worktreeName = name ··· 664 938 ) 665 939 ) 666 940 let resolvedBaseRef: String 667 - if (selectedBaseRef ?? "").isEmpty { 668 - resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 669 - } else { 670 - resolvedBaseRef = selectedBaseRef ?? "" 941 + switch baseRefSource { 942 + case .repositorySetting: 943 + if (selectedBaseRef ?? "").isEmpty { 944 + resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 945 + } else { 946 + resolvedBaseRef = selectedBaseRef ?? "" 947 + } 948 + case .explicit(let explicitBaseRef): 949 + if let explicitBaseRef, !explicitBaseRef.isEmpty { 950 + resolvedBaseRef = explicitBaseRef 951 + } else { 952 + resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" 953 + } 671 954 } 672 955 progress.baseRef = resolvedBaseRef 673 956 progress.copyIgnored = copyIgnored ··· 750 1033 ) 751 1034 } 752 1035 } 1036 + 1037 + case .worktreeCreationPrompt(.dismiss): 1038 + state.worktreeCreationPrompt = nil 1039 + return .merge( 1040 + .cancel(id: CancelID.worktreePromptLoad), 1041 + .cancel(id: CancelID.worktreePromptValidation) 1042 + ) 1043 + 1044 + case .worktreeCreationPrompt: 1045 + return .none 753 1046 754 1047 case .pendingWorktreeProgressUpdated(let id, let progress): 755 1048 updatePendingWorktreeProgress(id, progress: progress, state: &state) ··· 2051 2344 return .none 2052 2345 } 2053 2346 } 2347 + .ifLet(\.$worktreeCreationPrompt, action: \.worktreeCreationPrompt) { 2348 + WorktreeCreationPromptFeature() 2349 + } 2054 2350 } 2055 2351 2056 2352 private func refreshRepositoryPullRequests( ··· 2736 3032 ) 2737 3033 } 2738 3034 let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL 2739 - let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL) 2740 - let worktreeURL = baseDirectory.appending(path: name, directoryHint: .isDirectory) 3035 + let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL).standardizedFileURL 3036 + let worktreeURL = 3037 + baseDirectory 3038 + .appending(path: name, directoryHint: .isDirectory) 3039 + .standardizedFileURL 3040 + guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: baseDirectory) else { 3041 + return FailedWorktreeCleanup( 3042 + didRemoveWorktree: false, 3043 + didUpdatePinned: false, 3044 + didUpdateOrder: false, 3045 + worktree: nil 3046 + ) 3047 + } 2741 3048 let worktreeID = worktreeURL.path(percentEncoded: false) 2742 3049 let worktree = 2743 3050 state.repositories[id: repositoryID]?.worktrees[id: worktreeID] ··· 2759 3066 didUpdateOrder: cleanup.didUpdateOrder, 2760 3067 worktree: worktree 2761 3068 ) 3069 + } 3070 + 3071 + private func isPathInsideBaseDirectory(_ path: URL, baseDirectory: URL) -> Bool { 3072 + let normalizedPath = path.standardizedFileURL.pathComponents 3073 + let normalizedBase = baseDirectory.standardizedFileURL.pathComponents 3074 + guard normalizedPath.count >= normalizedBase.count else { 3075 + return false 3076 + } 3077 + return Array(normalizedPath.prefix(normalizedBase.count)) == normalizedBase 2762 3078 } 2763 3079 2764 3080 private struct WorktreeCleanupStateResult {
+78
supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + @Reducer 5 + struct WorktreeCreationPromptFeature { 6 + @ObservableState 7 + struct State: Equatable { 8 + let repositoryID: Repository.ID 9 + let repositoryName: String 10 + let automaticBaseRefLabel: String 11 + let baseRefOptions: [String] 12 + var branchName: String 13 + var selectedBaseRef: String? 14 + var validationMessage: String? 15 + var isValidating = false 16 + } 17 + 18 + enum Action: BindableAction, Equatable { 19 + case binding(BindingAction<State>) 20 + case cancelButtonTapped 21 + case createButtonTapped 22 + case setValidationMessage(String?) 23 + case setValidating(Bool) 24 + case delegate(Delegate) 25 + } 26 + 27 + @CasePathable 28 + enum Delegate: Equatable { 29 + case cancel 30 + case submit(repositoryID: Repository.ID, branchName: String, baseRef: String?) 31 + } 32 + 33 + var body: some Reducer<State, Action> { 34 + BindingReducer() 35 + Reduce { state, action in 36 + switch action { 37 + case .binding: 38 + state.validationMessage = nil 39 + return .none 40 + 41 + case .cancelButtonTapped: 42 + return .send(.delegate(.cancel)) 43 + 44 + case .createButtonTapped: 45 + let trimmed = state.branchName.trimmingCharacters(in: .whitespacesAndNewlines) 46 + guard !trimmed.isEmpty else { 47 + state.validationMessage = "Branch name required." 48 + return .none 49 + } 50 + guard !trimmed.contains(where: \.isWhitespace) else { 51 + state.validationMessage = "Branch names can't contain spaces." 52 + return .none 53 + } 54 + state.validationMessage = nil 55 + return .send( 56 + .delegate( 57 + .submit( 58 + repositoryID: state.repositoryID, 59 + branchName: trimmed, 60 + baseRef: state.selectedBaseRef 61 + ) 62 + ) 63 + ) 64 + 65 + case .setValidationMessage(let message): 66 + state.validationMessage = message 67 + return .none 68 + 69 + case .setValidating(let isValidating): 70 + state.isValidating = isValidating 71 + return .none 72 + 73 + case .delegate: 74 + return .none 75 + } 76 + } 77 + } 78 + }
+22 -20
supacode/Features/Repositories/Views/SidebarView.swift
··· 30 30 terminalManager: terminalManager 31 31 ) 32 32 .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) 33 - .focusedSceneValue(\.archiveWorktreeAction, archiveWorktreeAction) 34 - .focusedSceneValue(\.deleteWorktreeAction, deleteWorktreeAction) 35 - .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) 36 - .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } 37 - .onChange(of: state.selection) { _, _ in 38 - syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 39 - } 40 - .onChange(of: visibleHotkeyRows.map(\.id)) { _, _ in 41 - syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 42 - } 43 - .onChange(of: sidebarSelections) { _, newValue in 44 - store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: newValue))) 45 - } 46 - .onChange(of: repositoryIDs) { _, newValue in 47 - let collapsed = Set(collapsedRepositoryIDs).intersection(newValue) 48 - $collapsedRepositoryIDs.withLock { 49 - $0 = Array(collapsed).sorted() 50 - } 33 + .focusedSceneValue(\.archiveWorktreeAction, archiveWorktreeAction) 34 + .focusedSceneValue(\.deleteWorktreeAction, deleteWorktreeAction) 35 + .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) 36 + .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } 37 + .onChange(of: state.selection) { _, _ in 38 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 39 + } 40 + .onChange(of: visibleHotkeyRows.map(\.id)) { _, _ in 41 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 42 + } 43 + .onChange(of: sidebarSelections) { _, newValue in 44 + store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: newValue))) 45 + } 46 + .onChange(of: repositoryIDs) { _, newValue in 47 + let collapsed = Set(collapsedRepositoryIDs).intersection(newValue) 48 + $collapsedRepositoryIDs.withLock { 49 + $0 = Array(collapsed).sorted() 51 50 } 51 + } 52 52 } 53 53 54 54 private func expandedRepositoryIDs( ··· 94 94 private func makeArchiveWorktreeAction( 95 95 rows: [WorktreeRowModel] 96 96 ) -> (() -> Void)? { 97 - let targets = rows 97 + let targets = 98 + rows 98 99 .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting } 99 100 .map { 100 101 RepositoriesFeature.ArchiveWorktreeTarget( ··· 115 116 private func makeDeleteWorktreeAction( 116 117 rows: [WorktreeRowModel] 117 118 ) -> (() -> Void)? { 118 - let targets = rows 119 + let targets = 120 + rows 119 121 .filter { $0.isRemovable && !$0.isDeleting } 120 122 .map { 121 123 RepositoriesFeature.DeleteWorktreeTarget(
+68
supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct WorktreeCreationPromptView: View { 5 + @Bindable var store: StoreOf<WorktreeCreationPromptFeature> 6 + @FocusState private var isBranchFieldFocused: Bool 7 + 8 + var body: some View { 9 + VStack(alignment: .leading, spacing: 16) { 10 + VStack(alignment: .leading, spacing: 4) { 11 + Text("New Worktree") 12 + .font(.title3) 13 + Text("Create a branch in \(store.repositoryName)") 14 + .foregroundStyle(.secondary) 15 + } 16 + 17 + VStack(alignment: .leading, spacing: 8) { 18 + Text("Branch name") 19 + .foregroundStyle(.secondary) 20 + TextField("feature/my-change", text: $store.branchName) 21 + .textFieldStyle(.roundedBorder) 22 + .focused($isBranchFieldFocused) 23 + .onSubmit { 24 + store.send(.createButtonTapped) 25 + } 26 + } 27 + 28 + Picker("Branch from", selection: $store.selectedBaseRef) { 29 + Text(store.automaticBaseRefLabel) 30 + .tag(Optional<String>.none) 31 + ForEach(store.baseRefOptions, id: \.self) { ref in 32 + Text(ref) 33 + .tag(Optional(ref)) 34 + } 35 + } 36 + 37 + if let validationMessage = store.validationMessage, !validationMessage.isEmpty { 38 + Text(validationMessage) 39 + .font(.footnote) 40 + .foregroundStyle(.red) 41 + } 42 + 43 + HStack { 44 + if store.isValidating { 45 + ProgressView() 46 + .controlSize(.small) 47 + } 48 + Spacer() 49 + Button("Cancel") { 50 + store.send(.cancelButtonTapped) 51 + } 52 + .keyboardShortcut(.cancelAction) 53 + .help("Cancel (Esc)") 54 + Button("Create") { 55 + store.send(.createButtonTapped) 56 + } 57 + .keyboardShortcut(.defaultAction) 58 + .help("Create (↩)") 59 + .disabled(store.isValidating) 60 + } 61 + } 62 + .padding(20) 63 + .frame(minWidth: 420) 64 + .task { 65 + isBranchFieldFocused = true 66 + } 67 + } 68 + }
+2 -1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 214 214 let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 215 215 let contextRows = contextActionRows(for: row) 216 216 let isBulkSelection = contextRows.count > 1 217 - let archiveTargets = contextRows 217 + let archiveTargets = 218 + contextRows 218 219 .filter { !$0.isMainWorktree } 219 220 .map { 220 221 RepositoriesFeature.ArchiveWorktreeTarget(
+9 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 12 12 var githubIntegrationEnabled: Bool 13 13 var deleteBranchOnDeleteWorktree: Bool 14 14 var automaticallyArchiveMergedWorktrees: Bool 15 + var promptForWorktreeCreation: Bool 15 16 16 17 static let `default` = GlobalSettings( 17 18 appearanceMode: .dark, ··· 26 27 crashReportsEnabled: true, 27 28 githubIntegrationEnabled: true, 28 29 deleteBranchOnDeleteWorktree: true, 29 - automaticallyArchiveMergedWorktrees: false 30 + automaticallyArchiveMergedWorktrees: false, 31 + promptForWorktreeCreation: true 30 32 ) 31 33 32 34 init( ··· 42 44 crashReportsEnabled: Bool, 43 45 githubIntegrationEnabled: Bool, 44 46 deleteBranchOnDeleteWorktree: Bool, 45 - automaticallyArchiveMergedWorktrees: Bool 47 + automaticallyArchiveMergedWorktrees: Bool, 48 + promptForWorktreeCreation: Bool 46 49 ) { 47 50 self.appearanceMode = appearanceMode 48 51 self.defaultEditorID = defaultEditorID ··· 57 60 self.githubIntegrationEnabled = githubIntegrationEnabled 58 61 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 59 62 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 63 + self.promptForWorktreeCreation = promptForWorktreeCreation 60 64 } 61 65 62 66 init(from decoder: any Decoder) throws { ··· 94 98 automaticallyArchiveMergedWorktrees = 95 99 try container.decodeIfPresent(Bool.self, forKey: .automaticallyArchiveMergedWorktrees) 96 100 ?? Self.default.automaticallyArchiveMergedWorktrees 101 + promptForWorktreeCreation = 102 + try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 103 + ?? Self.default.promptForWorktreeCreation 97 104 } 98 105 }
+5 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 18 18 var githubIntegrationEnabled: Bool 19 19 var deleteBranchOnDeleteWorktree: Bool 20 20 var automaticallyArchiveMergedWorktrees: Bool 21 + var promptForWorktreeCreation: Bool 21 22 var selection: SettingsSection? = .general 22 23 var repositorySettings: RepositorySettingsFeature.State? 23 24 ··· 36 37 githubIntegrationEnabled = settings.githubIntegrationEnabled 37 38 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 38 39 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 40 + promptForWorktreeCreation = settings.promptForWorktreeCreation 39 41 } 40 42 41 43 var globalSettings: GlobalSettings { ··· 52 54 crashReportsEnabled: crashReportsEnabled, 53 55 githubIntegrationEnabled: githubIntegrationEnabled, 54 56 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 55 - automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees 57 + automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 58 + promptForWorktreeCreation: promptForWorktreeCreation 56 59 ) 57 60 } 58 61 } ··· 106 109 state.githubIntegrationEnabled = normalizedSettings.githubIntegrationEnabled 107 110 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 108 111 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 112 + state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 109 113 return .send(.delegate(.settingsChanged(normalizedSettings))) 110 114 111 115 case .binding:
+9
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 25 25 isOn: $store.automaticallyArchiveMergedWorktrees 26 26 ) 27 27 .help("Archive worktrees automatically when their pull requests are merged.") 28 + VStack(alignment: .leading) { 29 + Toggle( 30 + "Prompt for branch name during creation", 31 + isOn: $store.promptForWorktreeCreation 32 + ) 33 + .help("Ask for branch name and base ref before creating a worktree.") 34 + Text("When enabled, you choose the branch name and where it branches from before creating the worktree.") 35 + .foregroundStyle(.secondary) 36 + } 28 37 } 29 38 } 30 39 .formStyle(.grouped)
+276 -2
supacodeTests/RepositoriesFeatureTests.swift
··· 1 + import Clocks 1 2 import ComposableArchitecture 2 3 import CustomDump 4 + import DependenciesTestSupport 3 5 import Foundation 4 6 import IdentifiedCollections 7 + import Sharing 5 8 import Testing 6 9 7 10 @testable import supacode ··· 152 155 } 153 156 } 154 157 155 - @Test func createRandomWorktreeInRepositoryStreamsOutputLines() async { 158 + @Test func createRandomWorktreeInRepositoryWithPromptEnabledPresentsPrompt() async { 159 + let repoRoot = "/tmp/repo" 160 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 161 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 162 + let store = TestStore(initialState: makeState(repositories: [repository])) { 163 + RepositoriesFeature() 164 + } withDependencies: { 165 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 166 + $0.gitClient.branchRefs = { _ in ["origin/main", "origin/dev"] } 167 + } 168 + 169 + await store.send(.createRandomWorktreeInRepository(repository.id)) 170 + await store.receive(\.promptedWorktreeCreationDataLoaded) { 171 + $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 172 + repositoryID: repository.id, 173 + repositoryName: repository.name, 174 + automaticBaseRefLabel: "Automatic (origin/main)", 175 + baseRefOptions: ["origin/dev", "origin/main"], 176 + branchName: "", 177 + selectedBaseRef: nil, 178 + validationMessage: nil 179 + ) 180 + } 181 + } 182 + 183 + @Test func promptedWorktreeCreationCancelDismissesPrompt() async { 184 + let repoRoot = "/tmp/repo" 185 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 186 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 187 + var state = makeState(repositories: [repository]) 188 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 189 + repositoryID: repository.id, 190 + repositoryName: repository.name, 191 + automaticBaseRefLabel: "Automatic (origin/main)", 192 + baseRefOptions: ["origin/main"], 193 + branchName: "feature/new-branch", 194 + selectedBaseRef: nil, 195 + validationMessage: nil 196 + ) 197 + let store = TestStore(initialState: state) { 198 + RepositoriesFeature() 199 + } 200 + 201 + await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) { 202 + $0.worktreeCreationPrompt = nil 203 + } 204 + } 205 + 206 + @Test func startPromptedWorktreeCreationWithDuplicateLocalBranchShowsValidation() async { 207 + let repoRoot = "/tmp/repo" 208 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 209 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 210 + var state = makeState(repositories: [repository]) 211 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 212 + repositoryID: repository.id, 213 + repositoryName: repository.name, 214 + automaticBaseRefLabel: "Automatic (origin/main)", 215 + baseRefOptions: ["origin/main"], 216 + branchName: "feature/existing", 217 + selectedBaseRef: nil, 218 + validationMessage: nil 219 + ) 220 + let store = TestStore(initialState: state) { 221 + RepositoriesFeature() 222 + } withDependencies: { 223 + $0.gitClient.localBranchNames = { _ in ["feature/existing"] } 224 + } 225 + 226 + await store.send( 227 + .startPromptedWorktreeCreation( 228 + repositoryID: repository.id, 229 + branchName: "feature/existing", 230 + baseRef: nil 231 + ) 232 + ) { 233 + $0.worktreeCreationPrompt?.validationMessage = nil 234 + $0.worktreeCreationPrompt?.isValidating = true 235 + } 236 + await store.receive(\.promptedWorktreeCreationChecked) { 237 + $0.worktreeCreationPrompt?.validationMessage = "Branch name already exists." 238 + $0.worktreeCreationPrompt?.isValidating = false 239 + } 240 + } 241 + 242 + @Test func createRandomWorktreeInRepositoryLatestPromptRequestWins() async { 243 + actor PromptLoadGate { 244 + var continuation: CheckedContinuation<Void, Never>? 245 + 246 + func wait() async { 247 + await withCheckedContinuation { continuation in 248 + self.continuation = continuation 249 + } 250 + } 251 + 252 + func waitUntilArmed() async { 253 + while continuation == nil { 254 + await Task.yield() 255 + } 256 + } 257 + 258 + func resume() { 259 + continuation?.resume() 260 + continuation = nil 261 + } 262 + } 263 + 264 + let repoRootA = "/tmp/repo-a" 265 + let repoRootB = "/tmp/repo-b" 266 + let promptLoadGate = PromptLoadGate() 267 + let repoA = makeRepository( 268 + id: repoRootA, 269 + worktrees: [makeWorktree(id: repoRootA, name: "main", repoRoot: repoRootA)] 270 + ) 271 + let repoB = makeRepository( 272 + id: repoRootB, 273 + worktrees: [makeWorktree(id: repoRootB, name: "main", repoRoot: repoRootB)] 274 + ) 275 + let store = TestStore(initialState: makeState(repositories: [repoA, repoB])) { 276 + RepositoriesFeature() 277 + } withDependencies: { 278 + $0.gitClient.automaticWorktreeBaseRef = { root in 279 + if root.path(percentEncoded: false) == repoRootA { 280 + await promptLoadGate.wait() 281 + } 282 + return "origin/main" 283 + } 284 + $0.gitClient.branchRefs = { _ in ["origin/main"] } 285 + } 286 + 287 + await store.send(.createRandomWorktreeInRepository(repoA.id)) 288 + await promptLoadGate.waitUntilArmed() 289 + await store.send(.createRandomWorktreeInRepository(repoB.id)) 290 + await promptLoadGate.resume() 291 + await store.receive(\.promptedWorktreeCreationDataLoaded) { 292 + $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 293 + repositoryID: repoB.id, 294 + repositoryName: repoB.name, 295 + automaticBaseRefLabel: "Automatic (origin/main)", 296 + baseRefOptions: ["origin/main"], 297 + branchName: "", 298 + selectedBaseRef: nil, 299 + validationMessage: nil 300 + ) 301 + } 302 + await store.finish() 303 + } 304 + 305 + @Test func promptedWorktreeCreationCancelDuringValidationStopsCreation() async { 306 + let validationClock = TestClock() 307 + let repoRoot = "/tmp/repo" 308 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 309 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 310 + var state = makeState(repositories: [repository]) 311 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 312 + repositoryID: repository.id, 313 + repositoryName: repository.name, 314 + automaticBaseRefLabel: "Automatic (origin/main)", 315 + baseRefOptions: ["origin/main"], 316 + branchName: "feature/new-branch", 317 + selectedBaseRef: nil, 318 + validationMessage: nil 319 + ) 320 + let store = TestStore(initialState: state) { 321 + RepositoriesFeature() 322 + } withDependencies: { 323 + $0.gitClient.localBranchNames = { _ in 324 + try? await validationClock.sleep(for: .seconds(1)) 325 + return [] 326 + } 327 + } 328 + 329 + await store.send( 330 + .startPromptedWorktreeCreation( 331 + repositoryID: repository.id, 332 + branchName: "feature/new-branch", 333 + baseRef: nil 334 + ) 335 + ) { 336 + $0.worktreeCreationPrompt?.validationMessage = nil 337 + $0.worktreeCreationPrompt?.isValidating = true 338 + } 339 + await store.send(.worktreeCreationPrompt(.presented(.delegate(.cancel)))) { 340 + $0.worktreeCreationPrompt = nil 341 + } 342 + await validationClock.advance(by: .seconds(1)) 343 + await store.finish() 344 + } 345 + 346 + @Test func createWorktreeInRepositoryWithInvalidBranchNameFails() async { 347 + let repoRoot = "/tmp/repo" 348 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 349 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 350 + let store = TestStore(initialState: makeState(repositories: [repository])) { 351 + RepositoriesFeature() 352 + } withDependencies: { 353 + $0.uuid = .incrementing 354 + $0.gitClient.isValidBranchName = { _, _ in false } 355 + $0.gitClient.localBranchNames = { _ in [] } 356 + } 357 + store.exhaustivity = .off 358 + 359 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 360 + TextState("Branch name invalid") 361 + } actions: { 362 + ButtonState(role: .cancel) { 363 + TextState("OK") 364 + } 365 + } message: { 366 + TextState("Enter a valid git branch name and try again.") 367 + } 368 + 369 + await store.send( 370 + .createWorktreeInRepository( 371 + repositoryID: repository.id, 372 + nameSource: .explicit("../../Desktop"), 373 + baseRefSource: .repositorySetting 374 + ) 375 + ) 376 + await store.receive(\.createRandomWorktreeFailed) { 377 + $0.alert = expectedAlert 378 + } 379 + #expect(store.state.pendingWorktrees.isEmpty) 380 + await store.finish() 381 + } 382 + 383 + @Test func createRandomWorktreeFailedWithTraversalNameSkipsCleanup() async { 384 + let repoRoot = "/tmp/repo" 385 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 386 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 387 + let removed = LockIsolated(false) 388 + let store = TestStore(initialState: makeState(repositories: [repository])) { 389 + RepositoriesFeature() 390 + } withDependencies: { 391 + $0.gitClient.removeWorktree = { _, _ in 392 + removed.withValue { $0 = true } 393 + return URL(fileURLWithPath: "/tmp/removed") 394 + } 395 + $0.gitClient.pruneWorktrees = { _ in } 396 + $0.gitClient.worktrees = { _ in [mainWorktree] } 397 + } 398 + 399 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 400 + TextState("Unable to create worktree") 401 + } actions: { 402 + ButtonState(role: .cancel) { 403 + TextState("OK") 404 + } 405 + } message: { 406 + TextState("boom") 407 + } 408 + 409 + await store.send( 410 + .createRandomWorktreeFailed( 411 + title: "Unable to create worktree", 412 + message: "boom", 413 + pendingID: "pending:1", 414 + previousSelection: nil, 415 + repositoryID: repository.id, 416 + name: "../../Desktop" 417 + ) 418 + ) { 419 + $0.alert = expectedAlert 420 + } 421 + await store.finish() 422 + #expect(removed.value == false) 423 + } 424 + 425 + @Test(.dependencies) func createRandomWorktreeInRepositoryStreamsOutputLines() async { 156 426 let repoRoot = "/tmp/repo" 157 427 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 158 428 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) ··· 161 431 name: "swift-otter", 162 432 repoRoot: repoRoot 163 433 ) 434 + @Shared(.settingsFile) var settingsFile 435 + $settingsFile.withLock { $0.global.promptForWorktreeCreation = false } 164 436 let store = TestStore(initialState: makeState(repositories: [repository])) { 165 437 RepositoriesFeature() 166 438 } withDependencies: { ··· 194 466 #expect(store.state.alert == nil) 195 467 } 196 468 197 - @Test func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { 469 + @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { 198 470 let repoRoot = "/tmp/repo" 199 471 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 200 472 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 473 + @Shared(.settingsFile) var settingsFile 474 + $settingsFile.withLock { $0.global.promptForWorktreeCreation = false } 201 475 let store = TestStore(initialState: makeState(repositories: [repository])) { 202 476 RepositoriesFeature() 203 477 } withDependencies: {
+10 -4
supacodeTests/SettingsFeatureTests.swift
··· 23 23 crashReportsEnabled: true, 24 24 githubIntegrationEnabled: true, 25 25 deleteBranchOnDeleteWorktree: false, 26 - automaticallyArchiveMergedWorktrees: true 26 + automaticallyArchiveMergedWorktrees: true, 27 + promptForWorktreeCreation: true 27 28 ) 28 29 @Shared(.settingsFile) var settingsFile 29 30 $settingsFile.withLock { $0.global = loaded } ··· 47 48 $0.githubIntegrationEnabled = true 48 49 $0.deleteBranchOnDeleteWorktree = false 49 50 $0.automaticallyArchiveMergedWorktrees = true 51 + $0.promptForWorktreeCreation = true 50 52 } 51 53 await store.receive(\.delegate.settingsChanged) 52 54 } ··· 65 67 crashReportsEnabled: false, 66 68 githubIntegrationEnabled: true, 67 69 deleteBranchOnDeleteWorktree: true, 68 - automaticallyArchiveMergedWorktrees: false 70 + automaticallyArchiveMergedWorktrees: false, 71 + promptForWorktreeCreation: false 69 72 ) 70 73 @Shared(.settingsFile) var settingsFile 71 74 $settingsFile.withLock { $0.global = initialSettings } ··· 90 93 crashReportsEnabled: initialSettings.crashReportsEnabled, 91 94 githubIntegrationEnabled: initialSettings.githubIntegrationEnabled, 92 95 deleteBranchOnDeleteWorktree: initialSettings.deleteBranchOnDeleteWorktree, 93 - automaticallyArchiveMergedWorktrees: initialSettings.automaticallyArchiveMergedWorktrees 96 + automaticallyArchiveMergedWorktrees: initialSettings.automaticallyArchiveMergedWorktrees, 97 + promptForWorktreeCreation: initialSettings.promptForWorktreeCreation 94 98 ) 95 99 await store.receive(\.delegate.settingsChanged) 96 100 ··· 138 142 crashReportsEnabled: false, 139 143 githubIntegrationEnabled: true, 140 144 deleteBranchOnDeleteWorktree: true, 141 - automaticallyArchiveMergedWorktrees: true 145 + automaticallyArchiveMergedWorktrees: true, 146 + promptForWorktreeCreation: false 142 147 ) 143 148 144 149 await store.send(.settingsLoaded(loaded)) { ··· 155 160 $0.githubIntegrationEnabled = true 156 161 $0.deleteBranchOnDeleteWorktree = true 157 162 $0.automaticallyArchiveMergedWorktrees = true 163 + $0.promptForWorktreeCreation = false 158 164 $0.selection = selection 159 165 $0.repositorySettings = RepositorySettingsFeature.State( 160 166 rootURL: rootURL,
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 107 107 #expect(settings.global.githubIntegrationEnabled == true) 108 108 #expect(settings.global.deleteBranchOnDeleteWorktree == true) 109 109 #expect(settings.global.automaticallyArchiveMergedWorktrees == false) 110 + #expect(settings.global.promptForWorktreeCreation == true) 110 111 #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) 111 112 #expect(settings.repositoryRoots.isEmpty) 112 113 #expect(settings.pinnedWorktreeIDs.isEmpty)