From 5bca63bf649353f80054965836784ac31f4a3b6b Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 24 Apr 2026 17:17:09 -0500 Subject: [PATCH] Add bulk delete for sessions in the sidebar Adds a Select-mode toggle to the session sidebar so multiple sessions can be checked and deleted in one confirmation. Mirrors the existing TicketBoardView selection pattern; iterates the existing per-session delete cascade serially so all worktree, branch, terminal, and store cleanup remains intact. Closes #191 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/CrowUI/DeleteSessionAlert.swift | 121 +++++++++ .../Sources/CrowUI/SessionListView.swift | 244 ++++++++++++++---- .../Tests/CrowUITests/CrowUITests.swift | 86 ++++++ 3 files changed, 396 insertions(+), 55 deletions(-) diff --git a/Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift b/Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift index 4c03376..e8228a4 100644 --- a/Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift +++ b/Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift @@ -89,5 +89,126 @@ enum DeleteSessionMessageBuilder { "\n\nThe worktree folders and branches above will be removed.\n\nThe main repo (\(mainCheckouts.map(\.branch).joined(separator: ", "))) will not be affected." } } + + /// Build a confirmation message summarising a bulk delete of several sessions. + /// `worktreesBySession` maps each session ID to its full worktree list. + static func buildBulkMessage( + sessions: [Session], + worktreesBySession: [UUID: [SessionWorktree]] + ) -> String { + let count = sessions.count + let sessionNoun = count == 1 ? "session" : "sessions" + + var realCount = 0 + var mainCount = 0 + for session in sessions { + let wts = worktreesBySession[session.id] ?? [] + for wt in wts { + if wt.isMainRepoCheckout { + mainCount += 1 + } else { + realCount += 1 + } + } + } + + if realCount == 0 && mainCount == 0 { + return "This will remove \(count) \(sessionNoun)." + } + + var parts: [String] = [] + parts.append("This will delete \(count) \(sessionNoun).") + + if realCount > 0 { + let worktreeNoun = realCount == 1 ? "worktree" : "worktrees" + parts.append("\(realCount) \(worktreeNoun) and matching git branches will be removed from disk.") + } + if mainCount > 0 { + let checkoutNoun = mainCount == 1 ? "main repo checkout" : "main repo checkouts" + parts.append("\(mainCount) \(checkoutNoun) will not be affected.") + } + return parts.joined(separator: "\n\n") + } +} + +// MARK: - Bulk Delete Sessions Alert + +/// View modifier that attaches a bulk delete-sessions confirmation alert. +/// Iterates `selectedIDs` serially through `appState.onDeleteSession`. +struct BulkDeleteSessionsAlert: ViewModifier { + @Binding var isPresented: Bool + let selectedIDs: Set + let appState: AppState + let onCompletion: () -> Void + + func body(content: Content) -> some View { + content.alert("Delete Sessions?", isPresented: $isPresented) { + Button("Cancel", role: .cancel) {} + Button(buttonLabel, role: .destructive) { + let snapshot = sortedSnapshot + Task { + for id in snapshot { + do { + try await appState.onDeleteSession?(id) + } catch { + NSLog("Failed to delete session \(id): \(error)") + } + } + await MainActor.run { onCompletion() } + } + } + } message: { + Text(messageText) + } + } + + private var sortedSnapshot: [UUID] { + // Stable order: sessions first in the order they currently appear in AppState. + let order = Dictionary(uniqueKeysWithValues: appState.sessions.enumerated().map { ($1.id, $0) }) + return selectedIDs.sorted { (order[$0] ?? .max) < (order[$1] ?? .max) } + } + + private var selectedSessions: [Session] { + appState.sessions.filter { selectedIDs.contains($0.id) } + } + + private var hasRealWorktrees: Bool { + selectedSessions.contains { session in + appState.worktrees(for: session.id).contains { !$0.isMainRepoCheckout } + } + } + + private var buttonLabel: String { + let base = DeleteSessionMessageBuilder.buttonLabel(hasRealWorktrees: hasRealWorktrees) + return "\(base) (\(selectedIDs.count))" + } + + private var messageText: String { + let sessions = selectedSessions + var map: [UUID: [SessionWorktree]] = [:] + for session in sessions { + map[session.id] = appState.worktrees(for: session.id) + } + return DeleteSessionMessageBuilder.buildBulkMessage( + sessions: sessions, + worktreesBySession: map + ) + } +} + +extension View { + func bulkDeleteSessionsAlert( + isPresented: Binding, + selectedIDs: Set, + appState: AppState, + onCompletion: @escaping () -> Void + ) -> some View { + modifier(BulkDeleteSessionsAlert( + isPresented: isPresented, + selectedIDs: selectedIDs, + appState: appState, + onCompletion: onCompletion + )) + } } diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 3f5fb33..cda0d2e 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -6,12 +6,50 @@ public struct SessionListView: View { @Bindable var appState: AppState @State private var searchText = "" @State private var sessionToDelete: Session? + @State private var isSelectionMode = false + @State private var selectedSessionIDs: Set = [] + @State private var showBulkDeleteConfirm = false public init(appState: AppState) { self.appState = appState } public var body: some View { + VStack(spacing: 0) { + sessionList + + if isSelectionMode && !selectedSessionIDs.isEmpty { + bulkActionBar + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + selectToggleButton + } + ToolbarItem(placement: .primaryAction) { + Button { + appState.soundMuted.toggle() + appState.onSoundMutedChanged?(appState.soundMuted) + } label: { + Image(systemName: appState.soundMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .foregroundStyle(appState.soundMuted ? CorveilTheme.textMuted : CorveilTheme.gold) + } + .help(appState.soundMuted ? "Unmute notifications" : "Mute notifications") + .accessibilityLabel(appState.soundMuted ? "Unmute notifications" : "Mute notifications") + } + } + .deleteSessionAlert(session: $sessionToDelete, appState: appState) + .bulkDeleteSessionsAlert( + isPresented: $showBulkDeleteConfirm, + selectedIDs: selectedSessionIDs, + appState: appState + ) { + selectedSessionIDs.removeAll() + isSelectionMode = false + } + } + + private var sessionList: some View { List(selection: $appState.selectedSessionID) { // Brandmark header SidebarBrandmark() @@ -40,13 +78,18 @@ public struct SessionListView: View { if !appState.activeSessions.isEmpty { SectionDivider(title: "Active") ForEach(filteredSessions(appState.activeSessions)) { session in - SessionRow(session: session, appState: appState) - .tag(session.id) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .contextMenu { - sessionContextMenu(session) - } + SessionRow( + session: session, + appState: appState, + isSelectionMode: isSelectionMode, + selectedSessionIDs: $selectedSessionIDs + ) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + sessionContextMenu(session) + } } } @@ -54,17 +97,22 @@ public struct SessionListView: View { if !appState.reviewSessions.isEmpty { SectionDivider(title: "Reviews") ForEach(filteredSessions(appState.reviewSessions)) { session in - SessionRow(session: session, appState: appState) - .tag(session.id) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .contextMenu { - Button(role: .destructive) { - sessionToDelete = session - } label: { - Label("Delete", systemImage: "trash") - } + SessionRow( + session: session, + appState: appState, + isSelectionMode: isSelectionMode, + selectedSessionIDs: $selectedSessionIDs + ) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + Button(role: .destructive) { + sessionToDelete = session + } label: { + Label("Delete", systemImage: "trash") } + } } } @@ -72,13 +120,18 @@ public struct SessionListView: View { if !appState.inReviewSessions.isEmpty { SectionDivider(title: "In Review") ForEach(filteredSessions(appState.inReviewSessions)) { session in - SessionRow(session: session, appState: appState) - .tag(session.id) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .contextMenu { - sessionContextMenu(session) - } + SessionRow( + session: session, + appState: appState, + isSelectionMode: isSelectionMode, + selectedSessionIDs: $selectedSessionIDs + ) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + sessionContextMenu(session) + } } } @@ -86,13 +139,18 @@ public struct SessionListView: View { if !appState.completedSessions.isEmpty { SectionDivider(title: "Completed") ForEach(filteredSessions(appState.completedSessions)) { session in - SessionRow(session: session, appState: appState) - .tag(session.id) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .contextMenu { - sessionContextMenu(session) - } + SessionRow( + session: session, + appState: appState, + isSelectionMode: isSelectionMode, + selectedSessionIDs: $selectedSessionIDs + ) + .tag(session.id) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .contextMenu { + sessionContextMenu(session) + } } } } @@ -100,20 +158,65 @@ public struct SessionListView: View { .scrollContentBackground(.hidden) .background(CorveilTheme.bgDeep) .searchable(text: $searchText, prompt: "Search sessions") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - appState.soundMuted.toggle() - appState.onSoundMutedChanged?(appState.soundMuted) - } label: { - Image(systemName: appState.soundMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .foregroundStyle(appState.soundMuted ? CorveilTheme.textMuted : CorveilTheme.gold) + } + + private var selectToggleButton: some View { + Button { + isSelectionMode.toggle() + if !isSelectionMode { + selectedSessionIDs.removeAll() + } + } label: { + Image(systemName: isSelectionMode ? "xmark.circle" : "checkmark.circle") + .foregroundStyle(isSelectionMode ? .red : CorveilTheme.gold) + } + .help(isSelectionMode ? "Cancel selection" : "Select sessions") + .accessibilityLabel(isSelectionMode ? "Cancel selection" : "Select sessions") + } + + private var bulkActionBar: some View { + HStack(spacing: 10) { + Text("\(selectedSessionIDs.count) selected") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CorveilTheme.textSecondary) + + Spacer() + + Button { + isSelectionMode = false + selectedSessionIDs.removeAll() + } label: { + Text("Cancel") + .font(.system(size: 12)) + .foregroundStyle(CorveilTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + } + .buttonStyle(.plain) + + Button { + showBulkDeleteConfirm = true + } label: { + HStack(spacing: 4) { + Image(systemName: "trash") + .font(.system(size: 10)) + Text("Delete (\(selectedSessionIDs.count))") + .font(.system(size: 12, weight: .semibold)) } - .help(appState.soundMuted ? "Unmute notifications" : "Mute notifications") - .accessibilityLabel(appState.soundMuted ? "Unmute notifications" : "Mute notifications") + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: 6)) } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(CorveilTheme.bgSurface) + .overlay(alignment: .top) { + Divider().overlay(CorveilTheme.borderSubtle) } - .deleteSessionAlert(session: $sessionToDelete, appState: appState) } @ViewBuilder @@ -243,11 +346,26 @@ struct ManagerAllowListRow: View { struct SessionRow: View { let session: Session let appState: AppState + var isSelectionMode: Bool = false + var selectedSessionIDs: Binding>? = nil private var primaryWorktree: SessionWorktree? { appState.primaryWorktree(for: session.id) } + private var isChecked: Bool { + selectedSessionIDs?.wrappedValue.contains(session.id) ?? false + } + + private func toggleSelection() { + guard let binding = selectedSessionIDs else { return } + if binding.wrappedValue.contains(session.id) { + binding.wrappedValue.remove(session.id) + } else { + binding.wrappedValue.insert(session.id) + } + } + private var prLink: SessionLink? { appState.links(for: session.id).first(where: { $0.linkType == .pr }) } @@ -268,6 +386,35 @@ struct SessionRow: View { } var body: some View { + HStack(spacing: 8) { + if isSelectionMode { + Button(action: toggleSelection) { + Image(systemName: isChecked ? "checkmark.circle.fill" : "circle") + .font(.system(size: 16)) + .foregroundStyle(isChecked ? CorveilTheme.gold : CorveilTheme.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(isChecked ? "Deselect \(session.name)" : "Select \(session.name)") + } + + rowContent + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(rowBackgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(rowBorderColor, lineWidth: 1) + ) + ) + .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: needsAttention) + .animation(.easeInOut(duration: 0.2), value: appState.hideSessionDetails) + .padding(.vertical, 1) + } + + private var rowContent: some View { VStack(alignment: .leading, spacing: 3) { // Row 1: Name + status indicator HStack(spacing: 4) { @@ -315,19 +462,6 @@ struct SessionRow: View { } } } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(rowBackgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(rowBorderColor, lineWidth: 1) - ) - ) - .animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: needsAttention) - .animation(.easeInOut(duration: 0.2), value: appState.hideSessionDetails) - .padding(.vertical, 1) } @ViewBuilder diff --git a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift index df618ed..6ca04d0 100644 --- a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift +++ b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift @@ -145,3 +145,89 @@ private func makeWorktree( #expect(DeleteSessionMessageBuilder.buttonLabel(hasRealWorktrees: true) == "Delete Everything") #expect(DeleteSessionMessageBuilder.buttonLabel(hasRealWorktrees: false) == "Remove Session") } + +// MARK: - Bulk Delete Message Logic + +@Test func bulkMessageForSessionsWithoutWorktrees() { + let sessions = [ + Session(name: "alpha"), + Session(name: "bravo"), + Session(name: "charlie") + ] + let text = DeleteSessionMessageBuilder.buildBulkMessage( + sessions: sessions, + worktreesBySession: [:] + ) + #expect(text == "This will remove 3 sessions.") +} + +@Test func bulkMessageForSingleSessionUsesSingularNoun() { + let session = Session(name: "solo") + let text = DeleteSessionMessageBuilder.buildBulkMessage( + sessions: [session], + worktreesBySession: [:] + ) + #expect(text == "This will remove 1 session.") +} + +@Test func bulkMessageWithRealWorktreesMentionsCounts() { + let session = Session(name: "feat") + let wt = SessionWorktree( + sessionID: session.id, + repoName: "repo", + repoPath: "/repo", + worktreePath: "/worktrees/feat", + branch: "feature/test" + ) + let text = DeleteSessionMessageBuilder.buildBulkMessage( + sessions: [session], + worktreesBySession: [session.id: [wt]] + ) + #expect(text.contains("This will delete 1 session.")) + #expect(text.contains("1 worktree")) + #expect(text.contains("removed from disk")) +} + +@Test func bulkMessageWithMixedWorktreesMentionsBoth() { + let s1 = Session(name: "feat-a") + let s2 = Session(name: "feat-b") + let realWt = SessionWorktree( + sessionID: s1.id, + repoName: "repo", + repoPath: "/repo", + worktreePath: "/worktrees/feat-a", + branch: "feature/a" + ) + let mainWt = SessionWorktree( + sessionID: s2.id, + repoName: "repo", + repoPath: "/repo", + worktreePath: "/repo", + branch: "main" + ) + let text = DeleteSessionMessageBuilder.buildBulkMessage( + sessions: [s1, s2], + worktreesBySession: [s1.id: [realWt], s2.id: [mainWt]] + ) + #expect(text.contains("This will delete 2 sessions.")) + #expect(text.contains("1 worktree")) + #expect(text.contains("1 main repo checkout will not be affected")) +} + +@Test func bulkMessageWithOnlyMainCheckoutsSkipsRealWorktreeLine() { + let session = Session(name: "main-only") + let mainWt = SessionWorktree( + sessionID: session.id, + repoName: "repo", + repoPath: "/repo", + worktreePath: "/repo", + branch: "main" + ) + let text = DeleteSessionMessageBuilder.buildBulkMessage( + sessions: [session], + worktreesBySession: [session.id: [mainWt]] + ) + #expect(text.contains("This will delete 1 session.")) + #expect(text.contains("will not be affected")) + #expect(!text.contains("removed from disk")) +}