import AppKit import Foundation // MARK: - Vocabulary /// What the user chose at the save-or-discard step (06-history-undo.md ▸ Branch switching: "**Save /// All** ends every session with its normal commit …, **Discard** reverts buffers and uncommitted /// saves to HEAD, **Cancel** keeps the current branch and the sessions"). public enum SessionSettleChoice: Sendable, Equatable { case saveAll case discard case cancel } /// What the gate concluded — the only thing the operation behind it branches on. public enum SessionSettleOutcome: Sendable, Equatable { /// Nothing needed settling, or everything did and did. The tree is settled; run. case proceed /// The user chose Cancel. "Cancel keeps everything" — nothing was written, nothing reverted. case cancelled /// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer /// that fails validation cancels the whole switch with focus on the offending window, nothing /// half-switched" (06 ▸ Branch switching). The payload is that window's session id, already /// focused by the gate. case failed(String) } // MARK: - What one card window offers the step /// **A card window's three answers to the save-or-discard step**, handed over as closures. /// /// A type of its own rather than three members on `CardSessionFlushing` because it is optional as a /// unit: a window with nothing settleable in it has no settlement, and the gate should not have to /// ask three questions to find that out. `nil` is also every window in a build with no card session /// at all, which is what the protocol's default supplies. @MainActor public struct CardSessionSettlement { /// Whether this window is holding state a wholesale tree operation would disturb. public let needsSettling: @MainActor () -> Bool /// Ends the Edit session with its normal commit and *applies* the raw buffer. `false` means the /// raw buffer failed validation. public let saveAll: @MainActor () -> Bool /// Reverts the Edit buffer and leaves raw source without writing. public let discard: @MainActor () -> Void public init( needsSettling: @escaping @MainActor () -> Bool, saveAll: @escaping @MainActor () -> Bool, discard: @escaping @MainActor () -> Void ) { self.needsSettling = needsSettling self.saveAll = saveAll self.discard = discard } } // MARK: - One settleable session /// **A card window, as the save-or-discard step sees it** — three closures and the card it is over. /// /// A value of closures rather than a protocol over `CardWindowSession`, for `CloseFlushCoordinator`'s /// reason exactly: what this gate is *about* is a decision procedure, and a procedure written against /// a live window is verifiable only by running the app. The production values come from the card /// windows; a test builds them from a counter. @MainActor public struct SettleableSession { /// The window's identity — `CardWindowRef.cardID` is what production passes. Opaque to the gate, /// and only ever handed back to `focus`. public let id: String /// The **card's folder name** — its id, which is its folder on disk (01-storage-format.md). /// /// Matched component-wise against the paths a wholesale operation would write, which is what /// makes the match survive a lane move: a card's own folder component never changes, only the /// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason). public let cardFolderName: String /// Whether this session is holding state a wholesale tree operation would disturb: unsaved /// keystrokes, an **open** Edit session whose ~700 ms saves are deliberately uncommitted, or a /// raw-source outlet that is open at all. public let needsSettling: @MainActor () -> Bool /// **Save All** for this one session: end the Edit session with its normal commit, and *apply* /// the raw buffer. `false` means the raw buffer failed validation — the whole operation is off. public let saveAll: @MainActor () -> Bool /// **Discard** for this one session: revert the buffer and leave raw source without writing. The /// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's /// folder against the working tree rather than against HEAD for exactly that reason /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`). public let discard: @MainActor () -> Void public init( id: String, cardFolderName: String, needsSettling: @escaping @MainActor () -> Bool, saveAll: @escaping @MainActor () -> Bool, discard: @escaping @MainActor () -> Void ) { self.id = id self.cardFolderName = cardFolderName self.needsSettling = needsSettling self.saveAll = saveAll self.discard = discard } } // MARK: - SessionSettleGate /// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md ▸ Rules /// ▸ Undo restore vs open Edit sessions; ▸ Branch switching). /// /// ### One machinery, two callers, by design /// /// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same /// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch /// save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same /// rationale)." So this object is written for both from the start; the undo provider is its first /// caller and the branch controls will be its second, passing the paths a checkout would write /// instead of the paths a restore would. /// /// ### The diff decides whether it appears at all /// /// "A restore materializes only the diff between the current tree and the target state, so a card /// whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms /// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all." /// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a /// yes/no: a modal that appeared on every ⌘Z because *some* window somewhere was in Edit would be a /// different, much worse feature. /// /// ### Why the ask is a closure /// /// Presenting three buttons is AppKit's job and cannot be asserted without a display. The rule this /// file exists to hold — which sessions are asked about, what each answer does to them, and that a /// failing raw buffer stops everything with focus on the offender — is decidable from values, so the /// presentation is a seam and the decision is testable. @MainActor public struct SessionSettleGate { /// Every open card session on this board, read live: a window can open or close between the /// moment an operation starts and the moment it asks. public var sessions: () -> [SettleableSession] /// Presents the three-button step and answers what the user chose. public var ask: () async -> SessionSettleChoice /// Brings one session's window forward — the "focus on the offending window" half of the /// validation-failure rule. public var focus: (String) -> Void public init( sessions: @escaping () -> [SettleableSession], ask: @escaping () async -> SessionSettleChoice, focus: @escaping (String) -> Void = { _ in } ) { self.sessions = sessions self.ask = ask self.focus = focus } // MARK: The decision /// Settles whatever the operation's paths reach, and answers whether it may run. /// /// - Parameter paths: board-root-relative paths the operation would write. public func settle(touching paths: Set) async -> SessionSettleOutcome { let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() } guard !candidates.isEmpty else { return .proceed } switch await ask() { case .cancel: return .cancelled case .discard: for session in candidates { session.discard() } return .proceed case .saveAll: for session in candidates { guard session.saveAll() else { // "Nothing half-switched": the sessions saved before this one are saved, which is // an ordinary Save and loses nothing, but the operation itself does not run. focus(session.id) return .failed(session.id) } } return .proceed } } /// **Which sessions a set of paths reaches** — pure, and the whole of "the diff touches a session /// card". /// /// Component-exact folder matching, so a card whose id happens to be a prefix of another's cannot /// drag that other card's window into the step. public static func reached( by paths: Set, among sessions: [SettleableSession] ) -> [SettleableSession] { guard !paths.isEmpty else { return [] } return sessions.filter { session in paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) } } } } // MARK: - The presented step /// **The three buttons**, as an `NSAlert` — the production `SessionSettleGate.ask`. /// /// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s /// reason exactly: the operation behind it cannot proceed until the user has decided what happens to /// text no commit protects, and there is no non-modal shape for a question whose three answers are /// mutually exclusive and immediate. /// /// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed /// reflexively at a dialog nobody read must be the answer that changes nothing, and both other /// answers write. public enum SessionSettleStep { public static let title = "Unsaved card edits" public static let message = """ Restoring an earlier state would change cards you are editing. \ Save them, discard the changes, or cancel. """ @MainActor public static func ask() async -> SessionSettleChoice { let alert = NSAlert() alert.alertStyle = .warning alert.messageText = title alert.informativeText = message // Order matters for the key equivalents AppKit assigns: the first button takes Return, so // Cancel leads and the two writing answers follow. Escape reaches Cancel either way. alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Save All") alert.addButton(withTitle: "Discard") switch alert.runModal() { case .alertSecondButtonReturn: return .saveAll case .alertThirdButtonReturn: return .discard default: return .cancel } } }