Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,15 @@ public final class AppState {
/// Must be cleaned up when a session is deleted (see `SessionService.deleteSession`).
public var isMarkingInReview: [UUID: Bool] = [:]

/// Sessions whose async deletion (worktree teardown, branch removal, persistence)
/// is currently in progress. Set on the main actor at the start of
/// `SessionService.deleteSession` and cleared when the session is fully removed.
public var isDeletingSession: [UUID: Bool] = [:]

/// Most recent delete-cleanup error for a session, surfaced inline on the row
/// so failures aren't silent. Auto-cleared after a short delay or on retry.
public var sessionDeletionError: [UUID: String] = [:]

/// Called to open a session's primary worktree in VS Code.
public var onOpenInVSCode: ((UUID) -> Void)?

Expand Down
23 changes: 16 additions & 7 deletions Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ enum DeleteSessionMessageBuilder {
// MARK: - Bulk Delete Sessions Alert

/// View modifier that attaches a bulk delete-sessions confirmation alert.
/// Iterates `selectedIDs` serially through `appState.onDeleteSession`.
/// Fans out `selectedIDs` concurrently through `appState.onDeleteSession` so
/// the UI doesn't wait N×single-delete to repaint.
struct BulkDeleteSessionsAlert: ViewModifier {
@Binding var isPresented: Bool
let selectedIDs: Set<UUID>
Expand All @@ -147,14 +148,22 @@ struct BulkDeleteSessionsAlert: ViewModifier {
Button(buttonLabel, role: .destructive) {
let snapshot = sortedSnapshot
Task {
// Fan each delete out as its own Task so they run concurrently.
// SessionService.deleteSession yields the main actor while its
// disk/git cleanup runs on a detached task, so all selected
// sessions clean up in parallel rather than serially.
var tasks: [Task<Void, Never>] = []
for id in snapshot {
do {
try await appState.onDeleteSession?(id)
} catch {
NSLog("Failed to delete session \(id): \(error)")
}
tasks.append(Task {
do {
try await appState.onDeleteSession?(id)
} catch {
NSLog("Failed to delete session \(id): \(error)")
}
})
}
await MainActor.run { onCompletion() }
for t in tasks { await t.value }
onCompletion()
}
}
} message: {
Expand Down
1 change: 1 addition & 0 deletions Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public struct SessionDetailView: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(appState.isDeletingSession[session.id] == true)
}
}
.padding(.horizontal, 16)
Expand Down
32 changes: 30 additions & 2 deletions Packages/CrowUI/Sources/CrowUI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ public struct SessionListView: View {
} label: {
Label("Delete", systemImage: "trash")
}
.disabled(appState.isDeletingSession[session.id] == true)
}
}
}
Expand Down Expand Up @@ -255,6 +256,8 @@ public struct SessionListView: View {

@ViewBuilder
private func sessionContextMenu(_ session: Session) -> some View {
let deleting = appState.isDeletingSession[session.id] == true

if session.status == .active,
session.ticketURL != nil,
session.provider == .github {
Expand All @@ -263,7 +266,7 @@ public struct SessionListView: View {
} label: {
Label("Mark as In Review", systemImage: "eye.circle")
}
.disabled(appState.isMarkingInReview[session.id] == true)
.disabled(appState.isMarkingInReview[session.id] == true || deleting)
}

if session.status == .active || session.status == .inReview {
Expand All @@ -272,13 +275,15 @@ public struct SessionListView: View {
} label: {
Label("Mark as Completed", systemImage: "checkmark.circle")
}
.disabled(deleting)
}

Button(role: .destructive) {
sessionToDelete = session
} label: {
Label("Delete", systemImage: "trash")
}
.disabled(deleting)
}

private func filteredSessions(_ sessions: [Session]) -> [Session] {
Expand Down Expand Up @@ -461,6 +466,14 @@ struct SessionRow: View {
appState.terminals(for: session.id).contains { $0.backend == .tmux }
}

private var isDeleting: Bool {
appState.isDeletingSession[session.id] == true
}

private var deletionError: String? {
appState.sessionDeletionError[session.id]
}

var body: some View {
HStack(spacing: 8) {
if isSelectionMode {
Expand All @@ -471,9 +484,11 @@ struct SessionRow: View {
}
.buttonStyle(.plain)
.accessibilityLabel(isChecked ? "Deselect \(session.name)" : "Select \(session.name)")
.disabled(isDeleting)
}

rowContent
.opacity(isDeleting ? 0.55 : 1.0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
Expand Down Expand Up @@ -506,6 +521,7 @@ struct SessionRow: View {
}
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: needsAttention)
.animation(.easeInOut(duration: 0.2), value: appState.hideSessionDetails)
.animation(.easeInOut(duration: 0.2), value: isDeleting)
.padding(.vertical, 1)
}

Expand All @@ -521,7 +537,19 @@ struct SessionRow: View {
RemoteControlBadge(compact: true)
}
Spacer()
statusIndicator
if isDeleting {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Deleting session")
} else if let deletionError {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.font(.caption)
.help("Delete failed: \(deletionError)")
.accessibilityLabel("Delete failed: \(deletionError)")
} else {
statusIndicator
}
}

// Row 2: Ticket title (if any)
Expand Down
Loading
Loading