import AppKit import Foundation // MARK: - Vocabulary /// What the user chose at the save-or-discard step: **Save All** ends every session with its normal /// save, **Discard** reverts buffers and leaves the wholesale operation to reconcile what already /// reached disk, **Cancel** keeps everything exactly as it is. 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 operation with focus on the offending window, nothing /// half-done. 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 (`SessionSettleGate.path(_:isInsideFolderNamed:)`). 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. What /// already reached disk is reconciled by the wholesale operation behind the gate, which is the /// only party that knows the state it is writing towards. 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, retired with the callers that read it /// (`strategy/01-git-excision.md`, 2026-08-08). /// /// ### One machinery, every caller, by design /// /// The design never described a gate per operation: it described one save-or-discard step — Save All /// / Discard / Cancel — and handed it to whatever wants to move the tree wholesale under an open /// editor. So this object is written for all of them. **Nothing calls it today** (`strategy/ /// 01-git-excision.md` ▸ What is deleted: the git restore and branch switch were its two callers and /// went with the stack); it is kept because it is the settled answer to a question the next wholesale /// operation will ask on its first day. /// /// ### The paths decide whether it appears at all /// /// An operation that materializes only a diff leaves a card whose open Edit session the diff doesn't /// touch simply unaffected — that card's uncommitted ~700 ms saves continue undisturbed. 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 gesture because *some* window somewhere was in Edit would be a different, /// much worse feature. `settleAll()` is the other shape, for an operation that moves everything. /// /// ### 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 { await decide(over: Self.reached(by: paths, among: sessions())) } /// **Settles every open session, whatever the operation writes** — the gate for an operation that /// replaces the tree wholesale rather than materializing a diff. /// /// The path filter above is a diff-shaped operation's narrowing and belongs to it alone: a card /// whose open Edit session the diff doesn't touch is simply unaffected. A wholesale replacement /// has no such property, and the raw-source hazard is the reason it cannot borrow one — an /// unsettled raw buffer's Apply later writes the *entire* pre-operation `index.md` byte-for-byte /// over whatever now stands there, which is about the buffer describing a state that has gone, not /// about whether the operation happened to rewrite that card. So this asks about every session /// that is holding something, and about no path at all. public func settleAll() async -> SessionSettleOutcome { await decide(over: sessions()) } private func decide(over candidates: [SettleableSession]) async -> SessionSettleOutcome { let candidates = candidates.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 { path($0, isInsideFolderNamed: session.cardFolderName) } } } /// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a /// prefix of another's cannot be dragged into a step it has no business in. /// /// Moved here from the git history walk when the git stack was unbound (`strategy/ /// 01-git-excision.md` ▸ Sequencing ▸ Seam unbind); the walk was its only other caller. /// /// `dropLast()` because the *containing* folders are the question: a path that ends in the folder /// name is a file called that, not a file inside it. static func path(_ path: String, isInsideFolderNamed name: String) -> Bool { path.split(separator: "/").dropLast().contains { $0 == name } } } // 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 save protects, and there is no non-modal shape for a question whose three answers are /// mutually exclusive and immediate. /// /// 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. """ /// The same three buttons, asked for an operation that replaces the tree rather than editing part /// of it. **One sentence differs, deliberately**: the consequence a user is deciding about is not /// the same one, and a step that described the wrong operation would be a worse modal than no /// wording at all. Kept unused beside the gate, for the gate's reason. public static let wholesaleReplacementMessage = """ This would replace the cards you are editing. \ Save them, discard the changes, or cancel. """ @MainActor public static func ask(message: String = message) 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 } } }