diff --git a/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift b/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift new file mode 100644 index 000000000..8e26ba8be --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalCloseConfirmationGate.swift @@ -0,0 +1,24 @@ +import Foundation + +/// App-global guard against re-entrant close-confirmation alerts. +/// +/// Close requests arrive from independent paths (TCA effects, Ghostty +/// callbacks, the tab bar, the CLI socket), and `NSAlert.runModal()` keeps +/// draining main-actor jobs while it spins, so a late request can try to +/// present a second alert from inside the first one's run loop. AppKit may +/// answer that nested modal with an uncaught NSException (SIGABRT, Sentry +/// PROWL-MACOS-FP). While a confirmation is on screen, later ones are +/// dropped and treated as cancelled. +@MainActor +enum TerminalCloseConfirmationGate { + private(set) static var isPresenting = false + + /// Runs `present` unless a confirmation is already showing. + /// Returns `nil` when the gate is held; callers treat that as "cancelled". + static func run(_ present: () -> T) -> T? { + guard !isPresenting else { return nil } + isPresenting = true + defer { isPresenting = false } + return present() + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 6de15cefa..bf8c12c46 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -47,13 +47,16 @@ extension WorktreeTerminalState { target: TerminalCloseConfirmationTarget, decision: TerminalCloseConfirmationDecision ) -> Bool { - let alert = NSAlert() - alert.messageText = target.messageText - alert.informativeText = closeConfirmationMessage(for: decision) - alert.alertStyle = .warning - alert.addButton(withTitle: target.confirmButtonTitle) - alert.addButton(withTitle: "Cancel") - return alert.runModal() == .alertFirstButtonReturn + let confirmed = TerminalCloseConfirmationGate.run { + let alert = NSAlert() + alert.messageText = target.messageText + alert.informativeText = closeConfirmationMessage(for: decision) + alert.alertStyle = .warning + alert.addButton(withTitle: target.confirmButtonTitle) + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + return confirmed ?? false } func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { diff --git a/supacodeTests/TerminalCloseConfirmationGateTests.swift b/supacodeTests/TerminalCloseConfirmationGateTests.swift new file mode 100644 index 000000000..b8c3c1479 --- /dev/null +++ b/supacodeTests/TerminalCloseConfirmationGateTests.swift @@ -0,0 +1,29 @@ +import Testing + +@testable import supacode + +@MainActor +struct TerminalCloseConfirmationGateTests { + @Test func runExecutesWhenGateIsFree() { + let result = TerminalCloseConfirmationGate.run { true } + #expect(result == true) + #expect(TerminalCloseConfirmationGate.isPresenting == false) + } + + @Test func nestedRunIsDropped() { + var nestedResult: Bool? = false + let outer = TerminalCloseConfirmationGate.run { () -> Bool in + #expect(TerminalCloseConfirmationGate.isPresenting) + nestedResult = TerminalCloseConfirmationGate.run { true } + return true + } + #expect(outer == true) + #expect(nestedResult == nil) + } + + @Test func gateIsReleasedAfterRun() { + _ = TerminalCloseConfirmationGate.run { true } + let second = TerminalCloseConfirmationGate.run { false } + #expect(second == false) + } +}