diff --git a/supacode/Domain/ArchiveScriptProgress.swift b/supacode/Domain/ArchiveScriptProgress.swift new file mode 100644 index 00000000..c80a5e00 --- /dev/null +++ b/supacode/Domain/ArchiveScriptProgress.swift @@ -0,0 +1,25 @@ +import Foundation + +nonisolated struct ArchiveScriptProgress: Hashable, Sendable { + var titleText: String + var detailText: String + var outputLines: [String] + + init( + titleText: String, + detailText: String, + outputLines: [String] = [] + ) { + self.titleText = titleText + self.detailText = detailText + self.outputLines = outputLines + } + + mutating func appendOutputLine(_ line: String, maxLines: Int) { + detailText = line + outputLines.append(line) + if outputLines.count > maxLines { + outputLines.removeFirst(outputLines.count - maxLines) + } + } +} diff --git a/supacode/Domain/WorktreeLoadingState.swift b/supacode/Domain/WorktreeLoadingState.swift index 18bb112d..5079c6c4 100644 --- a/supacode/Domain/WorktreeLoadingState.swift +++ b/supacode/Domain/WorktreeLoadingState.swift @@ -1,4 +1,5 @@ enum WorktreeLoadingState { case creating + case archiving case removing } diff --git a/supacode/Domain/WorktreeRowModel.swift b/supacode/Domain/WorktreeRowModel.swift index 10ed5444..c0b5270c 100644 --- a/supacode/Domain/WorktreeRowModel.swift +++ b/supacode/Domain/WorktreeRowModel.swift @@ -9,6 +9,7 @@ struct WorktreeRowModel: Identifiable, Hashable { let isPinned: Bool let isMainWorktree: Bool let isPending: Bool + let isArchiving: Bool let isDeleting: Bool let isRemovable: Bool } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 569ec2f1..eb350ae3 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -20,6 +20,7 @@ private enum CancelID { private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) private nonisolated let worktreeCreationProgressLineLimit = 200 private nonisolated let worktreeCreationProgressUpdateStride = 20 +private nonisolated let archiveScriptProgressLineLimit = 200 nonisolated struct WorktreeCreationProgressUpdateThrottle { private let stride: Int @@ -70,6 +71,8 @@ struct RepositoriesFeature { var pendingWorktrees: [PendingWorktree] = [] var pendingSetupScriptWorktreeIDs: Set = [] var pendingTerminalFocusWorktreeIDs: Set = [] + var archivingWorktreeIDs: Set = [] + var archiveScriptProgressByWorktreeID: [Worktree.ID: ArchiveScriptProgress] = [:] var deletingWorktreeIDs: Set = [] var removingRepositoryIDs: Set = [] var pinnedWorktreeIDs: [Worktree.ID] = [] @@ -181,6 +184,10 @@ struct RepositoriesFeature { case requestArchiveWorktree(Worktree.ID, Repository.ID) case requestArchiveWorktrees([ArchiveWorktreeTarget]) case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) + case archiveScriptProgressUpdated(worktreeID: Worktree.ID, progress: ArchiveScriptProgress) + case archiveScriptSucceeded(worktreeID: Worktree.ID, repositoryID: Repository.ID) + case archiveScriptFailed(worktreeID: Worktree.ID, message: String) + case archiveWorktreeApply(Worktree.ID, Repository.ID) case unarchiveWorktree(Worktree.ID) case requestDeleteWorktree(Worktree.ID, Repository.ID) case requestDeleteWorktrees([DeleteWorktreeTarget]) @@ -284,6 +291,7 @@ struct RepositoriesFeature { @Dependency(GithubCLIClient.self) private var githubCLI @Dependency(GithubIntegrationClient.self) private var githubIntegration @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence + @Dependency(ShellClient.self) private var shellClient @Dependency(\.uuid) private var uuid var body: some Reducer { @@ -1148,6 +1156,9 @@ struct RepositoriesFeature { if state.deletingWorktreeIDs.contains(worktree.id) { return .none } + if state.archivingWorktreeIDs.contains(worktree.id) { + return .none + } if state.isWorktreeArchived(worktree.id) { return .none } @@ -1183,6 +1194,7 @@ struct RepositoriesFeature { } if state.isMainWorktree(worktree) || state.deletingWorktreeIDs.contains(worktree.id) + || state.archivingWorktreeIDs.contains(worktree.id) || state.isWorktreeArchived(worktree.id) { continue @@ -1221,6 +1233,75 @@ struct RepositoriesFeature { ) case .archiveWorktreeConfirmed(let worktreeID, let repositoryID): + guard let repository = state.repositories[id: repositoryID], + let worktree = repository.worktrees[id: worktreeID] + else { + return .none + } + if state.isWorktreeArchived(worktreeID) || state.archivingWorktreeIDs.contains(worktreeID) { + state.alert = nil + return .none + } + state.alert = nil + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + let script = repositorySettings.archiveScript + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return .send(.archiveWorktreeApply(worktreeID, repositoryID)) + } + state.archivingWorktreeIDs.insert(worktreeID) + state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "Preparing archive script" + ) + let shellClient = shellClient + return .run { send in + let envURL = URL(fileURLWithPath: "/usr/bin/env") + var progress = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "Running archive script" + ) + do { + for try await event in shellClient.runLoginStream( + envURL, + ["bash", "-lc", script], + worktree.workingDirectory, + log: false + ) { + switch event { + case .line(let line): + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) + await send(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress)) + case .finished: + await send(.archiveScriptSucceeded(worktreeID: worktreeID, repositoryID: repositoryID)) + } + } + } catch { + await send(.archiveScriptFailed(worktreeID: worktreeID, message: error.localizedDescription)) + } + } + + case .archiveScriptProgressUpdated(let worktreeID, let progress): + guard state.archivingWorktreeIDs.contains(worktreeID) else { + return .none + } + state.archiveScriptProgressByWorktreeID[worktreeID] = progress + return .none + + case .archiveScriptSucceeded(let worktreeID, let repositoryID): + state.archivingWorktreeIDs.remove(worktreeID) + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) + return .send(.archiveWorktreeApply(worktreeID, repositoryID)) + + case .archiveScriptFailed(let worktreeID, let message): + state.archivingWorktreeIDs.remove(worktreeID) + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) + state.alert = messageAlert(title: "Archive script failed", message: message) + return .none + + case .archiveWorktreeApply(let worktreeID, let repositoryID): guard let repository = state.repositories[id: repositoryID], let worktree = repository.worktrees[id: worktreeID] else { @@ -1325,6 +1406,9 @@ struct RepositoriesFeature { ) return .none } + if state.archivingWorktreeIDs.contains(worktree.id) { + return .none + } if state.deletingWorktreeIDs.contains(worktree.id) { return .none } @@ -1361,7 +1445,10 @@ struct RepositoriesFeature { else { continue } - if state.isMainWorktree(worktree) || state.deletingWorktreeIDs.contains(worktree.id) { + if state.isMainWorktree(worktree) + || state.deletingWorktreeIDs.contains(worktree.id) + || state.archivingWorktreeIDs.contains(worktree.id) + { continue } validTargets.append(target) @@ -1406,6 +1493,9 @@ struct RepositoriesFeature { else { return .none } + if state.archivingWorktreeIDs.contains(worktree.id) { + return .none + } if state.deletingWorktreeIDs.contains(worktree.id) { return .none } @@ -1451,9 +1541,11 @@ struct RepositoriesFeature { let wasArchived = state.isWorktreeArchived(worktreeID) withAnimation(.easeOut(duration: 0.2)) { state.deletingWorktreeIDs.remove(worktreeID) + state.archivingWorktreeIDs.remove(worktreeID) state.pendingWorktrees.removeAll { $0.id == worktreeID } state.pendingSetupScriptWorktreeIDs.remove(worktreeID) state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) state.worktreeInfoByID.removeValue(forKey: worktreeID) state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } state.archivedWorktreeIDs.removeAll { $0 == worktreeID } @@ -2504,6 +2596,10 @@ struct RepositoriesFeature { let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { availableWorktreeIDs.contains($0) } + let filteredArchivingIDs = state.archivingWorktreeIDs.intersection(availableWorktreeIDs) + let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { + availableWorktreeIDs.contains($0.key) + } let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) } @@ -2515,6 +2611,8 @@ struct RepositoriesFeature { state.deletingWorktreeIDs = filteredDeletingIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.archivingWorktreeIDs = filteredArchivingIDs + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress state.worktreeInfoByID = filteredWorktreeInfo } } else { @@ -2523,6 +2621,8 @@ struct RepositoriesFeature { state.deletingWorktreeIDs = filteredDeletingIDs state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs + state.archivingWorktreeIDs = filteredArchivingIDs + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress state.worktreeInfoByID = filteredWorktreeInfo } let didPrunePinned = prunePinnedWorktreeIDs(state: &state) @@ -2689,6 +2789,11 @@ extension RepositoriesFeature.State { return pendingWorktrees.first(where: { $0.id == id }) } + func archiveScriptProgress(for id: Worktree.ID?) -> ArchiveScriptProgress? { + guard let id else { return nil } + return archiveScriptProgressByWorktreeID[id] + } + func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { pendingTerminalFocusWorktreeIDs.contains(worktreeID) } @@ -2704,6 +2809,7 @@ extension RepositoriesFeature.State { isPinned: false, isMainWorktree: false, isPending: true, + isArchiving: false, isDeleting: isDeleting, isRemovable: false ) @@ -2718,6 +2824,7 @@ extension RepositoriesFeature.State { let isDeleting = removingRepositoryIDs.contains(repositoryID) || deletingWorktreeIDs.contains(worktree.id) + let isArchiving = archivingWorktreeIDs.contains(worktree.id) return WorktreeRowModel( id: worktree.id, repositoryID: repositoryID, @@ -2727,8 +2834,9 @@ extension RepositoriesFeature.State { isPinned: isPinned, isMainWorktree: isMainWorktree, isPending: false, + isArchiving: isArchiving, isDeleting: isDeleting, - isRemovable: !isDeleting + isRemovable: !isDeleting && !isArchiving ) } @@ -3131,6 +3239,8 @@ private func cleanupWorktreeState( state.pendingWorktrees.removeAll { $0.id == worktreeID } state.pendingSetupScriptWorktreeIDs.remove(worktreeID) state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) + state.archivingWorktreeIDs.remove(worktreeID) + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) state.deletingWorktreeIDs.remove(worktreeID) state.worktreeInfoByID.removeValue(forKey: worktreeID) let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index d1c336aa..9a29205d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -392,6 +392,17 @@ struct WorktreeDetailView: View { statusLines: [] ) } + if selectedRow.isArchiving { + let progress = repositories.archiveScriptProgress(for: selectedWorktreeID) + return WorktreeLoadingInfo( + name: selectedRow.name, + repositoryName: repositoryName, + state: .archiving, + statusTitle: progress?.titleText ?? selectedRow.name, + statusDetail: progress?.detailText ?? selectedRow.detail, + statusLines: progress?.outputLines ?? [] + ) + } if selectedRow.isPending { let pending = repositories.pendingWorktree(for: selectedWorktreeID) let progress = pending?.progress diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index 05a60c23..4fb6fcb2 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -6,7 +6,15 @@ struct WorktreeLoadingView: View { private let bottomAnchorID = "worktree-loading-bottom" var body: some View { - let actionLabel = info.state == .creating ? "Creating" : "Removing" + let actionLabel: String + switch info.state { + case .creating: + actionLabel = "Creating" + case .archiving: + actionLabel = "Archiving" + case .removing: + actionLabel = "Removing" + } let fallbackStatus = if let repositoryName = info.repositoryName { "\(actionLabel) worktree in \(repositoryName)" diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index ad2ed419..6366ec3a 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -58,7 +58,7 @@ struct WorktreeRowsView: View { rowView( row, isRepositoryRemoving: isRepositoryRemoving, - moveDisabled: isRepositoryRemoving || row.isDeleting, + moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil ) } @@ -77,7 +77,7 @@ struct WorktreeRowsView: View { rowView( row, isRepositoryRemoving: isRepositoryRemoving, - moveDisabled: isRepositoryRemoving || row.isDeleting, + moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil ) } @@ -94,7 +94,14 @@ struct WorktreeRowsView: View { shortcutHint: String? ) -> some View { let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) - let displayName = row.isDeleting ? "\(row.name) (deleting...)" : row.name + let displayName = + if row.isDeleting { + "\(row.name) (deleting...)" + } else if row.isArchiving { + "\(row.name) (archiving...)" + } else { + row.name + } let canShowRowActions = row.isRemovable && !isRepositoryRemoving let pinAction: (() -> Void)? = canShowRowActions && !row.isMainWorktree @@ -189,7 +196,7 @@ struct WorktreeRowsView: View { isHovered: config.isHovered, isPinned: row.isPinned, isMainWorktree: row.isMainWorktree, - isLoading: row.isPending || row.isDeleting, + isLoading: row.isPending || row.isArchiving || row.isDeleting, taskStatus: taskStatus, isRunScriptRunning: isRunScriptRunning, showsNotificationIndicator: config.showsNotificationIndicator, diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index f5854fbb..3b01161d 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -2,6 +2,7 @@ import Foundation nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var setupScript: String + var archiveScript: String var runScript: String var openActionID: String var worktreeBaseRef: String? @@ -11,6 +12,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case setupScript + case archiveScript case runScript case openActionID case worktreeBaseRef @@ -21,6 +23,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { static let `default` = RepositorySettings( setupScript: "", + archiveScript: "", runScript: "", openActionID: OpenWorktreeAction.automaticSettingsID, worktreeBaseRef: nil, @@ -31,6 +34,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { init( setupScript: String, + archiveScript: String, runScript: String, openActionID: String, worktreeBaseRef: String?, @@ -39,6 +43,7 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { pullRequestMergeStrategy: PullRequestMergeStrategy ) { self.setupScript = setupScript + self.archiveScript = archiveScript self.runScript = runScript self.openActionID = openActionID self.worktreeBaseRef = worktreeBaseRef @@ -52,6 +57,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { setupScript = try container.decodeIfPresent(String.self, forKey: .setupScript) ?? Self.default.setupScript + archiveScript = + try container.decodeIfPresent(String.self, forKey: .archiveScript) + ?? Self.default.archiveScript runScript = try container.decodeIfPresent(String.self, forKey: .runScript) ?? Self.default.runScript diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index d332a356..22460a84 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -114,6 +114,28 @@ struct RepositorySettingsView: View { .foregroundStyle(.secondary) } } + Section { + ZStack(alignment: .topLeading) { + TextEditor( + text: settings.archiveScript + ) + .font(.body) + .frame(minHeight: 120) + if store.settings.archiveScript.isEmpty { + Text("docker compose down") + .foregroundStyle(.secondary) + .padding(.leading, 6) + .font(.body) + .allowsHitTesting(false) + } + } + } header: { + VStack(alignment: .leading, spacing: 4) { + Text("Archive Script") + Text("Archive script that runs before a worktree is archived") + .foregroundStyle(.secondary) + } + } Section { ZStack(alignment: .topLeading) { TextEditor( diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index fd5ae92d..620cd779 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -769,6 +769,120 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.selectedWorktreeChanged) } + @Test(.dependencies) func archiveWorktreeConfirmedRunsArchiveScriptAndShowsProgress() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var state = makeState(repositories: [repository]) + state.selection = .worktree(mainWorktree.id) + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + $repositorySettings.withLock { + $0.archiveScript = "echo syncing\necho done" + } + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in + AsyncThrowingStream { continuation in + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) + continuation.yield(.finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) + continuation.finish() + } + } + } + + await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { + $0.archivingWorktreeIDs = [featureWorktree.id] + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "Preparing archive script" + ) + } + await store.receive(\.archiveScriptProgressUpdated) { + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "syncing", + outputLines: ["syncing"] + ) + } + await store.receive(\.archiveScriptProgressUpdated) { + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "done", + outputLines: ["syncing", "done"] + ) + } + await store.receive(\.archiveScriptSucceeded) { + $0.archivingWorktreeIDs = [] + $0.archiveScriptProgressByWorktreeID = [:] + } + await store.receive(\.archiveWorktreeApply) { + $0.archivedWorktreeIDs = [featureWorktree.id] + } + await store.receive(\.delegate.repositoriesChanged) + } + + @Test(.dependencies) func archiveWorktreeConfirmedScriptFailureBlocksArchive() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + $repositorySettings.withLock { + $0.archiveScript = "exit 7" + } + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in + AsyncThrowingStream { continuation in + continuation.finish( + throwing: ShellClientError( + command: "bash -lc exit 7", + stdout: "", + stderr: "fail", + exitCode: 7 + ) + ) + } + } + } + + let expectedAlert = AlertState { + TextState("Archive script failed") + } actions: { + ButtonState(role: .cancel) { + TextState("OK") + } + } message: { + TextState("Command failed: bash -lc exit 7\nstderr:\nfail") + } + + await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { + $0.archivingWorktreeIDs = [featureWorktree.id] + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "Preparing archive script" + ) + } + await store.receive(\.archiveScriptFailed) { + $0.archivingWorktreeIDs = [] + $0.archiveScriptProgressByWorktreeID = [:] + $0.alert = expectedAlert + } + #expect(store.state.archivedWorktreeIDs.isEmpty) + } + @Test func requestRenameBranchWithEmptyNameShowsAlert() async { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) diff --git a/supacodeTests/RepositorySettingsKeyTests.swift b/supacodeTests/RepositorySettingsKeyTests.swift index f6bfaf59..1bde0207 100644 --- a/supacodeTests/RepositorySettingsKeyTests.swift +++ b/supacodeTests/RepositorySettingsKeyTests.swift @@ -63,4 +63,19 @@ struct RepositorySettingsKeyTests { #expect(reloaded.repositories[rootURL.path(percentEncoded: false)] == settings) } + + @Test func decodeMissingArchiveScriptDefaultsToEmpty() throws { + let data = Data( + """ + { + "setupScript": "echo setup", + "runScript": "echo run", + "openActionID": "automatic" + } + """.utf8 + ) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + + #expect(settings.archiveScript.isEmpty) + } } -- 2.51.2 From d8dcbaeeedf07064d0969779de610a0cf531ec65 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:59:52 +0700 Subject: [PATCH 2/8] Fix loading view compile error for archiving state --- .../Views/WorktreeLoadingView.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index 4fb6fcb2..4ec7fca1 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -6,15 +6,14 @@ struct WorktreeLoadingView: View { private let bottomAnchorID = "worktree-loading-bottom" var body: some View { - let actionLabel: String - switch info.state { - case .creating: - actionLabel = "Creating" - case .archiving: - actionLabel = "Archiving" - case .removing: - actionLabel = "Removing" - } + let actionLabel = + if info.state == .creating { + "Creating" + } else if info.state == .archiving { + "Archiving" + } else { + "Removing" + } let fallbackStatus = if let repositoryName = info.repositoryName { "\(actionLabel) worktree in \(repositoryName)" -- 2.51.2 From 833e2af45c0ec6a7ac6a2423515587705a6256b7 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:09:11 +0700 Subject: [PATCH 3/8] Fix archive flow test expectations --- supacodeTests/RepositoriesFeatureTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 620cd779..403d7c1a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -759,7 +759,8 @@ struct RepositoriesFeatureTests { } await store.send(.requestArchiveWorktree(featureWorktree.id, repository.id)) - await store.receive(\.archiveWorktreeConfirmed) { + await store.receive(\.archiveWorktreeConfirmed) + await store.receive(\.archiveWorktreeApply) { $0.archivedWorktreeIDs = [featureWorktree.id] $0.pinnedWorktreeIDs = [] $0.worktreeOrderByRepository = [:] @@ -1464,7 +1465,8 @@ struct RepositoriesFeatureTests { pullRequest: mergedPullRequest ) } - await store.receive(\.archiveWorktreeConfirmed) { + await store.receive(\.archiveWorktreeConfirmed) + await store.receive(\.archiveWorktreeApply) { $0.archivedWorktreeIDs = [featureWorktree.id] } await store.receive(\.delegate.repositoriesChanged) -- 2.51.2 From 70d0532e31a86a0ba2d3b72c8f90ff58ca44fb06 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:27:46 +0700 Subject: [PATCH 4/8] Ignore stale archive script completions --- .../Reducer/RepositoriesFeature.swift | 10 ++++++ supacodeTests/RepositoriesFeatureTests.swift | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index eb350ae3..7abf74f0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -12,6 +12,9 @@ private enum CancelID { static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" + static func archiveScript(_ worktreeID: Worktree.ID) -> String { + "repositories.archiveScript.\(worktreeID)" + } static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { "repositories.delayedPRRefresh.\(worktreeID)" } @@ -1282,6 +1285,7 @@ struct RepositoriesFeature { await send(.archiveScriptFailed(worktreeID: worktreeID, message: error.localizedDescription)) } } + .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true) case .archiveScriptProgressUpdated(let worktreeID, let progress): guard state.archivingWorktreeIDs.contains(worktreeID) else { @@ -1291,11 +1295,17 @@ struct RepositoriesFeature { return .none case .archiveScriptSucceeded(let worktreeID, let repositoryID): + guard state.archivingWorktreeIDs.contains(worktreeID) else { + return .none + } state.archivingWorktreeIDs.remove(worktreeID) state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) return .send(.archiveWorktreeApply(worktreeID, repositoryID)) case .archiveScriptFailed(let worktreeID, let message): + guard state.archivingWorktreeIDs.contains(worktreeID) else { + return .none + } state.archivingWorktreeIDs.remove(worktreeID) state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) state.alert = messageAlert(title: "Archive script failed", message: message) diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 403d7c1a..0a568e44 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -884,6 +884,41 @@ struct RepositoriesFeatureTests { #expect(store.state.archivedWorktreeIDs.isEmpty) } + @Test func archiveScriptSucceededIgnoredWhenNotArchiving() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + #expect(store.state.archivedWorktreeIDs.isEmpty) + } + + @Test func archiveScriptFailedIgnoredWhenNotArchiving() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } + + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure")) + #expect(store.state.alert == nil) + #expect(store.state.archivedWorktreeIDs.isEmpty) + } + @Test func requestRenameBranchWithEmptyNameShowsAlert() async { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) -- 2.51.2 From 979cfd58680521e1a79e9cdfa54e20138690daf7 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:51:32 +0700 Subject: [PATCH 5/8] Preserve archive in-flight state across reload --- .../Reducer/RepositoriesFeature.swift | 59 ++++++++++++- supacodeTests/RepositoriesFeatureTests.swift | 83 ++++++++++++++++++- 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 316832c0..51a8ee17 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -970,6 +970,13 @@ struct RepositoriesFeature { progress.untrackedFilesToCopyCount = copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 progress.stage = .creatingWorktree + progress.commandText = worktreeCreateCommand( + repositoryRootURL: repository.rootURL, + name: name, + copyIgnored: copyIgnored, + copyUntracked: copyUntracked, + baseRef: resolvedBaseRef + ) await send( .pendingWorktreeProgressUpdated( id: pendingID, @@ -1249,6 +1256,7 @@ struct RepositoriesFeature { state.alert = nil @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings let script = repositorySettings.archiveScript + let commandText = archiveScriptCommand(script) let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return .send(.archiveWorktreeApply(worktreeID, repositoryID)) @@ -1256,14 +1264,16 @@ struct RepositoriesFeature { state.archivingWorktreeIDs.insert(worktreeID) state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( titleText: "Running archive script", - detailText: "Preparing archive script" + detailText: "Preparing archive script", + commandText: commandText ) let shellClient = shellClient return .run { send in let envURL = URL(fileURLWithPath: "/usr/bin/env") var progress = ArchiveScriptProgress( titleText: "Running archive script", - detailText: "Running archive script" + detailText: "Running archive script", + commandText: commandText ) do { for try await event in shellClient.runLoginStream( @@ -2607,9 +2617,9 @@ struct RepositoriesFeature { let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { availableWorktreeIDs.contains($0) } - let filteredArchivingIDs = state.archivingWorktreeIDs.intersection(availableWorktreeIDs) + let filteredArchivingIDs = state.archivingWorktreeIDs let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { - availableWorktreeIDs.contains($0.key) + availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) } let filteredWorktreeInfo = state.worktreeInfoByID.filter { availableWorktreeIDs.contains($0.key) @@ -3285,6 +3295,47 @@ private func cleanupWorktreeState( ) } +private func archiveScriptCommand(_ script: String) -> String { + let normalized = script.replacing("\n", with: "\\n") + return "bash -lc \(shellQuote(normalized))" +} + +private func worktreeCreateCommand( + repositoryRootURL: URL, + name: String, + copyIgnored: Bool, + copyUntracked: Bool, + baseRef: String +) -> String { + let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL).path(percentEncoded: false) + var parts = ["wt", "--base-dir", baseDir, "sw"] + if copyIgnored { + parts.append("--copy-ignored") + } + if copyUntracked { + parts.append("--copy-untracked") + } + if !baseRef.isEmpty { + parts.append("--from") + parts.append(baseRef) + } + if copyIgnored || copyUntracked { + parts.append("--verbose") + } + parts.append(name) + return parts.map(shellQuote).joined(separator: " ") +} + +private func shellQuote(_ value: String) -> String { + let needsQuoting = value.contains { character in + character.isWhitespace || character == "\"" || character == "'" || character == "\\" + } + guard needsQuoting else { + return value + } + return "'\(value.replacing("'", with: "'\"'\"'"))'" +} + private func updateWorktreeName( _ worktreeID: Worktree.ID, name: String, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 960d28be..8b83e981 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -802,13 +802,15 @@ struct RepositoriesFeatureTests { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", - detailText: "Preparing archive script" + detailText: "Preparing archive script", + commandText: "bash -lc 'echo syncing\\necho done'" ) } await store.receive(\.archiveScriptProgressUpdated) { $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "syncing", + commandText: "bash -lc 'echo syncing\\necho done'", outputLines: ["syncing"] ) } @@ -816,6 +818,7 @@ struct RepositoriesFeatureTests { $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", detailText: "done", + commandText: "bash -lc 'echo syncing\\necho done'", outputLines: ["syncing", "done"] ) } @@ -873,7 +876,8 @@ struct RepositoriesFeatureTests { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", - detailText: "Preparing archive script" + detailText: "Preparing archive script", + commandText: "bash -lc 'exit 7'" ) } await store.receive(\.archiveScriptFailed) { @@ -919,6 +923,81 @@ struct RepositoriesFeatureTests { #expect(store.state.archivedWorktreeIDs.isEmpty) } + @Test func repositoriesLoadedKeepsArchiveInFlightUntilSuccessCompletion() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) + var state = makeState(repositories: [repository]) + state.archivingWorktreeIDs = [featureWorktree.id] + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "still running" + ) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + store.exhaustivity = .off + + await store.send( + .repositoriesLoaded( + [reloadedRepository], + failures: [], + roots: [repository.rootURL], + animated: false + ) + ) + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) + + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + #expect(store.state.archivingWorktreeIDs.isEmpty) + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) + } + + @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) + var state = makeState(repositories: [repository]) + state.archivingWorktreeIDs = [featureWorktree.id] + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( + titleText: "Running archive script", + detailText: "still running" + ) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + store.exhaustivity = .off + + await store.send( + .repositoriesLoaded( + [reloadedRepository], + failures: [], + roots: [repository.rootURL], + animated: false + ) + ) + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) + + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed")) + #expect(store.state.archivingWorktreeIDs.isEmpty) + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) + #expect(store.state.alert != nil) + } + @Test func requestRenameBranchWithEmptyNameShowsAlert() async { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) -- 2.51.2 From efe5a41d5d5230670bbdfea7cbdc9a4c6f95accf Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:29:19 +0700 Subject: [PATCH 6/8] Fix worktree command helpers actor isolation --- .../Features/Repositories/Reducer/RepositoriesFeature.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 51a8ee17..c0410c58 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -3295,12 +3295,12 @@ private func cleanupWorktreeState( ) } -private func archiveScriptCommand(_ script: String) -> String { +private nonisolated func archiveScriptCommand(_ script: String) -> String { let normalized = script.replacing("\n", with: "\\n") return "bash -lc \(shellQuote(normalized))" } -private func worktreeCreateCommand( +private nonisolated func worktreeCreateCommand( repositoryRootURL: URL, name: String, copyIgnored: Bool, @@ -3326,7 +3326,7 @@ private func worktreeCreateCommand( return parts.map(shellQuote).joined(separator: " ") } -private func shellQuote(_ value: String) -> String { +private nonisolated func shellQuote(_ value: String) -> String { let needsQuoting = value.contains { character in character.isWhitespace || character == "\"" || character == "'" || character == "\\" } -- 2.51.2 From f7c9415b58c9ee73a53a79f4f8bc02dff888b476 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:46:24 +0700 Subject: [PATCH 7/8] Remove missing worktree progress command field usage --- .../Repositories/Reducer/RepositoriesFeature.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index c0410c58..1edbb5d1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -970,13 +970,6 @@ struct RepositoriesFeature { progress.untrackedFilesToCopyCount = copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 progress.stage = .creatingWorktree - progress.commandText = worktreeCreateCommand( - repositoryRootURL: repository.rootURL, - name: name, - copyIgnored: copyIgnored, - copyUntracked: copyUntracked, - baseRef: resolvedBaseRef - ) await send( .pendingWorktreeProgressUpdated( id: pendingID, -- 2.51.2 From 526a503492ca8aaec44dfbd3e869ebb8cd015d97 Mon Sep 17 00:00:00 2001 From: khoi <6994441+khoi@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:51:46 +0700 Subject: [PATCH 8/8] Restore command text in worktree progress UI --- supacode/Domain/ArchiveScriptProgress.swift | 3 +++ supacode/Domain/WorktreeCreationProgress.swift | 3 +++ supacode/Domain/WorktreeLoadingInfo.swift | 1 + .../Reducer/RepositoriesFeature.swift | 7 +++++++ .../Repositories/Views/WorktreeDetailView.swift | 3 +++ .../Repositories/Views/WorktreeLoadingView.swift | 16 ++++++++++++++++ 6 files changed, 33 insertions(+) diff --git a/supacode/Domain/ArchiveScriptProgress.swift b/supacode/Domain/ArchiveScriptProgress.swift index c80a5e00..ecb2ffbd 100644 --- a/supacode/Domain/ArchiveScriptProgress.swift +++ b/supacode/Domain/ArchiveScriptProgress.swift @@ -3,15 +3,18 @@ import Foundation nonisolated struct ArchiveScriptProgress: Hashable, Sendable { var titleText: String var detailText: String + var commandText: String? var outputLines: [String] init( titleText: String, detailText: String, + commandText: String? = nil, outputLines: [String] = [] ) { self.titleText = titleText self.detailText = detailText + self.commandText = commandText self.outputLines = outputLines } diff --git a/supacode/Domain/WorktreeCreationProgress.swift b/supacode/Domain/WorktreeCreationProgress.swift index 130d70ac..64a62edf 100644 --- a/supacode/Domain/WorktreeCreationProgress.swift +++ b/supacode/Domain/WorktreeCreationProgress.swift @@ -6,6 +6,7 @@ nonisolated struct WorktreeCreationProgress: Hashable, Sendable { var copyUntracked: Bool? var ignoredFilesToCopyCount: Int? var untrackedFilesToCopyCount: Int? + var commandText: String? var latestOutputLine: String? var outputLines: [String] @@ -17,6 +18,7 @@ nonisolated struct WorktreeCreationProgress: Hashable, Sendable { copyUntracked: Bool? = nil, ignoredFilesToCopyCount: Int? = nil, untrackedFilesToCopyCount: Int? = nil, + commandText: String? = nil, latestOutputLine: String? = nil, outputLines: [String] = [] ) { @@ -27,6 +29,7 @@ nonisolated struct WorktreeCreationProgress: Hashable, Sendable { self.copyUntracked = copyUntracked self.ignoredFilesToCopyCount = ignoredFilesToCopyCount self.untrackedFilesToCopyCount = untrackedFilesToCopyCount + self.commandText = commandText self.latestOutputLine = latestOutputLine self.outputLines = outputLines } diff --git a/supacode/Domain/WorktreeLoadingInfo.swift b/supacode/Domain/WorktreeLoadingInfo.swift index 1670f269..779e9d92 100644 --- a/supacode/Domain/WorktreeLoadingInfo.swift +++ b/supacode/Domain/WorktreeLoadingInfo.swift @@ -4,5 +4,6 @@ struct WorktreeLoadingInfo: Hashable { let state: WorktreeLoadingState let statusTitle: String? let statusDetail: String? + let statusCommand: String? let statusLines: [String] } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 1edbb5d1..c0410c58 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -970,6 +970,13 @@ struct RepositoriesFeature { progress.untrackedFilesToCopyCount = copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 progress.stage = .creatingWorktree + progress.commandText = worktreeCreateCommand( + repositoryRootURL: repository.rootURL, + name: name, + copyIgnored: copyIgnored, + copyUntracked: copyUntracked, + baseRef: resolvedBaseRef + ) await send( .pendingWorktreeProgressUpdated( id: pendingID, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 9a29205d..327a3d98 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -389,6 +389,7 @@ struct WorktreeDetailView: View { state: .removing, statusTitle: nil, statusDetail: nil, + statusCommand: nil, statusLines: [] ) } @@ -400,6 +401,7 @@ struct WorktreeDetailView: View { state: .archiving, statusTitle: progress?.titleText ?? selectedRow.name, statusDetail: progress?.detailText ?? selectedRow.detail, + statusCommand: progress?.commandText, statusLines: progress?.outputLines ?? [] ) } @@ -413,6 +415,7 @@ struct WorktreeDetailView: View { state: .creating, statusTitle: progress?.titleText ?? selectedRow.name, statusDetail: progress?.detailText ?? selectedRow.detail, + statusCommand: progress?.commandText, statusLines: progress?.liveOutputLines ?? [] ) } diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index 4ec7fca1..62c44f22 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -21,6 +21,7 @@ struct WorktreeLoadingView: View { "\(actionLabel) worktree..." } let statusLine = info.statusDetail ?? info.statusTitle ?? fallbackStatus + let statusCommand = info.statusCommand VStack(spacing: 10) { ProgressView() Text(info.name) @@ -31,10 +32,25 @@ struct WorktreeLoadingView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + if let statusCommand { + Text(statusCommand) + .font(.caption) + .monospaced() + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } } else { ScrollViewReader { scrollProxy in ScrollView { VStack(alignment: .leading, spacing: 4) { + if let statusCommand { + Text(statusCommand) + .font(.caption) + .monospaced() + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } ForEach(Array(info.statusLines.enumerated()), id: \.offset) { _, line in Text(line) .font(.caption)