From 08d60ebd51d3f6183f6608a7fac52a01db225b33 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:43:02 +0700 Subject: [PATCH] Add granular worktree creation progress states --- supacode/Domain/PendingWorktree.swift | 3 +- .../Domain/WorktreeCreationProgress.swift | 60 ++++++++++++ supacode/Domain/WorktreeLoadingInfo.swift | 2 + .../Reducer/RepositoriesFeature.swift | 62 +++++++++++- .../Views/WorktreeDetailView.swift | 20 +++- .../Views/WorktreeLoadingView.swift | 19 ++-- .../Repositories/Views/WorktreeRowsView.swift | 3 + .../CommandPaletteFeatureTests.swift | 9 +- supacodeTests/RepositoriesFeatureTests.swift | 97 ++++++++++++++++++- .../WorktreeCreationProgressTests.swift | 31 ++++++ 10 files changed, 284 insertions(+), 22 deletions(-) create mode 100644 supacode/Domain/WorktreeCreationProgress.swift create mode 100644 supacodeTests/WorktreeCreationProgressTests.swift diff --git a/supacode/Domain/PendingWorktree.swift b/supacode/Domain/PendingWorktree.swift index e8161c4c..89683ae6 100644 --- a/supacode/Domain/PendingWorktree.swift +++ b/supacode/Domain/PendingWorktree.swift @@ -3,6 +3,5 @@ import Foundation struct PendingWorktree: Identifiable, Hashable { let id: String let repositoryID: Repository.ID - let name: String - let detail: String + var progress: WorktreeCreationProgress } diff --git a/supacode/Domain/WorktreeCreationProgress.swift b/supacode/Domain/WorktreeCreationProgress.swift new file mode 100644 index 00000000..3e1bd3e0 --- /dev/null +++ b/supacode/Domain/WorktreeCreationProgress.swift @@ -0,0 +1,60 @@ +nonisolated struct WorktreeCreationProgress: Hashable, Sendable { + var stage: WorktreeCreationStage + var worktreeName: String? + var baseRef: String? + var copyIgnored: Bool? + var copyUntracked: Bool? + + init( + stage: WorktreeCreationStage, + worktreeName: String? = nil, + baseRef: String? = nil, + copyIgnored: Bool? = nil, + copyUntracked: Bool? = nil + ) { + self.stage = stage + self.worktreeName = worktreeName + self.baseRef = baseRef + self.copyIgnored = copyIgnored + self.copyUntracked = copyUntracked + } + + var titleText: String { + if let worktreeName, !worktreeName.isEmpty { + return "Creating \(worktreeName)" + } + return "Creating worktree" + } + + var detailText: String { + switch stage { + case .loadingLocalBranches: + return "Reading local branches" + case .choosingWorktreeName: + return "Choosing available worktree name" + case .checkingRepositoryMode: + return "Checking repository mode" + case .resolvingBaseReference: + return "Resolving base reference (\(baseRefDisplay))" + case .creatingWorktree: + let ignored = copyIgnored.map { $0 ? "on" : "off" } ?? "off" + let untracked = copyUntracked.map { $0 ? "on" : "off" } ?? "off" + return "Creating from \(baseRefDisplay) (copy ignored: \(ignored), copy untracked: \(untracked))" + } + } + + private var baseRefDisplay: String { + guard let baseRef, !baseRef.isEmpty else { + return "HEAD" + } + return baseRef + } +} + +nonisolated enum WorktreeCreationStage: Hashable, Sendable { + case loadingLocalBranches + case choosingWorktreeName + case checkingRepositoryMode + case resolvingBaseReference + case creatingWorktree +} diff --git a/supacode/Domain/WorktreeLoadingInfo.swift b/supacode/Domain/WorktreeLoadingInfo.swift index 9b344b45..a300ac60 100644 --- a/supacode/Domain/WorktreeLoadingInfo.swift +++ b/supacode/Domain/WorktreeLoadingInfo.swift @@ -2,4 +2,6 @@ struct WorktreeLoadingInfo: Hashable { let name: String let repositoryName: String? let state: WorktreeLoadingState + let statusTitle: String? + let statusDetail: String? } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index f7bd9a4a..8541321a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -68,6 +68,7 @@ struct RepositoriesFeature { case requestRenameBranch(Worktree.ID, String) case createRandomWorktree case createRandomWorktreeInRepository(Repository.ID) + case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) case createRandomWorktreeSucceeded( Worktree, repositoryID: Repository.ID, @@ -506,16 +507,29 @@ struct RepositoriesFeature { PendingWorktree( id: pendingID, repositoryID: repository.id, - name: "Creating worktree...", - detail: "" + progress: WorktreeCreationProgress(stage: .loadingLocalBranches) ) ) state.selection = .worktree(pendingID) let existingNames = Set(repository.worktrees.map { $0.name.lowercased() }) return .run { send in var newWorktreeName: String? + var progress = WorktreeCreationProgress(stage: .loadingLocalBranches) do { + await send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: progress + ) + ) let branchNames = try await gitClient.localBranchNames(repository.rootURL) + progress.stage = .choosingWorktreeName + await send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: progress + ) + ) let existing = existingNames.union(branchNames) let name = await MainActor.run { WorktreeNameGenerator.nextName(excluding: existing) @@ -537,15 +551,40 @@ struct RepositoriesFeature { return } newWorktreeName = name + progress.worktreeName = name + progress.stage = .checkingRepositoryMode + await send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: progress + ) + ) let isBareRepository = (try? await gitClient.isBareRepository(repository.rootURL)) ?? false let copyIgnored = isBareRepository ? false : copyIgnoredOnWorktreeCreate let copyUntracked = isBareRepository ? false : copyUntrackedOnWorktreeCreate + progress.stage = .resolvingBaseReference + await send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: progress + ) + ) let resolvedBaseRef: String if (selectedBaseRef ?? "").isEmpty { resolvedBaseRef = await gitClient.automaticWorktreeBaseRef(repository.rootURL) ?? "" } else { resolvedBaseRef = selectedBaseRef ?? "" } + progress.baseRef = resolvedBaseRef + progress.copyIgnored = copyIgnored + progress.copyUntracked = copyUntracked + progress.stage = .creatingWorktree + await send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: progress + ) + ) let newWorktree = try await gitClient.createWorktree( name, repository.rootURL, @@ -574,6 +613,10 @@ struct RepositoriesFeature { } } + case .pendingWorktreeProgressUpdated(let id, let progress): + updatePendingWorktreeProgress(id, progress: progress, state: &state) + return .none + case .createRandomWorktreeSucceeded( let worktree, let repositoryID, @@ -1945,8 +1988,8 @@ extension RepositoriesFeature.State { return WorktreeRowModel( id: pending.id, repositoryID: pending.repositoryID, - name: pending.name, - detail: pending.detail, + name: pending.progress.titleText, + detail: pending.progress.detailText, info: worktreeInfo(for: pending.id), isPinned: false, isMainWorktree: false, @@ -2250,6 +2293,17 @@ private func removePendingWorktree(_ id: String, state: inout RepositoriesFeatur state.pendingWorktrees.removeAll { $0.id == id } } +private func updatePendingWorktreeProgress( + _ id: String, + progress: WorktreeCreationProgress, + state: inout RepositoriesFeature.State +) { + guard let index = state.pendingWorktrees.firstIndex(where: { $0.id == id }) else { + return + } + state.pendingWorktrees[index].progress = progress +} + private func insertWorktree( _ worktree: Worktree, repositoryID: Repository.ID, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 068bf444..3b8072e3 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -15,7 +15,11 @@ struct WorktreeDetailView: View { let repositories = state.repositories let selectedRow = repositories.selectedRow(for: repositories.selectedWorktreeID) let selectedWorktree = repositories.worktree(for: repositories.selectedWorktreeID) - let loadingInfo = loadingInfo(for: selectedRow, repositories: repositories) + let loadingInfo = loadingInfo( + for: selectedRow, + selectedWorktreeID: repositories.selectedWorktreeID, + repositories: repositories + ) let hasActiveWorktree = selectedWorktree != nil && loadingInfo == nil let openActionSelection = state.openActionSelection let runScriptEnabled = hasActiveWorktree @@ -316,6 +320,7 @@ struct WorktreeDetailView: View { private func loadingInfo( for selectedRow: WorktreeRowModel?, + selectedWorktreeID: Worktree.ID?, repositories: RepositoriesFeature.State ) -> WorktreeLoadingInfo? { guard let selectedRow else { return nil } @@ -324,14 +329,21 @@ struct WorktreeDetailView: View { return WorktreeLoadingInfo( name: selectedRow.name, repositoryName: repositoryName, - state: .removing + state: .removing, + statusTitle: nil, + statusDetail: nil ) } if selectedRow.isPending { + let pending = repositories.pendingWorktree(for: selectedWorktreeID) + let progress = pending?.progress + let displayName = progress?.worktreeName ?? selectedRow.name return WorktreeLoadingInfo( - name: selectedRow.name, + name: displayName, repositoryName: repositoryName, - state: .creating + state: .creating, + statusTitle: progress?.titleText ?? selectedRow.name, + statusDetail: progress?.detailText ?? selectedRow.detail ) } return nil diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index e2850b41..39c2560e 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -5,6 +5,14 @@ struct WorktreeLoadingView: View { var body: some View { let actionLabel = info.state == .creating ? "Creating" : "Removing" + let statusTitle = + info.statusTitle + ?? { + if let repositoryName = info.repositoryName { + return "\(actionLabel) worktree in \(repositoryName)" + } + return "\(actionLabel) worktree..." + }() let followup = info.state == .creating ? "We will open the terminal when it's ready." @@ -13,12 +21,11 @@ struct WorktreeLoadingView: View { ProgressView() Text(info.name) .font(.headline) - if let repositoryName = info.repositoryName { - Text("\(actionLabel) worktree in \(repositoryName)") - .font(.subheadline) - .foregroundStyle(.secondary) - } else { - Text("\(actionLabel) worktree...") + Text(statusTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + if let statusDetail = info.statusDetail, !statusDetail.isEmpty { + Text(statusDetail) .font(.subheadline) .foregroundStyle(.secondary) } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index 8d66b0ed..3c87d816 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -266,6 +266,9 @@ struct WorktreeRowsView: View { if row.isMainWorktree { return "Default" } + if row.isPending { + return row.detail + } if row.id.contains("/") { let pathName = URL(fileURLWithPath: row.id).lastPathComponent if !pathName.isEmpty { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 8696cb12..d569b919 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -41,8 +41,13 @@ struct CommandPaletteFeatureTests { PendingWorktree( id: "\(rootPath)/wt-pending", repositoryID: repository.id, - name: "pending", - detail: "pending" + progress: WorktreeCreationProgress( + stage: .creatingWorktree, + worktreeName: "pending", + baseRef: "origin/main", + copyIgnored: false, + copyUntracked: false + ) ), ] diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 33947fd0..b6ab7d0f 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -95,6 +95,97 @@ struct RepositoriesFeatureTests { } } + @Test func pendingProgressUpdateUpdatesPendingWorktreeState() async { + let repoRoot = "/tmp/repo" + let repository = makeRepository( + id: repoRoot, + worktrees: [makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot)] + ) + let pendingID = "pending:test" + var state = makeState(repositories: [repository]) + state.selection = .worktree(pendingID) + state.pendingWorktrees = [ + PendingWorktree( + id: pendingID, + repositoryID: repository.id, + progress: WorktreeCreationProgress(stage: .loadingLocalBranches) + ), + ] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + let nextProgress = WorktreeCreationProgress( + stage: .creatingWorktree, + worktreeName: "swift-otter", + baseRef: "origin/main", + copyIgnored: false, + copyUntracked: true + ) + await store.send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: nextProgress + ) + ) { + $0.pendingWorktrees[0].progress = nextProgress + } + } + + @Test func pendingProgressUpdateIsIgnoredAfterCreateFailureRemovesPendingWorktree() async { + let repoRoot = "/tmp/repo" + let repository = makeRepository(id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) + let pendingID = "pending:test" + var state = makeState(repositories: [repository]) + state.selection = .worktree(pendingID) + state.pendingWorktrees = [ + PendingWorktree( + id: pendingID, + repositoryID: repository.id, + progress: WorktreeCreationProgress( + stage: .checkingRepositoryMode, + worktreeName: "swift-otter" + ) + ), + ] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + let expectedAlert = AlertState { + TextState("Unable to create worktree") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState("boom") + } + + await store.send( + .createRandomWorktreeFailed( + title: "Unable to create worktree", + message: "boom", + pendingID: pendingID, + previousSelection: nil, + repositoryID: repository.id, + name: nil + ) + ) { + $0.pendingWorktrees = [] + $0.selection = nil + $0.alert = expectedAlert + } + + await store.send( + .pendingWorktreeProgressUpdated( + id: pendingID, + progress: WorktreeCreationProgress(stage: .creatingWorktree) + ) + ) + #expect(store.state.pendingWorktrees.isEmpty) + } + @Test func requestDeleteWorktreeShowsConfirmation() async { let worktree = makeWorktree(id: "/tmp/wt", name: "owl") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -637,8 +728,7 @@ struct RepositoriesFeatureTests { PendingWorktree( id: removedWorktree.id, repositoryID: repository.id, - name: "pending", - detail: "" + progress: WorktreeCreationProgress(stage: .choosingWorktreeName) ), ] initialState.pinnedWorktreeIDs = [removedWorktree.id] @@ -721,8 +811,7 @@ struct RepositoriesFeatureTests { PendingWorktree( id: pendingID, repositoryID: repository.id, - name: "Creating worktree...", - detail: "" + progress: WorktreeCreationProgress(stage: .loadingLocalBranches) ), ] initialState.selection = .worktree(pendingID) diff --git a/supacodeTests/WorktreeCreationProgressTests.swift b/supacodeTests/WorktreeCreationProgressTests.swift new file mode 100644 index 00000000..9e1e5b7d --- /dev/null +++ b/supacodeTests/WorktreeCreationProgressTests.swift @@ -0,0 +1,31 @@ +import Testing + +@testable import supacode + +struct WorktreeCreationProgressTests { + @Test func resolvingBaseReferenceUsesHeadFallback() { + let progress = WorktreeCreationProgress( + stage: .resolvingBaseReference, + worktreeName: "swift-otter" + ) + + #expect(progress.titleText == "Creating swift-otter") + #expect(progress.detailText == "Resolving base reference (HEAD)") + } + + @Test func creatingWorktreeIncludesBaseRefAndCopyFlags() { + let progress = WorktreeCreationProgress( + stage: .creatingWorktree, + worktreeName: "swift-otter", + baseRef: "origin/main", + copyIgnored: true, + copyUntracked: false + ) + + #expect(progress.titleText == "Creating swift-otter") + #expect( + progress.detailText + == "Creating from origin/main (copy ignored: on, copy untracked: off)" + ) + } +} -- 2.51.2