Files
lanework/Kanban/App/SessionSettleGate.swift
rzen 1f7d84bf64 Build branch switching and the popover git surface
GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.

GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.

BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).

Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.

41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 16:41:14 -04:00

282 lines
12 KiB
Swift

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<String>) async -> SessionSettleOutcome {
await decide(over: Self.reached(by: paths, among: sessions()))
}
/// **Settles every open session, whatever the operation writes** — the branch switch's gate
/// (06 ▸ Branch switching).
///
/// The path filter above is the *restore's* narrowing and belongs to it alone: "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". A branch switch has no such
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out — "an
/// unsettled raw buffer … its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
/// onto the new branch's card" — is about the buffer belonging to the old branch, not about
/// whether the checkout 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 { 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.
"""
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
/// consequence a user is deciding about is not the same one — a restore would change the cards
/// being edited, while a switch takes every card to a different branch — and a step that described
/// the wrong operation would be a worse modal than no wording at all.
public static let branchSwitchMessage = """
Switching branches 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
}
}
}