Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
290 lines
13 KiB
Swift
290 lines
13 KiB
Swift
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<String>) 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<String>,
|
|
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
|
|
}
|
|
}
|
|
}
|