Implement undo and redo as forward commits
GitHistoryProvider is the second HistoryProviding implementation: its stack IS HEAD's first-parent ancestry, reseeded on load (redo empty), re-synced to HEAD before every crossing so agents' self-commits become the top and ⌘Z steps back exactly one commit; any arriving commit clears redo (a heal-only window deliberately does not). Restores are forward commits through the ordinary signature path — GitRestoreOperation materializes only the current-vs-target diff as working-tree writes and resolves no reset/checkout symbol at all; heal commits are transparent in-session (pointer passes over, restores exclude heal-owned paths, identity carried on landed windows via PlannedCommit.kind → GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu labels never nest in-session; the root commit is not a step (crossing it would restore the empty tree). Provider binding flips: makeHistoryProvider(store, tier, git) — free binds native everywhere, Pro binds the git provider on git boards and NOTHING on mode-none/repo-nested (the pair disables through existing validation); add-git mid-session live-binds via HistoryStore.didAddGit → bindHistoryProvider (the flip only ever adds). SessionSettleGate is the reusable Save All / Discard / Cancel step: restores whose diff touches an open Edit session or raw-source buffer gate on it (Save All applies with validation — a refused buffer cancels the whole restore focused on the offender; Discard reverts via CardBodyEditSession.discardBuffer and reconciles against the working tree, deliberately skipping the second flush); untouched sessions ride through undisturbed. Built for the branch-switch card to reuse. BoardStore gains the async performWholesale sibling. CardHistorySection fills the m6 EmptyView slot: read-only, newest first, follows the card across lane moves by folder-component match (the UUID is the identity — no rename detection), absent off git mode and off Pro. 2332 tests / 403 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
+174
-17
@@ -290,19 +290,30 @@ public final class AppModel {
|
|||||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||||
/// nothing renders from it.
|
/// nothing renders from it.
|
||||||
///
|
///
|
||||||
/// ### The `Tier` argument, and the one consumer it is still short
|
/// ### The three answers, and the `nil` among them
|
||||||
///
|
///
|
||||||
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
|
/// The closure is now the whole tier matrix, in three lines (12 ▸ Tier matrix; 06 ▸ Rules):
|
||||||
/// today and binds the native stack for both — not because the seam is decorative, but because
|
///
|
||||||
/// the git provider it would bind does not exist yet (12 ▸ Tier matrix: git init/adoption,
|
/// - **Free** — the native stack, on every board. There is no `HistoryStore` at all off Pro, so
|
||||||
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
|
/// the absent git state *is* the tier test; nothing here reads a flag.
|
||||||
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
|
/// - **Pro, mode `git`** — the git provider: undo as forward restore commits over HEAD's
|
||||||
/// than a change to the composition root, and so that the tier a board actually composed under is
|
/// first-parent ancestry.
|
||||||
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
|
/// - **Pro, mode `none` or `repoNested`** — **no provider**. "A board without git has no
|
||||||
/// alongside its provider.
|
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" — so Edit
|
||||||
|
/// ▸ Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo
|
||||||
|
/// provider in the composed tier — under Pro, no-git and repo-nested boards, matching their menu
|
||||||
|
/// items", 03-board-ui.md ▸ Toolbar). Not the native stack: on Pro, a mode-none board's edits
|
||||||
|
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second
|
||||||
|
/// substrate the design does not have.
|
||||||
|
///
|
||||||
|
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession`
|
||||||
|
/// composes the git state *before* the provider: which substrate a board gets is a question about
|
||||||
|
/// its repository, and a root that had to ask the disk itself would be a second detection.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
|
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
||||||
NativeHistoryProvider()
|
guard let git else { return NativeHistoryProvider() }
|
||||||
|
guard git.mode == .git else { return nil }
|
||||||
|
return GitHistoryProvider(boardRoot: store.rootURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Sessions
|
// MARK: Sessions
|
||||||
@@ -323,7 +334,16 @@ public final class AppModel {
|
|||||||
///
|
///
|
||||||
/// Which implementation it is, is the tier's answer and nobody else's
|
/// Which implementation it is, is the tier's answer and nobody else's
|
||||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||||
public let history: any HistoryProviding
|
///
|
||||||
|
/// **`nil` is a board with no undo at all** — under Pro, mode `none` and repo-nested boards
|
||||||
|
/// (06-history-undo.md ▸ Rules: "A board without git has **no undo/redo**"). The command
|
||||||
|
/// surface disables through `undoManager`, which answers the empty way over an absent
|
||||||
|
/// substrate.
|
||||||
|
///
|
||||||
|
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
|
||||||
|
/// sanctioned mid-session mode flip, binds a provider here on the board it flips
|
||||||
|
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
|
||||||
|
public var history: (any HistoryProviding)?
|
||||||
|
|
||||||
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
||||||
///
|
///
|
||||||
@@ -724,10 +744,6 @@ public final class AppModel {
|
|||||||
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
||||||
// re-derives it.
|
// re-derives it.
|
||||||
let tier = currentTier()
|
let tier = currentTier()
|
||||||
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
|
||||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
|
||||||
// the board session and dies at close/quit ... standard macOS behavior".
|
|
||||||
let history = makeHistoryProvider(store, tier)
|
|
||||||
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||||
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
|
||||||
// without looking at the disk at all — the inert posture is unconditional there — and under
|
// without looking at the disk at all — the inert posture is unconditional there — and under
|
||||||
@@ -736,7 +752,17 @@ public final class AppModel {
|
|||||||
//
|
//
|
||||||
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
||||||
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
||||||
|
//
|
||||||
|
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
|
||||||
|
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
|
||||||
|
// disk itself would be a second detection able to disagree with this one.
|
||||||
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
|
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
|
||||||
|
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
||||||
|
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||||
|
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
|
||||||
|
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
|
||||||
|
// survives relaunch) — the seam's whole point.
|
||||||
|
let history = makeHistoryProvider(store, tier, git)
|
||||||
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
||||||
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
||||||
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
||||||
@@ -763,6 +789,20 @@ public final class AppModel {
|
|||||||
store?.banners.clearHistorySuspension()
|
store?.banners.clearHistorySuspension()
|
||||||
}
|
}
|
||||||
store.commitSeam = .binding(to: committer)
|
store.commitSeam = .binding(to: committer)
|
||||||
|
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
||||||
|
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
||||||
|
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
||||||
|
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
||||||
|
// `activateAutoCommit` remembers on its behalf.
|
||||||
|
committer.reportLanded = { [weak self, ref] window in
|
||||||
|
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
||||||
|
provider.noteLanded(window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// **Add-git binds undo too** (06 ▸ Rules ▸ Detection — the one commanded mid-session mode
|
||||||
|
// flip). See `bindHistoryProvider(for:)` for the judgment call this records.
|
||||||
|
git.didAddGit = { [weak self] in
|
||||||
|
self?.bindHistoryProvider(for: ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||||
@@ -789,10 +829,127 @@ public final class AppModel {
|
|||||||
cardRefs: [],
|
cardRefs: [],
|
||||||
access: access
|
access: access
|
||||||
)
|
)
|
||||||
|
wireGitUndo(history, store: store, git: git, ref: ref)
|
||||||
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
||||||
refreshRecents()
|
refreshRecents()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The git provider's wiring
|
||||||
|
|
||||||
|
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing
|
||||||
|
/// at all for any other substrate.
|
||||||
|
///
|
||||||
|
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
|
||||||
|
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
|
||||||
|
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
|
||||||
|
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider
|
||||||
|
/// stays a thing that knows about commits, and the model stays the only object that knows what a
|
||||||
|
/// window is.
|
||||||
|
private func wireGitUndo(
|
||||||
|
_ history: (any HistoryProviding)?,
|
||||||
|
store: BoardStore,
|
||||||
|
git: HistoryStore?,
|
||||||
|
ref: BoardWindowRef
|
||||||
|
) {
|
||||||
|
guard let provider = history as? GitHistoryProvider, let git else { return }
|
||||||
|
|
||||||
|
provider.flushPendingCommit = { [weak git] in
|
||||||
|
await git?.committer?.flushNow()
|
||||||
|
}
|
||||||
|
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||||
|
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||||
|
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
|
||||||
|
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
||||||
|
// one-shot banner failure naming the operation and the error, the tree left as it was").
|
||||||
|
//
|
||||||
|
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
|
||||||
|
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
|
||||||
|
// `WriteOperation` vocabulary — and a git operation is deliberately not one of those
|
||||||
|
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
|
||||||
|
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
|
||||||
|
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
|
||||||
|
// a banner-surface change, not this card's.
|
||||||
|
provider.reportFailure = { [weak store] failure in
|
||||||
|
store?.banners.postLoss(failure.description)
|
||||||
|
}
|
||||||
|
provider.runBracketed = { [weak store] subject, work in
|
||||||
|
guard let store else { return await work() }
|
||||||
|
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
|
||||||
|
// own subject — the sentence the trail now carries, spoken once when the reload lands.
|
||||||
|
try? await store.performWholesale(announcing: subject) { await work() }
|
||||||
|
}
|
||||||
|
provider.settleSessions = { [weak self, weak provider] paths in
|
||||||
|
guard let self, let provider else { return .proceed }
|
||||||
|
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
|
||||||
|
}
|
||||||
|
provider.seed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The save-or-discard step for one board**, built from its open card windows
|
||||||
|
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||||
|
///
|
||||||
|
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
||||||
|
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
||||||
|
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
|
||||||
|
SessionSettleGate(
|
||||||
|
sessions: { [weak self] in
|
||||||
|
guard let self, let session = self.sessions[ref] else { return [] }
|
||||||
|
return session.cardRefs.compactMap { cardRef in
|
||||||
|
guard let flushing = self.cardSessions[cardRef],
|
||||||
|
let settlement = flushing.settlement else { return nil }
|
||||||
|
return SettleableSession(
|
||||||
|
id: cardRef.cardID,
|
||||||
|
cardFolderName: cardRef.cardID,
|
||||||
|
needsSettling: settlement.needsSettling,
|
||||||
|
saveAll: settlement.saveAll,
|
||||||
|
discard: { [weak provider] in
|
||||||
|
settlement.discard()
|
||||||
|
// The card's uncommitted on-disk saves are reverted by the restore
|
||||||
|
// itself, which compares this folder against the working tree rather
|
||||||
|
// than against HEAD — see `GitRestoreOperation.plan`.
|
||||||
|
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ask: { await SessionSettleStep.ask() },
|
||||||
|
focus: { [weak self] id in
|
||||||
|
guard let self, let session = self.sessions[ref] else { return }
|
||||||
|
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||||
|
// Opening a window that is already open is how SwiftUI's value-addressed groups say
|
||||||
|
// "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and
|
||||||
|
// the reason reopening a live card focuses its window rather than making a second one.
|
||||||
|
self.windowOpener?(id: WindowID.card, value: cardRef)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Binds the git provider onto an already-open session** — add-git's one caller.
|
||||||
|
///
|
||||||
|
/// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git:
|
||||||
|
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
||||||
|
/// the git controls, the first auto-commit follows". It does not mention undo, so this is a
|
||||||
|
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting
|
||||||
|
/// for the next open. Three reasons point the same way — the mode flip already carries the
|
||||||
|
/// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this
|
||||||
|
/// board); 12-editions.md's "an open board finishes with the provider it composed" is a rule about
|
||||||
|
/// a **tier** lapsing, which cannot change a running session at all; and a board that visibly
|
||||||
|
/// starts accumulating commits while ⌘Z stays greyed out until it is closed and reopened would
|
||||||
|
/// read as a defect rather than as a policy.
|
||||||
|
///
|
||||||
|
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one
|
||||||
|
/// away — there is no un-add-git.
|
||||||
|
func bindHistoryProvider(for ref: BoardWindowRef) {
|
||||||
|
guard var session = sessions[ref], session.history == nil,
|
||||||
|
let git = session.git, git.mode == .git else { return }
|
||||||
|
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
|
||||||
|
session.history = history
|
||||||
|
sessions[ref] = session
|
||||||
|
session.store.history = history
|
||||||
|
session.undoManager.history = history
|
||||||
|
wireGitUndo(history, store: session.store, git: git, ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
/// Registers a card window with its board's session, so the close flush can find it.
|
/// Registers a card window with its board's session, so the close flush can find it.
|
||||||
///
|
///
|
||||||
/// A card window whose board has no session is a card window with no board — the ownership rule
|
/// A card window whose board has no session is a card window with no board — the ownership rule
|
||||||
@@ -1112,7 +1269,7 @@ public final class AppModel {
|
|||||||
// Cleared rather than merely dropped because the steps hold closures over the store
|
// Cleared rather than merely dropped because the steps hold closures over the store
|
||||||
// this line is about to release, and a stack that outlived its board would be a
|
// this line is about to release, and a stack that outlived its board would be a
|
||||||
// retain cycle wearing an undo stack's clothes.
|
// retain cycle wearing an undo stack's clothes.
|
||||||
session.history.clear()
|
session.history?.clear()
|
||||||
storeRegistry.release(session.store)
|
storeRegistry.release(session.store)
|
||||||
session.access?.stop()
|
session.access?.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ final class CardWindowSession: CardSessionFlushing {
|
|||||||
/// window that has not joined its board — holds nothing, which is true.
|
/// window that has not joined its board — holds nothing, which is true.
|
||||||
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
|
var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?
|
||||||
|
|
||||||
|
/// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
|
||||||
|
/// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
|
||||||
|
/// `rawSourceHoldsUnsavedText`, and for its reason — the outlet is window state living beside
|
||||||
|
/// this object rather than inside it.
|
||||||
|
var rawSourceApply: (@MainActor () -> Bool)?
|
||||||
|
var rawSourceCancel: (@MainActor () -> Void)?
|
||||||
|
var rawSourceIsActive: (@MainActor () -> Bool)?
|
||||||
|
|
||||||
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
|
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
|
||||||
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
|
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
|
||||||
///
|
///
|
||||||
@@ -89,6 +97,44 @@ final class CardWindowSession: CardSessionFlushing {
|
|||||||
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
|
body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **What a restore or a branch switch asks this window to settle** (06-history-undo.md ▸ Rules
|
||||||
|
/// ▸ Undo restore vs open Edit sessions).
|
||||||
|
///
|
||||||
|
/// ### The predicate is *open*, not *dirty*
|
||||||
|
///
|
||||||
|
/// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
|
||||||
|
/// saves are on disk and deliberately uncommitted — the stage-around rule's whole point — so a
|
||||||
|
/// restore landing over them would either bury text no commit protects or leave the session's
|
||||||
|
/// next debounced save to write pre-restore bytes back over the restored card, "a ⌘Z that visibly
|
||||||
|
/// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
|
||||||
|
/// the same fact.
|
||||||
|
///
|
||||||
|
/// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
|
||||||
|
/// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
|
||||||
|
/// card". A buffer read from before the restore is the hazard; typing is not required for it.
|
||||||
|
var settlement: CardSessionSettlement? {
|
||||||
|
CardSessionSettlement(
|
||||||
|
needsSettling: { [self] in
|
||||||
|
body.isEditing || body.isDirty || rawSourceIsActive?() == true
|
||||||
|
},
|
||||||
|
saveAll: { [self] in
|
||||||
|
// The Edit session ends with its normal commit — "each card's Edit→Preview flip".
|
||||||
|
body.endEditSession()
|
||||||
|
// Apply validates; a refusal is the whole operation's cancellation, and the alert it
|
||||||
|
// raised is already on the offending window.
|
||||||
|
guard rawSourceIsActive?() == true else { return true }
|
||||||
|
return rawSourceApply?() ?? true
|
||||||
|
},
|
||||||
|
discard: { [self] in
|
||||||
|
// The buffer goes back to what disk says; the *disk* goes back to the target state as
|
||||||
|
// part of the restore itself, which reconciles this card's folder against the working
|
||||||
|
// tree rather than against HEAD (`GitRestoreOperation.plan`).
|
||||||
|
body.discardBuffer()
|
||||||
|
rawSourceCancel?()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var hasEnded = false
|
private var hasEnded = false
|
||||||
|
|
||||||
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
/// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
|
||||||
@@ -185,6 +231,9 @@ struct CardWindowHost: View {
|
|||||||
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
|
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
|
||||||
/// on every reload (`AttachmentThumbnailCache`).
|
/// on every reload (`AttachmentThumbnailCache`).
|
||||||
@State private var thumbnails = AttachmentThumbnailCache()
|
@State private var thumbnails = AttachmentThumbnailCache()
|
||||||
|
/// This card's commit trail (05-card-window.md ▸ History). Held here for `thumbnails`' reason —
|
||||||
|
/// it must survive every snapshot — and surfaced to the view only in git mode (`cardHistory`).
|
||||||
|
@State private var history = CardHistory()
|
||||||
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
|
||||||
/// flush; cleared by the resolution that lets the close resume.
|
/// flush; cleared by the resolution that lets the close resume.
|
||||||
@State private var isClosePending = false
|
@State private var isClosePending = false
|
||||||
@@ -311,6 +360,29 @@ struct CardWindowHost: View {
|
|||||||
.onDisappear { finish() }
|
.onDisappear { finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History).
|
||||||
|
///
|
||||||
|
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
|
||||||
|
/// flag: no `HistoryStore` means the free tier (12-editions.md — where the section never exists),
|
||||||
|
/// and a mode other than `git` means a board the app manages no history for. The object is held
|
||||||
|
/// by this host so it survives every snapshot, `thumbnails`' reason exactly.
|
||||||
|
private var cardHistory: CardHistory? {
|
||||||
|
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
|
||||||
|
///
|
||||||
|
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
|
||||||
|
/// commit the app makes — the debounced ones, the launch catch-up, and a restore's. A foreign
|
||||||
|
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
|
||||||
|
/// next commit or the next open, which is the same freshness bound the popover's branch line has
|
||||||
|
/// and a great deal cheaper than polling HEAD from a sidebar.
|
||||||
|
private func historyReloadKey(store: BoardStore) -> String {
|
||||||
|
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
|
||||||
|
return "\(ref.cardID)#\(commits)"
|
||||||
|
}
|
||||||
|
|
||||||
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸
|
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸
|
||||||
/// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps
|
/// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps
|
||||||
/// the minimum it always had.
|
/// the minimum it always had.
|
||||||
@@ -343,11 +415,20 @@ struct CardWindowHost: View {
|
|||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
comments: session.comments,
|
comments: session.comments,
|
||||||
thumbnails: thumbnails,
|
thumbnails: thumbnails,
|
||||||
|
history: cardHistory,
|
||||||
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
|
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
|
||||||
onToggleTask: { offset, checked in
|
onToggleTask: { offset, checked in
|
||||||
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
|
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// **The trail, re-read when a commit lands** (05 ▸ History). The id is the pair of facts
|
||||||
|
// the answer depends on: which card this is, and how many commits this board has made —
|
||||||
|
// so the section refreshes after the app's own commits, after an agent's that the watcher
|
||||||
|
// committed, and after a ⌘Z's restore, with nothing here knowing what a committer is.
|
||||||
|
.task(id: historyReloadKey(store: store)) {
|
||||||
|
guard let cardHistory else { return }
|
||||||
|
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
|
||||||
|
}
|
||||||
// **The listing is the snapshot's, republished** — `Card.attachments`, which the loader
|
// **The listing is the snapshot's, republished** — `Card.attachments`, which the loader
|
||||||
// fills from `attachments/`'s top-level files in Finder order. Every write in the
|
// fills from `attachments/`'s top-level files in Finder order. Every write in the
|
||||||
// section is bracketed, so the reload that refreshes this arrives by itself and the
|
// section is bracketed, so the reload that refreshes this arrives by itself and the
|
||||||
@@ -544,6 +625,12 @@ struct CardWindowHost: View {
|
|||||||
// The other half of the outlet's wiring: the session answers for this window's unsaved
|
// The other half of the outlet's wiring: the session answers for this window's unsaved
|
||||||
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
|
// content, and the outlet is the half that does not live inside it (`CardWindowSession`).
|
||||||
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
|
session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
|
||||||
|
// The save-or-discard step's half of the same wiring (06-history-undo.md ▸ Branch switching):
|
||||||
|
// Save All *applies* an open outlet — validation included, so a refusal cancels the whole
|
||||||
|
// operation — and Discard leaves it without writing.
|
||||||
|
session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
|
||||||
|
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
|
||||||
|
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
|
||||||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||||||
Self.configureComments(session.comments, store: store, cardID: cardID)
|
Self.configureComments(session.comments, store: store, cardID: cardID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,23 @@ public protocol CardSessionFlushing: AnyObject {
|
|||||||
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes
|
/// template that silently missed those keystrokes would break 09's never-misses-keystrokes
|
||||||
/// guarantee, which outranks the item's availability.
|
/// guarantee, which outranks the item's availability.
|
||||||
var holdsUnsavedContent: Bool { get }
|
var holdsUnsavedContent: Bool { get }
|
||||||
|
|
||||||
|
/// **This window's answers to the save-or-discard step** (06-history-undo.md ▸ Rules ▸ Undo
|
||||||
|
/// restore vs open Edit sessions; ▸ Branch switching), or `nil` for a window with nothing a
|
||||||
|
/// wholesale tree operation could disturb.
|
||||||
|
///
|
||||||
|
/// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would
|
||||||
|
/// a *template copy* miss keystrokes", which is a read; this one carries the two writes — Save
|
||||||
|
/// All and Discard — that a restore or a branch switch needs the window to perform before it can
|
||||||
|
/// run on a settled tree. Same window, two different questions, and collapsing them would give
|
||||||
|
/// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes.
|
||||||
|
var settlement: CardSessionSettlement? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension CardSessionFlushing {
|
public extension CardSessionFlushing {
|
||||||
func endSession() async {}
|
func endSession() async {}
|
||||||
var holdsUnsavedContent: Bool { false }
|
var holdsUnsavedContent: Bool { false }
|
||||||
|
var settlement: CardSessionSettlement? { nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CloseFlushCoordinator
|
// MARK: - CloseFlushCoordinator
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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 {
|
||||||
|
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<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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,44 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
// MARK: - GitLandedWindow
|
||||||
|
|
||||||
|
/// **One debounce window's commits, as the undo stack needs to hear about them** — see
|
||||||
|
/// `GitAutoCommitter.reportLanded`.
|
||||||
|
///
|
||||||
|
/// The heal halves are separated because the two rules they serve are separate: `healOIDs` is what
|
||||||
|
/// makes the *pointer* pass over a heal commit, and `healPaths` is what makes a restore's
|
||||||
|
/// materialized diff **exclude** the paths whose divergence is heal work (06-history-undo.md ▸ Rules
|
||||||
|
/// ▸ Heal commits are transparent to undo, in-session — both halves, stated in one sentence).
|
||||||
|
public struct GitLandedWindow: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// Every commit the window landed, oldest first.
|
||||||
|
public let commits: [GitLandedCommit]
|
||||||
|
|
||||||
|
/// Board-root-relative paths this window committed as heal work.
|
||||||
|
public let healPaths: Set<String>
|
||||||
|
|
||||||
|
public init(commits: [GitLandedCommit], healPaths: Set<String>) {
|
||||||
|
self.commits = commits
|
||||||
|
self.healPaths = healPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oids of the heal-class commits — the ones the undo pointer passes over.
|
||||||
|
public var healOIDs: Set<String> {
|
||||||
|
Set(commits.filter { $0.kind == .heal }.map(\.oid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether *everything* this window landed was heal work.
|
||||||
|
///
|
||||||
|
/// The distinction the stack acts on: a window of nothing but heal commits must leave the undo
|
||||||
|
/// pointer and the redo stack exactly where they were — "the fresh heal commit is in-session,
|
||||||
|
/// transparent, and the undo run continues past it" (06). A window carrying anything else is an
|
||||||
|
/// ordinary arrival, and arrivals clear redo.
|
||||||
|
public var isEntirelyHeal: Bool {
|
||||||
|
!commits.isEmpty && commits.allSatisfy { $0.kind == .heal }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - GitAutoCommitter
|
// MARK: - GitAutoCommitter
|
||||||
|
|
||||||
/// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced
|
/// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced
|
||||||
@@ -110,6 +148,22 @@ public final class GitAutoCommitter {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var reportRecovery: (@MainActor () -> Void)?
|
public var reportRecovery: (@MainActor () -> Void)?
|
||||||
|
|
||||||
|
/// **What a flush landed, and which of it was heal work** — the undo stack's in-session ear
|
||||||
|
/// (06-history-undo.md ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live; ▸ Heal commits
|
||||||
|
/// are transparent to undo, in-session).
|
||||||
|
///
|
||||||
|
/// Two facts travel here and nowhere else can carry them. **Liveness**: "foreign commits …
|
||||||
|
/// push onto the in-session undo stack as ordinary steps as they land", and a commit this engine
|
||||||
|
/// made is the one kind of arrival the stack could otherwise only discover by polling HEAD.
|
||||||
|
/// **Heal transparency**: the heal class is known from the Writer's heal-marked receipts, which
|
||||||
|
/// this engine clears the instant a window commits — so the moment of landing is the only moment
|
||||||
|
/// at which "that commit was the heal" is knowable at all.
|
||||||
|
///
|
||||||
|
/// `nil` on every board with no undo provider bound (the free tier's committer does not exist;
|
||||||
|
/// a Pro board's does, and a provider is bound beside it).
|
||||||
|
@ObservationIgnored
|
||||||
|
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
|
||||||
|
|
||||||
// MARK: - Observable state
|
// MARK: - Observable state
|
||||||
|
|
||||||
/// **The repository state the engine is holding for**, or `nil` when it is free to commit
|
/// **The repository state the engine is holding for**, or `nil` when it is free to commit
|
||||||
@@ -247,7 +301,8 @@ public final class GitAutoCommitter {
|
|||||||
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
|
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
|
||||||
// the foreign version commits on the next quiet debounce instead — which is the same
|
// the foreign version commits on the next quiet debounce instead — which is the same
|
||||||
// "re-debounce" answer contention gets everywhere else.
|
// "re-debounce" answer contention gets everywhere else.
|
||||||
apply(Self.execute(input))
|
let result = Self.execute(input)
|
||||||
|
apply(result.outcome, healPaths: result.healPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Edit sessions
|
// MARK: - Edit sessions
|
||||||
@@ -324,12 +379,12 @@ public final class GitAutoCommitter {
|
|||||||
// The brief backoff. Off the main actor for the git work, on it for the sleep, so a held
|
// The brief backoff. Off the main actor for the git work, on it for the sleep, so a held
|
||||||
// lock costs a couple of suspended turns rather than a blocked UI.
|
// lock costs a couple of suspended turns rather than a blocked UI.
|
||||||
for attempt in 0...max(0, lockRetryAttempts) {
|
for attempt in 0...max(0, lockRetryAttempts) {
|
||||||
let outcome = await Task.detached(priority: .utility) { Self.execute(input) }.value
|
let result = await Task.detached(priority: .utility) { Self.execute(input) }.value
|
||||||
if case .locked = outcome, attempt < max(0, lockRetryAttempts) {
|
if case .locked = result.outcome, attempt < max(0, lockRetryAttempts) {
|
||||||
try? await Task.sleep(for: lockRetryDelay)
|
try? await Task.sleep(for: lockRetryDelay)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
apply(outcome)
|
apply(result.outcome, healPaths: result.healPaths)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,35 +410,45 @@ public final class GitAutoCommitter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one flush concluded — the outcome, plus the paths it committed as heal work.
|
||||||
|
///
|
||||||
|
/// The second half exists for `reportLanded`: heal paths are known only inside the split, which
|
||||||
|
/// runs here, and the stack that needs them lives on the main actor.
|
||||||
|
private struct FlushOutput: Sendable {
|
||||||
|
let outcome: GitCommitOutcome
|
||||||
|
var healPaths: Set<String> = []
|
||||||
|
}
|
||||||
|
|
||||||
/// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around
|
/// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around
|
||||||
/// the open sessions, split by provenance, compose, commit.
|
/// the open sessions, split by provenance, compose, commit.
|
||||||
private nonisolated static func execute(_ input: FlushInput) -> GitCommitOutcome {
|
private nonisolated static func execute(_ input: FlushInput) -> FlushOutput {
|
||||||
let reading = GitCommitOperation.reading(at: input.boardRoot)
|
let reading = GitCommitOperation.reading(at: input.boardRoot)
|
||||||
if let pause = reading.pause { return .held(pause) }
|
if let pause = reading.pause { return FlushOutput(outcome: .held(pause)) }
|
||||||
if reading.isIndexLocked { return .locked }
|
if reading.isIndexLocked { return FlushOutput(outcome: .locked) }
|
||||||
|
|
||||||
// `nil` is "the survey could not be taken" — an unwritable object store, a corrupt index —
|
// `nil` is "the survey could not be taken" — an unwritable object store, a corrupt index —
|
||||||
// and it must not read as a clean tree: that would no-op silently and let history stop
|
// and it must not read as a clean tree: that would no-op silently and let history stop
|
||||||
// advancing with nothing on the banner strip (06 ▸ Interaction with external writers, the
|
// advancing with nothing on the banner strip (06 ▸ Interaction with external writers, the
|
||||||
// genuine-failure clause).
|
// genuine-failure clause).
|
||||||
guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else {
|
guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else {
|
||||||
return .failed(GitOperationFailure(
|
return FlushOutput(outcome: .failed(GitOperationFailure(
|
||||||
operation: "Recording this board's history",
|
operation: "Recording this board's history",
|
||||||
message: "this board's repository could not be read"
|
message: "this board's repository could not be read"
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
let changed = surveyed
|
let changed = surveyed
|
||||||
.filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) }
|
.filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) }
|
||||||
guard !changed.isEmpty else { return .nothingToCommit }
|
guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) }
|
||||||
|
|
||||||
return GitCommitOperation.perform(
|
let commits = plan(
|
||||||
at: input.boardRoot,
|
changed,
|
||||||
commits: plan(
|
reading: reading,
|
||||||
changed,
|
input: input,
|
||||||
reading: reading,
|
composition: composition(for: changed, input: input)
|
||||||
input: input,
|
)
|
||||||
composition: composition(for: changed, input: input)
|
return FlushOutput(
|
||||||
)
|
outcome: GitCommitOperation.perform(at: input.boardRoot, commits: commits),
|
||||||
|
healPaths: Set(commits.filter { $0.kind == .heal }.flatMap(\.paths))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +540,8 @@ public final class GitAutoCommitter {
|
|||||||
paths: changed.map(\.path),
|
paths: changed.map(\.path),
|
||||||
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
|
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
|
||||||
author: user,
|
author: user,
|
||||||
committer: user
|
committer: user,
|
||||||
|
kind: .root
|
||||||
)]
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,11 +563,18 @@ public final class GitAutoCommitter {
|
|||||||
}
|
}
|
||||||
let author: GitIdentity
|
let author: GitIdentity
|
||||||
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
||||||
|
let kind: PlannedCommitKind
|
||||||
|
switch group.kind {
|
||||||
|
case .foreign: kind = .foreign
|
||||||
|
case .heal: kind = .heal
|
||||||
|
case .user: kind = .user
|
||||||
|
}
|
||||||
return PlannedCommit(
|
return PlannedCommit(
|
||||||
paths: group.paths.map(\.path),
|
paths: group.paths.map(\.path),
|
||||||
message: input.composer.message(for: request(group.paths, authorship)),
|
message: input.composer.message(for: request(group.paths, authorship)),
|
||||||
author: author,
|
author: author,
|
||||||
committer: user
|
committer: user,
|
||||||
|
kind: kind
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,13 +592,18 @@ public final class GitAutoCommitter {
|
|||||||
|
|
||||||
// MARK: - Outcomes
|
// MARK: - Outcomes
|
||||||
|
|
||||||
private func apply(_ outcome: GitCommitOutcome) {
|
private func apply(_ outcome: GitCommitOutcome, healPaths: Set<String> = []) {
|
||||||
switch outcome {
|
switch outcome {
|
||||||
case let .committed(oids):
|
case let .committed(landed):
|
||||||
|
let oids = landed.map(\.oid)
|
||||||
pause = nil
|
pause = nil
|
||||||
lastFailure = nil
|
lastFailure = nil
|
||||||
commitCount += oids.count
|
commitCount += oids.count
|
||||||
lastCommitOIDs = oids
|
lastCommitOIDs = oids
|
||||||
|
// **The stack hears about every commit this engine lands** (06 ▸ Rules ▸ The stack is
|
||||||
|
// HEAD's first-parent ancestry, live), *before* the receipts that describe them are
|
||||||
|
// cleared below — the heal class exists only for as long as they do.
|
||||||
|
reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths))
|
||||||
// The window is over: its receipts have said everything they can say, and keeping them
|
// The window is over: its receipts have said everything they can say, and keeping them
|
||||||
// would let them vouch for the *next* window's changes to the same paths.
|
// would let them vouch for the *next* window's changes to the same paths.
|
||||||
harvested.removeAll()
|
harvested.removeAll()
|
||||||
|
|||||||
@@ -110,6 +110,38 @@ public struct GitChangedPath: Sendable, Equatable, Hashable {
|
|||||||
|
|
||||||
// MARK: - A planned commit
|
// MARK: - A planned commit
|
||||||
|
|
||||||
|
/// **Which of 06's classes a planned commit belongs to** — carried through the libgit2 work so a
|
||||||
|
/// landed commit can be recognized by the class that planned it.
|
||||||
|
///
|
||||||
|
/// It exists for one consumer: the undo provider's **heal transparency** (06-history-undo.md ▸ Rules
|
||||||
|
/// ▸ Heal commits are transparent to undo, in-session: "heal-class commits — their paths known by the
|
||||||
|
/// Writer's heal-marked receipts — never become undo steps"). Receipts live on the main actor and are
|
||||||
|
/// cleared the moment a window commits, so the only way the stack can ever learn *which commit* was
|
||||||
|
/// the heal is to be told at the moment it lands.
|
||||||
|
///
|
||||||
|
/// A tag rather than a re-derivation, deliberately: a plan whose staging produced HEAD's tree is
|
||||||
|
/// skipped and lands no commit at all, so the oids that come back are not positionally alignable with
|
||||||
|
/// the plans that were submitted.
|
||||||
|
public enum PlannedCommitKind: String, Sendable, Equatable, CaseIterable {
|
||||||
|
/// A repository's first commit — "Initial board state", never split (06 ▸ Rules ▸ Abnormal repo
|
||||||
|
/// states).
|
||||||
|
case root
|
||||||
|
case foreign
|
||||||
|
case heal
|
||||||
|
case user
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One commit that actually landed: its oid, and the class of the plan that made it.
|
||||||
|
public struct GitLandedCommit: Sendable, Equatable {
|
||||||
|
public let oid: String
|
||||||
|
public let kind: PlannedCommitKind
|
||||||
|
|
||||||
|
public init(oid: String, kind: PlannedCommitKind) {
|
||||||
|
self.oid = oid
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
|
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
|
||||||
///
|
///
|
||||||
/// A value rather than a call, because the flush's whole decision — the three-way split, the
|
/// A value rather than a call, because the flush's whole decision — the three-way split, the
|
||||||
@@ -138,19 +170,33 @@ public struct PlannedCommit: Sendable, Equatable {
|
|||||||
/// itself.
|
/// itself.
|
||||||
public let committer: GitIdentity
|
public let committer: GitIdentity
|
||||||
|
|
||||||
public init(paths: [String], message: String, author: GitIdentity, committer: GitIdentity) {
|
/// Which of 06's classes planned this — carried so the landed commit can be recognized by it.
|
||||||
|
/// See `PlannedCommitKind`; defaulted so a caller with only one class to make (add-git's root
|
||||||
|
/// commit, the undo provider's restore) says nothing about a split it is not part of.
|
||||||
|
public let kind: PlannedCommitKind
|
||||||
|
|
||||||
|
public init(
|
||||||
|
paths: [String],
|
||||||
|
message: String,
|
||||||
|
author: GitIdentity,
|
||||||
|
committer: GitIdentity,
|
||||||
|
kind: PlannedCommitKind = .user
|
||||||
|
) {
|
||||||
self.paths = paths
|
self.paths = paths
|
||||||
self.message = message
|
self.message = message
|
||||||
self.author = author
|
self.author = author
|
||||||
self.committer = committer
|
self.committer = committer
|
||||||
|
self.kind = kind
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a flush ended — the four outcomes 06 gives the committer, and no fifth.
|
/// How a flush ended — the four outcomes 06 gives the committer, and no fifth.
|
||||||
public enum GitCommitOutcome: Sendable, Equatable {
|
public enum GitCommitOutcome: Sendable, Equatable {
|
||||||
|
|
||||||
/// One commit per planned commit that had anything in it, oldest first.
|
/// One commit per planned commit that had anything in it, oldest first — each carrying the class
|
||||||
case committed([String])
|
/// of the plan that made it (`PlannedCommitKind`), which is how heal transparency reaches the
|
||||||
|
/// undo stack.
|
||||||
|
case committed([GitLandedCommit])
|
||||||
|
|
||||||
/// **The happy path, not a malfunction** (06 ▸ Interaction with external writers): the tree had
|
/// **The happy path, not a malfunction** (06 ▸ Interaction with external writers): the tree had
|
||||||
/// nothing to commit — an agent already committed its own work, or the window held only paths
|
/// nothing to commit — an agent already committed its own work, or the window held only paths
|
||||||
@@ -452,11 +498,11 @@ enum GitCommitOperation {
|
|||||||
// own terms.
|
// own terms.
|
||||||
resetIndexToHead(index, in: repository)
|
resetIndexToHead(index, in: repository)
|
||||||
|
|
||||||
var landed: [String] = []
|
var landed: [GitLandedCommit] = []
|
||||||
for plan in commits {
|
for plan in commits {
|
||||||
switch commit(plan, in: repository, index: index) {
|
switch commit(plan, in: repository, index: index) {
|
||||||
case let .landed(oid):
|
case let .landed(oid):
|
||||||
landed.append(oid)
|
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
|
||||||
case .skipped:
|
case .skipped:
|
||||||
continue
|
continue
|
||||||
case let .stopped(outcome):
|
case let .stopped(outcome):
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - GitHistoryProvider
|
||||||
|
|
||||||
|
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
|
||||||
|
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
|
||||||
|
/// designed around.
|
||||||
|
///
|
||||||
|
/// ### The stack is not a stack
|
||||||
|
///
|
||||||
|
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
|
||||||
|
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
|
||||||
|
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
|
||||||
|
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
|
||||||
|
/// redo list of the commits already crossed in this session. Both are caches over a repository that
|
||||||
|
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
|
||||||
|
/// property of the shape rather than a discipline.
|
||||||
|
///
|
||||||
|
/// ### Four rules, and where each one lives
|
||||||
|
///
|
||||||
|
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
|
||||||
|
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
|
||||||
|
/// only move forward.
|
||||||
|
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
|
||||||
|
/// crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
|
||||||
|
/// steps back over *them* rather than silently reverting twenty minutes of their work.
|
||||||
|
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
|
||||||
|
/// `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
|
||||||
|
/// requires (below).
|
||||||
|
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
|
||||||
|
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
|
||||||
|
///
|
||||||
|
/// ### Heal transparency, and its honest limit
|
||||||
|
///
|
||||||
|
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
|
||||||
|
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
|
||||||
|
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
|
||||||
|
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
|
||||||
|
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
|
||||||
|
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
|
||||||
|
/// 06 and not worked around here.
|
||||||
|
///
|
||||||
|
/// ### Asynchrony
|
||||||
|
///
|
||||||
|
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
|
||||||
|
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
|
||||||
|
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
|
||||||
|
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class GitHistoryProvider: HistoryProviding {
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
|
||||||
|
/// The board this is the history of — in git mode, the repository's working-tree root.
|
||||||
|
public let boardRoot: URL
|
||||||
|
|
||||||
|
// MARK: - Seams
|
||||||
|
|
||||||
|
/// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
|
||||||
|
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
|
||||||
|
///
|
||||||
|
/// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
|
||||||
|
/// own — the forward trail would be missing the very version the undo is stepping back from.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var flushPendingCommit: (@MainActor () async -> Void)?
|
||||||
|
|
||||||
|
/// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
|
||||||
|
/// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
|
||||||
|
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var isHeld: (@MainActor () -> Bool)?
|
||||||
|
|
||||||
|
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
|
||||||
|
/// half-committed by a timer that fires mid-materialization.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var suspendCommitting: (@MainActor () -> Void)?
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
public var resumeCommitting: (@MainActor () -> Void)?
|
||||||
|
|
||||||
|
/// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
|
||||||
|
/// board with no card windows to settle — every storeless test, and a session composed before any
|
||||||
|
/// window opened.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
|
||||||
|
|
||||||
|
/// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
|
||||||
|
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
|
||||||
|
/// `nil` runs the work bare, which is what a repository-level test wants.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||||
|
|
||||||
|
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||||
|
|
||||||
|
// MARK: - The cached stack
|
||||||
|
|
||||||
|
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
|
||||||
|
public private(set) var ancestry: [GitCommitRecord] = []
|
||||||
|
|
||||||
|
/// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
|
||||||
|
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
|
||||||
|
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
|
||||||
|
/// so labels never nest" (06 ▸ Commit messages).
|
||||||
|
public private(set) var pointerOID: String?
|
||||||
|
|
||||||
|
/// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
|
||||||
|
/// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
|
||||||
|
public private(set) var redoCommits: [GitCommitRecord] = []
|
||||||
|
|
||||||
|
/// The HEAD this cache was built against — the pre-flight sync's comparison.
|
||||||
|
private var knownHead: String?
|
||||||
|
|
||||||
|
/// Heal-class commits landed **in this session**, which the pointer passes over.
|
||||||
|
private var healOIDs: Set<String> = []
|
||||||
|
|
||||||
|
/// Paths committed as heal work in this session, which a restore never materializes.
|
||||||
|
private var healPaths: Set<String> = []
|
||||||
|
|
||||||
|
/// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
|
||||||
|
public private(set) var isCrossing = false
|
||||||
|
|
||||||
|
/// How many restores this provider has landed — the trail's own testimony, so a test need not
|
||||||
|
/// infer a crossing from a commit walk.
|
||||||
|
public private(set) var restoreCount = 0
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||||
|
|
||||||
|
public init(boardRoot: URL) {
|
||||||
|
self.boardRoot = boardRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Seeding
|
||||||
|
|
||||||
|
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
|
||||||
|
/// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
|
||||||
|
/// relaunch; ▸ Branch switching: "The undo/redo stack does not survive a switch. It is discarded
|
||||||
|
/// and reseeded from the new HEAD's first-parent ancestry … redo starts empty").
|
||||||
|
///
|
||||||
|
/// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
|
||||||
|
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
|
||||||
|
public func reseed() async {
|
||||||
|
let root = boardRoot
|
||||||
|
let records = await Task.detached(priority: .userInitiated) {
|
||||||
|
GitHistoryWalk.ancestry(at: root)
|
||||||
|
}.value
|
||||||
|
adopt(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `reseed()` without waiting — what a board open and an add-git flip call.
|
||||||
|
public func seed() {
|
||||||
|
Task { await reseed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reseed's main-actor half, split out so the sync path can reuse it.
|
||||||
|
private func adopt(_ records: [GitCommitRecord]) {
|
||||||
|
ancestry = records
|
||||||
|
knownHead = records.first?.oid
|
||||||
|
pointerOID = records.first?.oid
|
||||||
|
redoCommits = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
|
||||||
|
/// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
|
||||||
|
/// app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly
|
||||||
|
/// **one** commit."
|
||||||
|
///
|
||||||
|
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
|
||||||
|
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
|
||||||
|
/// thereby one rule."
|
||||||
|
private func syncToHEAD() async {
|
||||||
|
let root = boardRoot
|
||||||
|
let head = await Task.detached(priority: .userInitiated) {
|
||||||
|
GitHistoryWalk.headOID(at: root)
|
||||||
|
}.value
|
||||||
|
guard head != knownHead else { return }
|
||||||
|
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
|
||||||
|
await reseed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - What the committer tells it
|
||||||
|
|
||||||
|
/// **A flush landed** — `GitAutoCommitter.reportLanded`.
|
||||||
|
///
|
||||||
|
/// Two behaviours, and the split between them is the heal rule:
|
||||||
|
///
|
||||||
|
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
|
||||||
|
/// they were, and only records what was healed. That is what keeps an undo run from being
|
||||||
|
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
|
||||||
|
/// undo run continues past it" (06).
|
||||||
|
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
|
||||||
|
/// stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
|
||||||
|
/// ⌘Z crosses what just happened.
|
||||||
|
public func noteLanded(_ window: GitLandedWindow) {
|
||||||
|
healOIDs.formUnion(window.healOIDs)
|
||||||
|
healPaths.formUnion(window.healPaths)
|
||||||
|
guard !window.commits.isEmpty else { return }
|
||||||
|
|
||||||
|
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
|
||||||
|
// the cache catches up on its own turn. `settled()` is how anything that must not race it
|
||||||
|
// waits — `cross(_:)` first of all.
|
||||||
|
refresh = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if window.isEntirelyHeal {
|
||||||
|
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
|
||||||
|
// the redo list are untouched.
|
||||||
|
await self.refreshAncestryKeepingPointer()
|
||||||
|
} else {
|
||||||
|
await self.reseed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
|
||||||
|
@ObservationIgnored
|
||||||
|
private var refresh: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
|
||||||
|
/// landed" is a fact to await rather than a race.
|
||||||
|
///
|
||||||
|
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
|
||||||
|
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
|
||||||
|
public func settled() async {
|
||||||
|
await refresh?.value
|
||||||
|
refresh = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
|
||||||
|
/// path, and the one every successful restore takes.
|
||||||
|
private func refreshAncestryKeepingPointer() async {
|
||||||
|
let root = boardRoot
|
||||||
|
let records = await Task.detached(priority: .userInitiated) {
|
||||||
|
GitHistoryWalk.ancestry(at: root)
|
||||||
|
}.value
|
||||||
|
ancestry = records
|
||||||
|
knownHead = records.first?.oid
|
||||||
|
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
|
||||||
|
// The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
|
||||||
|
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
|
||||||
|
// top, redo empty.
|
||||||
|
adopt(records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HistoryProviding
|
||||||
|
|
||||||
|
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's
|
||||||
|
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers
|
||||||
|
/// against whatever provider the session bound, and this one has a repository to read instead —
|
||||||
|
/// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is
|
||||||
|
/// the substrate" (14 ▸ C1) means in code.
|
||||||
|
public func register(_ step: HistoryStep) {}
|
||||||
|
|
||||||
|
public var canUndo: Bool {
|
||||||
|
guard !isCrossing, isHeld?() != true else { return false }
|
||||||
|
return crossableIndex() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var canRedo: Bool {
|
||||||
|
guard !isCrossing, isHeld?() != true else { return false }
|
||||||
|
return !redoCommits.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
|
||||||
|
/// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
|
||||||
|
/// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
|
||||||
|
public var undoActionName: String? {
|
||||||
|
guard canUndo, let index = crossableIndex() else { return nil }
|
||||||
|
return ancestry[index].subject
|
||||||
|
}
|
||||||
|
|
||||||
|
public var redoActionName: String? {
|
||||||
|
guard canRedo else { return nil }
|
||||||
|
return redoCommits.last?.subject
|
||||||
|
}
|
||||||
|
|
||||||
|
public func undo() {
|
||||||
|
Task { await cross(.undo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func redo() {
|
||||||
|
Task { await cross(.redo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
|
||||||
|
/// a board reopened a second later has exactly the same trail.
|
||||||
|
public func clear() {
|
||||||
|
ancestry = []
|
||||||
|
pointerOID = nil
|
||||||
|
redoCommits = []
|
||||||
|
knownHead = nil
|
||||||
|
healOIDs = []
|
||||||
|
healPaths = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The crossing
|
||||||
|
|
||||||
|
/// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
|
||||||
|
public func cross(_ direction: HistoryDirection) async {
|
||||||
|
guard !isCrossing, isHeld?() != true else { return }
|
||||||
|
isCrossing = true
|
||||||
|
defer { isCrossing = false }
|
||||||
|
|
||||||
|
// **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
|
||||||
|
// whatever the debounce is still holding becomes a commit of its own before a restore lands
|
||||||
|
// on top of it, so both states exist in the trail.
|
||||||
|
await flushPendingCommit?()
|
||||||
|
await settled()
|
||||||
|
await syncToHEAD()
|
||||||
|
|
||||||
|
switch direction {
|
||||||
|
case .undo:
|
||||||
|
guard let index = crossableIndex() else { return }
|
||||||
|
let crossed = ancestry[index]
|
||||||
|
guard let target = crossed.parentOID else { return }
|
||||||
|
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
|
||||||
|
guard landed else { return }
|
||||||
|
redoCommits.append(crossed)
|
||||||
|
pointerOID = target
|
||||||
|
await refreshAncestryKeepingPointer()
|
||||||
|
|
||||||
|
case .redo:
|
||||||
|
guard let target = redoCommits.last else { return }
|
||||||
|
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
|
||||||
|
guard landed else { return }
|
||||||
|
redoCommits.removeLast()
|
||||||
|
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
|
||||||
|
// with the pointer where the undo found it.
|
||||||
|
pointerOID = target.oid
|
||||||
|
await refreshAncestryKeepingPointer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializes one target state as a new commit. Answers whether the crossing may advance.
|
||||||
|
///
|
||||||
|
/// `message` is both the commit's subject and the bracket's completion announcement
|
||||||
|
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||||
|
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
|
||||||
|
private func restore(to target: String, message: String) async -> Bool {
|
||||||
|
let root = boardRoot
|
||||||
|
let excluded = healPaths
|
||||||
|
|
||||||
|
// The **preliminary** plan: what the restore would write, which is the only thing that can
|
||||||
|
// say whether any open session is in its way.
|
||||||
|
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
||||||
|
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
||||||
|
}).value else {
|
||||||
|
report("this board's repository could not be read")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var reconciling: Set<String> = []
|
||||||
|
if !preliminary.isEmpty, let settleSessions {
|
||||||
|
switch await settleSessions(Set(preliminary.paths)) {
|
||||||
|
case .cancelled, .failed:
|
||||||
|
// "Cancel keeps everything" — and a raw buffer that would not validate cancels the
|
||||||
|
// whole restore, focused on the offender (06 ▸ Branch switching).
|
||||||
|
return false
|
||||||
|
case .proceed:
|
||||||
|
reconciling = discardedFolders
|
||||||
|
discardedFolders = []
|
||||||
|
}
|
||||||
|
if reconciling.isEmpty {
|
||||||
|
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
|
||||||
|
// recomputed below against the HEAD that now exists rather than the one it was
|
||||||
|
// drafted against.
|
||||||
|
//
|
||||||
|
// **Discard deliberately does not flush.** Ending a session un-stages-around its
|
||||||
|
// folder, so a flush here would commit exactly the uncommitted saves the user just
|
||||||
|
// asked to lose — a Discard that wrote them into history forever. Nothing is left
|
||||||
|
// behind by skipping it: everything else pending was already flushed at the top of
|
||||||
|
// the crossing, and the discarded folder is reconciled against the working tree by
|
||||||
|
// the plan itself.
|
||||||
|
await flushPendingCommit?()
|
||||||
|
await settled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound before the closure that crosses actors reads it — the settle step is over, and what
|
||||||
|
// it decided is a value from here on.
|
||||||
|
let folders = reconciling
|
||||||
|
var landed = false
|
||||||
|
let work: @MainActor () async -> Void = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.suspendCommitting?()
|
||||||
|
defer { self.resumeCommitting?() }
|
||||||
|
let outcome = await Task.detached(priority: .userInitiated, operation: {
|
||||||
|
guard let plan = GitRestoreOperation.plan(
|
||||||
|
at: root,
|
||||||
|
target: target,
|
||||||
|
excluding: excluded,
|
||||||
|
reconciling: folders
|
||||||
|
) else {
|
||||||
|
return GitCommitOutcome.failed(GitOperationFailure(
|
||||||
|
operation: GitRestoreOperation.operationName,
|
||||||
|
message: "this board's repository could not be read"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return GitRestoreOperation.apply(plan, at: root, message: message)
|
||||||
|
}).value
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case .committed:
|
||||||
|
self.restoreCount += 1
|
||||||
|
landed = true
|
||||||
|
case .nothingToCommit:
|
||||||
|
// The step was crossed and needed no bytes — every path its diff would have written
|
||||||
|
// was heal work, or the two states are byte-identical. The pointer still advances:
|
||||||
|
// a step that changed nothing is still a step the user asked to walk past.
|
||||||
|
landed = true
|
||||||
|
case .locked:
|
||||||
|
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
|
||||||
|
// in-progress banner row is the branch card's surface. Here the honest answer is to
|
||||||
|
// leave the stack where it is so ⌘Z can simply be pressed again.
|
||||||
|
Self.logger.debug("restore found index.lock held — the stack is unchanged")
|
||||||
|
case let .held(pause):
|
||||||
|
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
||||||
|
case let .failed(failure):
|
||||||
|
self.reportFailure?(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let runBracketed {
|
||||||
|
await runBracketed(message, work)
|
||||||
|
} else {
|
||||||
|
await work()
|
||||||
|
}
|
||||||
|
return landed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
|
||||||
|
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
|
||||||
|
private var discardedFolders: Set<String> = []
|
||||||
|
|
||||||
|
/// **A settle step discarded this card's session** — its folder is compared against the working
|
||||||
|
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
|
||||||
|
/// reverted by the restore itself rather than by a second pass that could disagree with it
|
||||||
|
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
||||||
|
public func noteDiscarded(cardFolderPath: String) {
|
||||||
|
discardedFolders.insert(cardFolderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The pointer
|
||||||
|
|
||||||
|
/// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
|
||||||
|
///
|
||||||
|
/// Two commits are never steps:
|
||||||
|
///
|
||||||
|
/// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
|
||||||
|
/// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
|
||||||
|
/// is the empty tree: crossing it would delete every file the board has ever had, in one
|
||||||
|
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
|
||||||
|
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
|
||||||
|
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
|
||||||
|
/// lost either way: the commit stays reachable in any git client.)
|
||||||
|
private func crossableIndex() -> Int? {
|
||||||
|
guard !ancestry.isEmpty else { return nil }
|
||||||
|
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
|
||||||
|
for index in start..<ancestry.count {
|
||||||
|
let commit = ancestry[index]
|
||||||
|
guard !healOIDs.contains(commit.oid) else { continue }
|
||||||
|
guard commit.parentOID != nil else { return nil }
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func report(_ message: String) {
|
||||||
|
reportFailure?(GitOperationFailure(
|
||||||
|
operation: GitRestoreOperation.operationName,
|
||||||
|
message: message
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftGitX
|
||||||
|
|
||||||
|
// MARK: - GitCommitRecord
|
||||||
|
|
||||||
|
/// **One commit, flattened to what a stack and a sidebar need.**
|
||||||
|
///
|
||||||
|
/// The undo stack reads `oid`, `parentOID` and `subject`; the card window's History section reads
|
||||||
|
/// `subject`, `authorName` and `date` (05-card-window.md ▸ History: "semantic subject, relative date,
|
||||||
|
/// author"). One value rather than two because they are the same walk read twice, and a second record
|
||||||
|
/// type would be a second definition of what a commit is.
|
||||||
|
public struct GitCommitRecord: Sendable, Equatable, Identifiable {
|
||||||
|
|
||||||
|
/// The full hex oid. `id` too — a commit is its hash, and nothing in this app ever shows two
|
||||||
|
/// records for one commit.
|
||||||
|
public let oid: String
|
||||||
|
|
||||||
|
/// The commit's first line, exactly as the message engine wrote it ("Move card 'Fix login' to
|
||||||
|
/// Doing"). The undo menu's label and the History row's headline are both this string.
|
||||||
|
public let subject: String
|
||||||
|
|
||||||
|
/// The **author**, which is where origin lives (06-history-undo.md ▸ Interaction with external
|
||||||
|
/// writers: "Origin lives in the author field … not in message prose"). So a foreign commit's row
|
||||||
|
/// reads `Lanework External` and a stamped agent's reads its own name, with no rendering rule of
|
||||||
|
/// its own.
|
||||||
|
public let authorName: String
|
||||||
|
|
||||||
|
/// The author's timestamp — what "2 days ago" is relative to.
|
||||||
|
public let date: Date
|
||||||
|
|
||||||
|
/// The **first** parent, or `nil` for a root commit. First-parent only, because the whole stack
|
||||||
|
/// is defined as first-parent ancestry and a merge's second parent is a different history.
|
||||||
|
public let parentOID: String?
|
||||||
|
|
||||||
|
public var id: String { oid }
|
||||||
|
|
||||||
|
public init(oid: String, subject: String, authorName: String, date: Date, parentOID: String?) {
|
||||||
|
self.oid = oid
|
||||||
|
self.subject = subject
|
||||||
|
self.authorName = authorName
|
||||||
|
self.date = date
|
||||||
|
self.parentOID = parentOID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GitHistoryWalk
|
||||||
|
|
||||||
|
/// **HEAD's first-parent ancestry, read** (06-history-undo.md ▸ Rules ▸ The stack is HEAD's
|
||||||
|
/// first-parent ancestry, live) — the one walk both this milestone's surfaces are built on.
|
||||||
|
///
|
||||||
|
/// ### Why the walk is the stack
|
||||||
|
///
|
||||||
|
/// "The undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty … no sidecar
|
||||||
|
/// state, nothing ever lost" (06 ▸ Rules ▸ Undo survives relaunch). There is therefore no persisted
|
||||||
|
/// stack to read and nothing to keep in step with the repository: the repository *is* the stack, and
|
||||||
|
/// this file is how it is spelled out. In-session and post-relaunch are one rule because they are one
|
||||||
|
/// function.
|
||||||
|
///
|
||||||
|
/// ### Isolation
|
||||||
|
///
|
||||||
|
/// `GitRepository`'s rule, unchanged: every function is `nonisolated`, opens its own `Repository`, and
|
||||||
|
/// confines the handle to its own synchronous scope. Callers reach these through `Task.detached`, so
|
||||||
|
/// the main actor never blocks on libgit2 and libgit2 never sees two threads on one handle.
|
||||||
|
enum GitHistoryWalk {
|
||||||
|
|
||||||
|
/// How far back a walk goes.
|
||||||
|
///
|
||||||
|
/// A cap rather than an unbounded walk for `GitRepository.pathFirstAppearanceRanks`' reason: a
|
||||||
|
/// board with years of history must not spend a second answering "can I undo?". The cost of the
|
||||||
|
/// cap is that the oldest steps of a very long trail are unreachable by ⌘Z, which is the same
|
||||||
|
/// bound every undo stack has ever had, and the whole trail stays inspectable in any git client —
|
||||||
|
/// the property 06 actually promises.
|
||||||
|
static let defaultLimit = 512
|
||||||
|
|
||||||
|
/// HEAD's oid, or `nil` on an unborn HEAD or a repository that will not open.
|
||||||
|
///
|
||||||
|
/// **The pre-flight sync's whole question** (06: "The stack re-syncs its top to HEAD before every
|
||||||
|
/// undo/redo — self-commits move HEAD outside the app's committer; the pre-flight sync is how the
|
||||||
|
/// stack learns"). One reference read, which is why the sync can afford to run before every
|
||||||
|
/// crossing.
|
||||||
|
nonisolated static func headOID(at boardRoot: URL) -> String? {
|
||||||
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||||
|
let repository = try? Repository.open(at: boardRoot),
|
||||||
|
!repository.isHEADUnborn,
|
||||||
|
let head = try? repository.HEAD,
|
||||||
|
let commit = head.target as? Commit else { return nil }
|
||||||
|
return commit.id.hex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HEAD's first-parent ancestry, **newest first** — index 0 is HEAD.
|
||||||
|
///
|
||||||
|
/// An unborn HEAD answers `[]`, which is exactly "the undo trail simply starts empty" (06 ▸ Rules
|
||||||
|
/// ▸ Abnormal repo states) with no case of its own.
|
||||||
|
nonisolated static func ancestry(at boardRoot: URL, limit: Int = defaultLimit) -> [GitCommitRecord] {
|
||||||
|
guard BoardGitMode.hasGitEntry(at: boardRoot),
|
||||||
|
let repository = try? Repository.open(at: boardRoot),
|
||||||
|
!repository.isHEADUnborn,
|
||||||
|
let head = try? repository.HEAD,
|
||||||
|
let tip = head.target as? Commit else { return [] }
|
||||||
|
|
||||||
|
var records: [GitCommitRecord] = []
|
||||||
|
var current: Commit? = tip
|
||||||
|
while let commit = current, records.count < max(0, limit) {
|
||||||
|
let parent = (try? commit.parents)?.first
|
||||||
|
records.append(record(commit, parent: parent))
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Every commit that touched one card's folder, newest first** — the card window's History
|
||||||
|
/// section (05-card-window.md ▸ History).
|
||||||
|
///
|
||||||
|
/// ### Following the card is matching its own folder name
|
||||||
|
///
|
||||||
|
/// "The listing **follows the card across lane moves** (path changes; the UUID folder is the
|
||||||
|
/// identity to track)." A card's folder *is* its identity: `‹lane-uuid›/‹card-uuid›/index.md`, so
|
||||||
|
/// a lane move rewrites the first component and never the second. Matching on the card's own
|
||||||
|
/// folder component therefore follows it across every move it can make — into another lane, into
|
||||||
|
/// `.trash/`, back out again — with no rename detection to be defeated by a large diff, and no
|
||||||
|
/// `--follow` heuristic to disagree with git's own answer. (`GitRepository.pathFirstAppearanceRanks`
|
||||||
|
/// records the opposite trade for its own question: it does *not* follow renames, and says so.)
|
||||||
|
///
|
||||||
|
/// The walk is HEAD's first-parent ancestry, so the trail a card shows is the trail its board's
|
||||||
|
/// current branch has — which is what makes a branch switch change it for free.
|
||||||
|
nonisolated static func commitsTouching(
|
||||||
|
folderNamed name: String,
|
||||||
|
at boardRoot: URL,
|
||||||
|
limit: Int = defaultLimit
|
||||||
|
) -> [GitCommitRecord] {
|
||||||
|
guard !name.isEmpty,
|
||||||
|
BoardGitMode.hasGitEntry(at: boardRoot),
|
||||||
|
let repository = try? Repository.open(at: boardRoot),
|
||||||
|
!repository.isHEADUnborn,
|
||||||
|
let head = try? repository.HEAD,
|
||||||
|
let tip = head.target as? Commit else { return [] }
|
||||||
|
|
||||||
|
var records: [GitCommitRecord] = []
|
||||||
|
var current: Commit? = tip
|
||||||
|
var walked = 0
|
||||||
|
while let commit = current, walked < max(0, limit) {
|
||||||
|
walked += 1
|
||||||
|
let parent = (try? commit.parents)?.first
|
||||||
|
if touches(commit, folderNamed: name, parent: parent, in: repository) {
|
||||||
|
records.append(record(commit, parent: parent))
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a
|
||||||
|
/// prefix of another's cannot borrow its history.
|
||||||
|
nonisolated static func path(_ path: String, isInsideFolderNamed name: String) -> Bool {
|
||||||
|
path.split(separator: "/").dropLast().contains { $0 == name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private static func record(_ commit: Commit, parent: Commit?) -> GitCommitRecord {
|
||||||
|
GitCommitRecord(
|
||||||
|
oid: commit.id.hex,
|
||||||
|
subject: commit.summary,
|
||||||
|
authorName: commit.author.name,
|
||||||
|
date: commit.author.date,
|
||||||
|
parentOID: parent?.id.hex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether one commit's diff against its first parent mentions the folder.
|
||||||
|
///
|
||||||
|
/// **A root commit is diffed against nothing**, so its whole tree counts as touched — the same
|
||||||
|
/// reading `pathFirstAppearanceRanks` gives a walk's base, and the honest one: every file in a
|
||||||
|
/// root commit arrived in it.
|
||||||
|
private static func touches(
|
||||||
|
_ commit: Commit,
|
||||||
|
folderNamed name: String,
|
||||||
|
parent: Commit?,
|
||||||
|
in repository: Repository
|
||||||
|
) -> Bool {
|
||||||
|
guard parent != nil else {
|
||||||
|
return treePaths(of: commit, in: repository).contains { path($0, isInsideFolderNamed: name) }
|
||||||
|
}
|
||||||
|
guard let diff = try? repository.diff(commit: commit) else { return false }
|
||||||
|
return diff.changes.contains { delta in
|
||||||
|
path(delta.newFile.path, isInsideFolderNamed: name)
|
||||||
|
|| path(delta.oldFile.path, isInsideFolderNamed: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every blob path under a commit's tree — `GitRepository.filePaths`' twin, kept here rather than
|
||||||
|
/// shared because that one is `private` to a file with a different job.
|
||||||
|
private static func treePaths(of commit: Commit, in repository: Repository) -> [String] {
|
||||||
|
guard let tree = try? commit.tree else { return [] }
|
||||||
|
var paths: [String] = []
|
||||||
|
|
||||||
|
func walk(_ tree: Tree, prefix: String, depth: Int) {
|
||||||
|
guard depth < 8 else { return }
|
||||||
|
for entry in tree.entries {
|
||||||
|
let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name
|
||||||
|
if entry.type == .tree {
|
||||||
|
guard let subtree: Tree = try? repository.show(id: entry.id) else { continue }
|
||||||
|
walk(subtree, prefix: path, depth: depth + 1)
|
||||||
|
} else {
|
||||||
|
paths.append(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree, prefix: "", depth: 0)
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import Foundation
|
||||||
|
import libgit2
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - The plan
|
||||||
|
|
||||||
|
/// **One restore, as the writes it will make** — computed before anything touches the working tree,
|
||||||
|
/// so the whole of what a ⌘Z is about to do is a value a caller can inspect, gate on, and test.
|
||||||
|
public struct GitRestorePlan: Sendable, Equatable {
|
||||||
|
|
||||||
|
/// One file the restore will write or remove.
|
||||||
|
public struct Change: Sendable, Equatable {
|
||||||
|
/// Board-root-relative, in git's own spelling.
|
||||||
|
public let path: String
|
||||||
|
/// The bytes to write, or `nil` to remove the file.
|
||||||
|
public let contents: Data?
|
||||||
|
|
||||||
|
public init(path: String, contents: Data?) {
|
||||||
|
self.path = path
|
||||||
|
self.contents = contents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public let changes: [Change]
|
||||||
|
|
||||||
|
public init(changes: [Change]) {
|
||||||
|
self.changes = changes
|
||||||
|
}
|
||||||
|
|
||||||
|
public var paths: [String] { changes.map(\.path) }
|
||||||
|
|
||||||
|
public var isEmpty: Bool { changes.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GitRestoreOperation
|
||||||
|
|
||||||
|
/// **Undo and redo, as forward commits** (14-git-operations.md ▸ The forward-restore model; the
|
||||||
|
/// load-bearing extraction): "Every restorative operation moves history forward. Nothing the app does
|
||||||
|
/// ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite."
|
||||||
|
///
|
||||||
|
/// ### What this file is allowed to call, and what it is not
|
||||||
|
///
|
||||||
|
/// It materializes an older state as **ordinary working-tree writes** and then commits them through
|
||||||
|
/// the same signature-capable path every auto-commit takes (`GitCommitOperation.perform`). It never
|
||||||
|
/// calls `git_reset`, never moves a reference by hand, never writes `refs/`, and never touches the
|
||||||
|
/// reflog: the only ref movement in the whole restore is `git_commit_create`'s own advance of HEAD,
|
||||||
|
/// which is what a commit *is*. That is the property "verifiable by trail inspection in any git
|
||||||
|
/// client" reduces to, and it is checkable here by reading the imports: nothing below resolves a
|
||||||
|
/// reset or a checkout symbol at all.
|
||||||
|
///
|
||||||
|
/// ### Only the diff, never the tree
|
||||||
|
///
|
||||||
|
/// "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" (06-history-undo.md ▸ Rules
|
||||||
|
/// ▸ Undo restore vs open Edit sessions). So the plan is HEAD's tree against the target's, file by
|
||||||
|
/// file — never a checkout of the whole target, which would sweep every unrelated file on the board
|
||||||
|
/// through a write it did not need.
|
||||||
|
///
|
||||||
|
/// Two deliberate narrowings ride on that:
|
||||||
|
///
|
||||||
|
/// - **`excluding`** — the heal-transparency rule's second half (06 ▸ Rules ▸ Heal commits are
|
||||||
|
/// transparent to undo): "a restore materializing an older target **excludes paths whose divergence
|
||||||
|
/// is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler."
|
||||||
|
/// - **`reconciling`** — the folders of card sessions the user chose to **Discard** at the
|
||||||
|
/// save-or-discard step (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to
|
||||||
|
/// HEAD"). Those folders are compared against the **working tree** rather than against HEAD,
|
||||||
|
/// because their uncommitted on-disk saves are precisely the state HEAD does not have — one pass
|
||||||
|
/// that both drops the discarded saves and applies the restore, instead of a revert followed by a
|
||||||
|
/// restore that would have to agree with it.
|
||||||
|
///
|
||||||
|
/// ### Isolation
|
||||||
|
///
|
||||||
|
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
|
||||||
|
/// the same synchronous scope, and no handle crosses an `await`. Called from a detached task.
|
||||||
|
enum GitRestoreOperation {
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||||
|
|
||||||
|
/// The operation name a failure carries into the banner (06 ▸ Interaction with external writers:
|
||||||
|
/// "surfaces as a one-shot banner failure naming the operation and the error").
|
||||||
|
static let operationName = "Restoring an earlier state"
|
||||||
|
|
||||||
|
/// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
|
||||||
|
private static let startUp: Bool = {
|
||||||
|
git_libgit2_init() >= 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Planning
|
||||||
|
|
||||||
|
/// **The writes that would turn the working tree into `target`'s state**, or `nil` when the
|
||||||
|
/// repository could not be read.
|
||||||
|
///
|
||||||
|
/// `nil` is emphatically not "nothing to do": a restore that silently did nothing because a tree
|
||||||
|
/// would not load is the one failure mode a forward-only undo could not explain afterwards.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - target: the oid of the commit whose state is being restored.
|
||||||
|
/// - excluding: board-root-relative paths whose divergence is heal work — never materialized.
|
||||||
|
/// - reconciling: board-root-relative folders compared against the working tree rather than
|
||||||
|
/// against HEAD (the Discard branch of the save-or-discard step).
|
||||||
|
nonisolated static func plan(
|
||||||
|
at boardRoot: URL,
|
||||||
|
target: String,
|
||||||
|
excluding: Set<String> = [],
|
||||||
|
reconciling: Set<String> = []
|
||||||
|
) -> GitRestorePlan? {
|
||||||
|
_ = startUp
|
||||||
|
guard let repository = open(boardRoot) else { return nil }
|
||||||
|
defer { git_repository_free(repository) }
|
||||||
|
|
||||||
|
guard let targetTree = tree(of: target, in: repository) else { return nil }
|
||||||
|
defer { git_tree_free(targetTree) }
|
||||||
|
var wanted: [String: git_oid] = [:]
|
||||||
|
fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted)
|
||||||
|
|
||||||
|
var current: [String: git_oid] = [:]
|
||||||
|
if let headTree = headTree(of: repository) {
|
||||||
|
defer { git_tree_free(headTree) }
|
||||||
|
fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: ¤t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconciled folders answer from disk instead: their committed state is beside the point,
|
||||||
|
// because what is being discarded is exactly what is *not* committed.
|
||||||
|
if !reconciling.isEmpty {
|
||||||
|
for folder in reconciling {
|
||||||
|
current = current.filter { !isInside($0.key, folder: folder) }
|
||||||
|
}
|
||||||
|
for path in workingTreeFiles(under: reconciling, at: boardRoot) {
|
||||||
|
// A sentinel oid nothing can equal: the comparison below only ever asks "same or
|
||||||
|
// different", and a working-tree file's bytes are not addressed by the object store.
|
||||||
|
current[path] = git_oid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var changes: [GitRestorePlan.Change] = []
|
||||||
|
for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) {
|
||||||
|
guard !excluding.contains(path) else { continue }
|
||||||
|
if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue }
|
||||||
|
guard let data = blob(oid, in: repository) else { continue }
|
||||||
|
changes.append(GitRestorePlan.Change(path: path, contents: data))
|
||||||
|
}
|
||||||
|
for path in current.keys.sorted() where wanted[path] == nil {
|
||||||
|
guard !excluding.contains(path) else { continue }
|
||||||
|
changes.append(GitRestorePlan.Change(path: path, contents: nil))
|
||||||
|
}
|
||||||
|
return GitRestorePlan(changes: changes.sorted { $0.path < $1.path })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Applying
|
||||||
|
|
||||||
|
/// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound.
|
||||||
|
///
|
||||||
|
/// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary
|
||||||
|
/// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore
|
||||||
|
/// is the user acting through the app, whatever the origin of the commit it crosses.
|
||||||
|
///
|
||||||
|
/// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip —
|
||||||
|
/// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no
|
||||||
|
/// bytes", not as a failure.
|
||||||
|
nonisolated static func apply(
|
||||||
|
_ plan: GitRestorePlan,
|
||||||
|
at boardRoot: URL,
|
||||||
|
message: String
|
||||||
|
) -> GitCommitOutcome {
|
||||||
|
_ = startUp
|
||||||
|
guard !plan.isEmpty else { return .nothingToCommit }
|
||||||
|
|
||||||
|
let manager = FileManager.default
|
||||||
|
for change in plan.changes {
|
||||||
|
let url = boardRoot.appendingPathComponent(change.path)
|
||||||
|
guard let contents = change.contents else {
|
||||||
|
try? manager.removeItem(at: url)
|
||||||
|
pruneEmptyFolders(above: url, upTo: boardRoot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let folder = url.deletingLastPathComponent()
|
||||||
|
do {
|
||||||
|
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try contents.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
logger.error("restore could not write \(change.path, privacy: .public)")
|
||||||
|
return .failed(GitOperationFailure(
|
||||||
|
operation: operationName,
|
||||||
|
message: (error as NSError).localizedDescription
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let identity = GitCommitOperation.userIdentity(at: boardRoot)
|
||||||
|
return GitCommitOperation.perform(
|
||||||
|
at: boardRoot,
|
||||||
|
commits: [PlannedCommit(
|
||||||
|
paths: plan.paths,
|
||||||
|
message: message,
|
||||||
|
author: identity,
|
||||||
|
committer: identity,
|
||||||
|
kind: .user
|
||||||
|
)],
|
||||||
|
allowRootCommit: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private plumbing
|
||||||
|
|
||||||
|
private static func open(_ boardRoot: URL) -> OpaquePointer? {
|
||||||
|
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
|
||||||
|
var repository: OpaquePointer?
|
||||||
|
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? {
|
||||||
|
var id = git_oid()
|
||||||
|
guard git_oid_fromstr(&id, oid) == 0 else { return nil }
|
||||||
|
var commit: OpaquePointer?
|
||||||
|
guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil }
|
||||||
|
defer { git_commit_free(commit) }
|
||||||
|
var tree: OpaquePointer?
|
||||||
|
guard git_commit_tree(&tree, commit) == 0 else { return nil }
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func headTree(of repository: OpaquePointer) -> OpaquePointer? {
|
||||||
|
guard git_repository_head_unborn(repository) != 1 else { return nil }
|
||||||
|
var reference: OpaquePointer?
|
||||||
|
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
|
||||||
|
defer { git_reference_free(reference) }
|
||||||
|
var object: OpaquePointer?
|
||||||
|
guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil }
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every blob under a tree, board-root-relative, with its object id.
|
||||||
|
///
|
||||||
|
/// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a
|
||||||
|
/// pathological repository, not a statement about boards.
|
||||||
|
private static func fileMap(
|
||||||
|
of tree: OpaquePointer,
|
||||||
|
in repository: OpaquePointer,
|
||||||
|
prefix: String,
|
||||||
|
depth: Int,
|
||||||
|
into map: inout [String: git_oid]
|
||||||
|
) {
|
||||||
|
guard depth < 8 else { return }
|
||||||
|
for position in 0..<git_tree_entrycount(tree) {
|
||||||
|
guard let entry = git_tree_entry_byindex(tree, position),
|
||||||
|
let rawName = git_tree_entry_name(entry),
|
||||||
|
let id = git_tree_entry_id(entry) else { continue }
|
||||||
|
let name = String(cString: rawName)
|
||||||
|
// A `/` in a tree entry name is impossible in a well-formed tree and would be a path
|
||||||
|
// escape if it were not: refuse rather than interpret (`GitHeadSnapshot`'s rule).
|
||||||
|
guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { continue }
|
||||||
|
let path = prefix.isEmpty ? name : prefix + "/" + name
|
||||||
|
|
||||||
|
switch git_tree_entry_type(entry) {
|
||||||
|
case GIT_OBJECT_TREE:
|
||||||
|
var child: OpaquePointer?
|
||||||
|
guard git_tree_lookup(&child, repository, id) == 0, let child else { continue }
|
||||||
|
defer { git_tree_free(child) }
|
||||||
|
fileMap(of: child, in: repository, prefix: path, depth: depth + 1, into: &map)
|
||||||
|
case GIT_OBJECT_BLOB:
|
||||||
|
map[path] = id.pointee
|
||||||
|
default:
|
||||||
|
// Submodules and symlinks: neither is a board, and neither is followed anywhere else
|
||||||
|
// in this app either.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func blob(_ oid: git_oid, in repository: OpaquePointer) -> Data? {
|
||||||
|
var id = oid
|
||||||
|
var blob: OpaquePointer?
|
||||||
|
guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil }
|
||||||
|
defer { git_blob_free(blob) }
|
||||||
|
let size = Int(git_blob_rawsize(blob))
|
||||||
|
guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() }
|
||||||
|
return Data(bytes: bytes, count: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked — it is
|
||||||
|
/// not part of any board's tree and nothing here may write into it.
|
||||||
|
private static func workingTreeFiles(under folders: Set<String>, at boardRoot: URL) -> [String] {
|
||||||
|
var found: [String] = []
|
||||||
|
for folder in folders {
|
||||||
|
let root = boardRoot.appendingPathComponent(folder)
|
||||||
|
guard let walker = FileManager.default.enumerator(
|
||||||
|
at: root,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey],
|
||||||
|
options: [.skipsHiddenFiles, .skipsPackageDescendants]
|
||||||
|
) else { continue }
|
||||||
|
for case let url as URL in walker {
|
||||||
|
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
|
||||||
|
else { continue }
|
||||||
|
guard let relative = relativePath(of: url, under: boardRoot) else { continue }
|
||||||
|
found.append(relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func relativePath(of url: URL, under boardRoot: URL) -> String? {
|
||||||
|
let root = boardRoot.standardizedFileURL.path
|
||||||
|
let path = url.standardizedFileURL.path
|
||||||
|
guard path.hasPrefix(root + "/") else { return nil }
|
||||||
|
return String(path.dropFirst(root.count + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isInside(_ path: String, folder: String) -> Bool {
|
||||||
|
path == folder || path.hasPrefix(folder + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isInside(_ path: String, folders: Set<String>) -> Bool {
|
||||||
|
folders.contains { isInside(path, folder: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes folders emptied by a deletion, up to (never including) the board root — the same
|
||||||
|
/// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty
|
||||||
|
/// UUID folders that the loader would then have to ignore.
|
||||||
|
private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) {
|
||||||
|
let manager = FileManager.default
|
||||||
|
let root = boardRoot.standardizedFileURL.path
|
||||||
|
var folder = file.deletingLastPathComponent().standardizedFileURL
|
||||||
|
while folder.path != root, folder.path.hasPrefix(root + "/") {
|
||||||
|
let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? []
|
||||||
|
guard contents.isEmpty || contents == [".DS_Store"] else { return }
|
||||||
|
try? manager.removeItem(at: folder)
|
||||||
|
folder = folder.deletingLastPathComponent().standardizedFileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
|
||||||
|
var left = lhs
|
||||||
|
var right = rhs
|
||||||
|
return git_oid_cmp(&left, &right) == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,18 @@ public final class HistoryStore {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var autoCommitWiring: ((GitAutoCommitter) -> Void)?
|
private var autoCommitWiring: ((GitAutoCommitter) -> Void)?
|
||||||
|
|
||||||
|
/// **The mid-session mode flip, announced** — called once, after a successful `addGit()`, and
|
||||||
|
/// never on any other path.
|
||||||
|
///
|
||||||
|
/// It exists because the flip has a second consumer beyond the committer: the board's **undo
|
||||||
|
/// substrate**. A session that composed on a mode-none board bound no provider at all
|
||||||
|
/// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip
|
||||||
|
/// means the board now has a trail to be an undo stack over. What binding it means is
|
||||||
|
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
|
||||||
|
/// keep that decision out of a git state that has no business knowing what a provider is.
|
||||||
|
@ObservationIgnored
|
||||||
|
public var didAddGit: (@MainActor () -> Void)?
|
||||||
|
|
||||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
||||||
|
|
||||||
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
|
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
|
||||||
@@ -189,6 +201,8 @@ public final class HistoryStore {
|
|||||||
self.committer = committer
|
self.committer = committer
|
||||||
autoCommitWiring?(committer)
|
autoCommitWiring?(committer)
|
||||||
committer.start()
|
committer.start()
|
||||||
|
// Last, after the mode and the committer: the undo binding reads both.
|
||||||
|
didAddGit?()
|
||||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||||
return true
|
return true
|
||||||
case .failure(let failure):
|
case .failure(let failure):
|
||||||
|
|||||||
@@ -48,7 +48,31 @@ public final class BoardUndoManager: UndoManager {
|
|||||||
|
|
||||||
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
|
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
|
||||||
/// only ever reachable while the session that made it is alive.
|
/// only ever reachable while the session that made it is alive.
|
||||||
private let history: any HistoryProviding
|
///
|
||||||
|
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
||||||
|
///
|
||||||
|
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all — "the pair disabled
|
||||||
|
/// on boards with no undo provider in the composed tier — under Pro, no-git and repo-nested
|
||||||
|
/// boards, matching their menu items" (03-board-ui.md ▸ Toolbar ▸ Catalog; 06-history-undo.md
|
||||||
|
/// ▸ Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar
|
||||||
|
/// pair, and ⌘Z itself go quiet together, through the same validation path a lock uses. Modelling
|
||||||
|
/// it as an absent substrate rather than as a substrate that always says no is the honest shape:
|
||||||
|
/// there is nothing there, and nothing can accidentally accumulate in it.
|
||||||
|
///
|
||||||
|
/// ### Settable, for exactly one event
|
||||||
|
///
|
||||||
|
/// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip:
|
||||||
|
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
||||||
|
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session
|
||||||
|
/// gains a commit trail, and a trail with a dead ⌘Z over it would read as a bug. The composition
|
||||||
|
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit
|
||||||
|
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`.
|
||||||
|
///
|
||||||
|
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
|
||||||
|
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
|
||||||
|
/// all — `BoardSession.tier` is a `let` with no setter. Mode can change, by explicit command,
|
||||||
|
/// and only in this one direction.)
|
||||||
|
var history: (any HistoryProviding)?
|
||||||
|
|
||||||
/// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
|
/// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
|
||||||
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
|
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
|
||||||
@@ -58,7 +82,7 @@ public final class BoardUndoManager: UndoManager {
|
|||||||
/// the adapter's own grammar — should have.
|
/// the adapter's own grammar — should have.
|
||||||
private let isReadOnly: @MainActor () -> Bool
|
private let isReadOnly: @MainActor () -> Bool
|
||||||
|
|
||||||
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
||||||
self.history = history
|
self.history = history
|
||||||
self.isReadOnly = isReadOnly
|
self.isReadOnly = isReadOnly
|
||||||
super.init()
|
super.init()
|
||||||
@@ -68,10 +92,11 @@ public final class BoardUndoManager: UndoManager {
|
|||||||
|
|
||||||
/// **False under the lock, whatever the stack holds.** The steps are still there — this is an
|
/// **False under the lock, whatever the stack holds.** The steps are still there — this is an
|
||||||
/// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
|
/// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
|
||||||
/// would have crossed before it landed.
|
/// would have crossed before it landed. False with no substrate at all, for the reason
|
||||||
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
|
/// `history` records.
|
||||||
|
public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true }
|
||||||
|
|
||||||
public override var canRedo: Bool { !isReadOnly() && history.canRedo }
|
public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }
|
||||||
|
|
||||||
// MARK: Crossing
|
// MARK: Crossing
|
||||||
|
|
||||||
@@ -80,17 +105,17 @@ public final class BoardUndoManager: UndoManager {
|
|||||||
/// started anyway is refused one layer down by `performWrite` — which leaves the step on the
|
/// started anyway is refused one layer down by `performWrite` — which leaves the step on the
|
||||||
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
|
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
|
||||||
/// guard here would be a second answer to one question.
|
/// guard here would be a second answer to one question.
|
||||||
public override func undo() { history.undo() }
|
public override func undo() { history?.undo() }
|
||||||
|
|
||||||
public override func redo() { history.redo() }
|
public override func redo() { history?.redo() }
|
||||||
|
|
||||||
// MARK: Titles
|
// MARK: Titles
|
||||||
|
|
||||||
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
|
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
|
||||||
/// nothing to cross, which is what its menu-title composition expects.
|
/// nothing to cross, which is what its menu-title composition expects.
|
||||||
public override var undoActionName: String { history.undoActionName ?? "" }
|
public override var undoActionName: String { history?.undoActionName ?? "" }
|
||||||
|
|
||||||
public override var redoActionName: String { history.redoActionName ?? "" }
|
public override var redoActionName: String { history?.redoActionName ?? "" }
|
||||||
|
|
||||||
/// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
|
/// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
|
||||||
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
|
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
|
||||||
|
|||||||
@@ -1248,6 +1248,39 @@ public final class BoardStore: HealHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same bracket over work that **awaits** — the undo restore (06-history-undo.md) and, next,
|
||||||
|
/// the branch switch.
|
||||||
|
///
|
||||||
|
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
|
||||||
|
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
|
||||||
|
/// git operation is a detached libgit2 task the main actor must not block on
|
||||||
|
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
|
||||||
|
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
|
||||||
|
/// to guess which one a trailing closure meant.
|
||||||
|
///
|
||||||
|
/// The refusal, the ordering and the arming are the synchronous version's, unchanged — see its
|
||||||
|
/// doc comment for all three.
|
||||||
|
public func performWholesale(
|
||||||
|
announcing completion: String? = nil,
|
||||||
|
awaiting operation: () async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
if let readOnlyLock {
|
||||||
|
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||||
|
}
|
||||||
|
watcherBrackets?.begin()
|
||||||
|
defer {
|
||||||
|
wholesaleReloadFloor = reloadGeneration + 1
|
||||||
|
wholesaleCompletion = completion
|
||||||
|
watcherBrackets?.end()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await operation()
|
||||||
|
} catch let error as BoardWriteError {
|
||||||
|
banners.post(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lane width
|
// MARK: - Lane width
|
||||||
|
|
||||||
/// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md §
|
/// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md §
|
||||||
|
|||||||
@@ -226,6 +226,28 @@ public final class CardBodyEditSession {
|
|||||||
return outcome
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Throws the buffer away and takes disk's word for it** — the Discard branch of the
|
||||||
|
/// save-or-discard step (06-history-undo.md ▸ Branch switching: "Discard reverts buffers and
|
||||||
|
/// uncommitted saves to HEAD").
|
||||||
|
///
|
||||||
|
/// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation
|
||||||
|
/// behind the step's to undo, because only that operation knows which state it is restoring to
|
||||||
|
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder
|
||||||
|
/// against the working tree for exactly this reason). Splitting it that way is what keeps the two
|
||||||
|
/// halves from being two answers able to disagree: one pass writes the card's files, once.
|
||||||
|
///
|
||||||
|
/// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer
|
||||||
|
/// would write the discarded text back over the restored card a moment later.
|
||||||
|
public func discardBuffer() {
|
||||||
|
cancelPending()
|
||||||
|
text = disk
|
||||||
|
sessionOriginBody = nil
|
||||||
|
if isEditing {
|
||||||
|
isEditing = false
|
||||||
|
editSessionDidChange?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
/// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of
|
||||||
/// reported.
|
/// reported.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - One row
|
||||||
|
|
||||||
|
/// One **History** row: a commit that touched this card's folder (05-card-window.md ▸ History).
|
||||||
|
///
|
||||||
|
/// A value rather than the `GitCommitRecord` itself, so the view renders strings a test has already
|
||||||
|
/// checked and never formats a date in a `body`.
|
||||||
|
struct CardHistoryRow: Identifiable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The commit's oid — the identity, and nothing the row shows.
|
||||||
|
let id: String
|
||||||
|
|
||||||
|
/// The commit's subject, exactly as the message engine wrote it.
|
||||||
|
let subject: String
|
||||||
|
|
||||||
|
/// "2 days ago · Claude" — the row's second line.
|
||||||
|
let attribution: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The seam
|
||||||
|
|
||||||
|
/// What the History section shows, as a pure function of commits and a clock
|
||||||
|
/// (05-card-window.md ▸ History: "newest first — semantic subject, relative date, author").
|
||||||
|
enum CardHistoryRows {
|
||||||
|
|
||||||
|
/// The rows for one card's commits, newest first — which is the order the walk already answers
|
||||||
|
/// in, so nothing here re-sorts and nothing can disagree with git about what "newest" means.
|
||||||
|
nonisolated static func rows(
|
||||||
|
for commits: [GitCommitRecord],
|
||||||
|
now: Date = Date(),
|
||||||
|
locale: Locale = .autoupdatingCurrent
|
||||||
|
) -> [CardHistoryRow] {
|
||||||
|
commits.map { commit in
|
||||||
|
CardHistoryRow(
|
||||||
|
id: commit.oid,
|
||||||
|
subject: commit.subject,
|
||||||
|
attribution: attribution(of: commit, now: now, locale: locale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "⟨relative date⟩ · ⟨author⟩", with the author dropped when there is none to name.
|
||||||
|
///
|
||||||
|
/// The author is the commit's, which is where origin lives (06-history-undo.md ▸ Interaction with
|
||||||
|
/// external writers) — so a foreign commit reads `Lanework External` and a `modified-by` agent
|
||||||
|
/// reads its own name, with no rendering rule of this section's own. That is 05's claim that the
|
||||||
|
/// trail "reads as a story, agent and hand edits included" arriving for free.
|
||||||
|
nonisolated static func attribution(
|
||||||
|
of commit: GitCommitRecord,
|
||||||
|
now: Date = Date(),
|
||||||
|
locale: Locale = .autoupdatingCurrent
|
||||||
|
) -> String {
|
||||||
|
let when = relativeDate(commit.date, now: now, locale: locale)
|
||||||
|
let author = commit.authorName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return author.isEmpty ? when : "\(when) · \(author)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A relative date in the system's own words ("2 days ago"), with **"just now"** for anything
|
||||||
|
/// inside a minute.
|
||||||
|
///
|
||||||
|
/// The floor is a judgment call, recorded: `RelativeFormatStyle` renders a five-second-old commit
|
||||||
|
/// as "in 0 seconds" whenever the clock rounds the wrong way, and a trail whose newest row reads
|
||||||
|
/// as the future is worse than one that rounds down. Everything past a minute is the platform's
|
||||||
|
/// answer verbatim, localized and abbreviated to suit a 26-character-wide sidebar.
|
||||||
|
nonisolated static func relativeDate(
|
||||||
|
_ date: Date,
|
||||||
|
now: Date = Date(),
|
||||||
|
locale: Locale = .autoupdatingCurrent
|
||||||
|
) -> String {
|
||||||
|
guard now.timeIntervalSince(date) >= 60 else { return "just now" }
|
||||||
|
var style = Date.RelativeFormatStyle(presentation: .named, unitsStyle: .wide)
|
||||||
|
style.locale = locale
|
||||||
|
return date.formatted(style.locale(locale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The loader
|
||||||
|
|
||||||
|
/// **One card window's commit trail** — the object the sidebar renders and the host refreshes.
|
||||||
|
///
|
||||||
|
/// ### Its existence is the section's visibility rule
|
||||||
|
///
|
||||||
|
/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same
|
||||||
|
/// honesty rule as the popover's git section" (05 ▸ History), and the free tier has no git state at
|
||||||
|
/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole
|
||||||
|
/// of the absence — no placeholder, no empty header, nothing to explain.
|
||||||
|
///
|
||||||
|
/// ### It re-reads rather than subscribes
|
||||||
|
///
|
||||||
|
/// The trail changes when a commit lands, which is exactly what `GitAutoCommitter.commitCount`
|
||||||
|
/// counts. The host re-asks on that number and on the card's own folder, so a trail refreshes after
|
||||||
|
/// every commit — the app's own, an agent's the watcher committed, and a restore's — without this
|
||||||
|
/// object learning what a committer is.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class CardHistory {
|
||||||
|
|
||||||
|
/// The rows, newest first. Empty until the first load answers, which is also the honest answer for
|
||||||
|
/// a card whose folder no commit has touched yet.
|
||||||
|
private(set) var rows: [CardHistoryRow] = []
|
||||||
|
|
||||||
|
/// Whether a load is in flight — what keeps the section from flashing "no history yet" during the
|
||||||
|
/// first walk of a large repository.
|
||||||
|
private(set) var isLoading = false
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// Reads the commits that touched `cardFolderName` under `boardRoot`, off the main actor.
|
||||||
|
func load(boardRoot: URL, cardFolderName: String) async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let commits = await Task.detached(priority: .utility) {
|
||||||
|
GitHistoryWalk.commitsTouching(folderNamed: cardFolderName, at: boardRoot)
|
||||||
|
}.value
|
||||||
|
rows = CardHistoryRows.rows(for: commits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The section
|
||||||
|
|
||||||
|
/// The sidebar's **History** section: the card's commit trail, read-only, newest first
|
||||||
|
/// (05-card-window.md ▸ History).
|
||||||
|
///
|
||||||
|
/// ### No actions, deliberately
|
||||||
|
///
|
||||||
|
/// "Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a
|
||||||
|
/// git-client task for now; a per-row forward-restore and lane history are wishlist items,
|
||||||
|
/// deliberately." So the rows are text: selectable, copyable, and nothing else. The one restore this
|
||||||
|
/// milestone ships is board-level ⌘Z, which is a different gesture with a different target.
|
||||||
|
///
|
||||||
|
/// ### Empty says so, rather than disappearing
|
||||||
|
///
|
||||||
|
/// Contrast Details beside it, which vanishes when a card has no unknown keys. The distinction is
|
||||||
|
/// what an empty state would *imply*: an absent Details section implies nothing (most cards have no
|
||||||
|
/// unknown keys), while an absent History section on a git board would imply the board has no
|
||||||
|
/// history — the exact claim 05 reserves for boards that genuinely have none. A card whose folder is
|
||||||
|
/// newer than its last commit is a real and temporary state, and one quiet line is the honest way to
|
||||||
|
/// say so.
|
||||||
|
struct CardHistorySection: View {
|
||||||
|
|
||||||
|
let history: CardHistory
|
||||||
|
|
||||||
|
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
|
||||||
|
CardSidebarSectionHeader(title: "History")
|
||||||
|
|
||||||
|
if history.rows.isEmpty {
|
||||||
|
Text(history.isLoading ? "Reading history…" : "No commits yet")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
ForEach(history.rows) { row in
|
||||||
|
self.row(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subject over attribution — `CardDetailsSection.row`'s shape, inverted: there the quiet line is
|
||||||
|
/// the key and the loud one the value; here the *subject* is what a reader scans for and the date
|
||||||
|
/// and author are the qualifier. Same two fonts, same wrap-rather-than-truncate rule, so the two
|
||||||
|
/// sections read as one column at any text size.
|
||||||
|
private func row(_ row: CardHistoryRow) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(row.subject)
|
||||||
|
.font(.callout)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text(row.attribution)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(row.subject), \(row.attribution)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,10 @@ struct CardWindowView: View {
|
|||||||
let comments: CardComments
|
let comments: CardComments
|
||||||
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
||||||
let thumbnails: AttachmentThumbnailCache
|
let thumbnails: AttachmentThumbnailCache
|
||||||
|
/// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git —
|
||||||
|
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
|
||||||
|
/// see `historySlot`.
|
||||||
|
let history: CardHistory?
|
||||||
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
||||||
/// window**"). `nil` only where a caller has no store to import through.
|
/// window**"). `nil` only where a caller has no store to import through.
|
||||||
let fileDrop: CardWindowDropDelegate?
|
let fileDrop: CardWindowDropDelegate?
|
||||||
@@ -325,22 +329,21 @@ struct CardWindowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The History section's reserved place in the stack** — between Details and Actions, 05's
|
/// **The History section** — between Details and Actions, 05's order (05 ▸ History: "the card's
|
||||||
/// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject,
|
/// commit trail, read-only … newest first — semantic subject, relative date, author").
|
||||||
/// relative date, author").
|
|
||||||
///
|
///
|
||||||
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
|
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
|
||||||
/// exist here, so a header over empty space would claim a commit trail on every board — and on
|
/// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section",
|
||||||
/// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one
|
/// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host
|
||||||
/// that can never arrive. What the slot reserves is the **position**, so filling it in moves
|
/// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about:
|
||||||
/// nothing above or below it.
|
/// what the slot reserves is the **position**, and on every other board that position is empty.
|
||||||
///
|
///
|
||||||
// m7-git: the trail itself, plus the two rules that come with it — absence on boards without
|
// A later card: View ▸ History, which focuses this section (11-command-nexus.md).
|
||||||
// app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md)
|
|
||||||
// and View ▸ History, which focuses this section (11-command-nexus.md).
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var historySlot: some View {
|
private var historySlot: some View {
|
||||||
EmptyView()
|
if let history {
|
||||||
|
CardHistorySection(history: history)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -498,7 +498,7 @@ struct BoardSessionHistoryTests {
|
|||||||
let log = StepLog()
|
let log = StepLog()
|
||||||
|
|
||||||
#expect(session.undoManager.canUndo == false)
|
#expect(session.undoManager.canUndo == false)
|
||||||
session.history.register(log.step("Move Card"))
|
session.history?.register(log.step("Move Card"))
|
||||||
|
|
||||||
// The window hands AppKit the adapter; the adapter is answering from the session's provider.
|
// The window hands AppKit the adapter; the adapter is answering from the session's provider.
|
||||||
#expect(session.undoManager.canUndo)
|
#expect(session.undoManager.canUndo)
|
||||||
@@ -526,14 +526,14 @@ struct BoardSessionHistoryTests {
|
|||||||
let secondSession = try #require(model.session(for: secondRef))
|
let secondSession = try #require(model.session(for: secondRef))
|
||||||
#expect(firstSession.history !== secondSession.history)
|
#expect(firstSession.history !== secondSession.history)
|
||||||
|
|
||||||
firstSession.history.register(log.step("Move Card"))
|
firstSession.history?.register(log.step("Move Card"))
|
||||||
|
|
||||||
#expect(firstSession.history.canUndo)
|
#expect(firstSession.history?.canUndo == true)
|
||||||
#expect(secondSession.history.canUndo == false)
|
#expect(secondSession.history?.canUndo == false)
|
||||||
|
|
||||||
secondSession.undoManager.undo()
|
secondSession.undoManager.undo()
|
||||||
#expect(log.crossings.isEmpty)
|
#expect(log.crossings.isEmpty)
|
||||||
#expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone")
|
#expect(firstSession.history?.canUndo == true, "the other board's ⌘Z left this one's stack alone")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Closing a board empties its stack — session-only persistence")
|
@Test("Closing a board empties its stack — session-only persistence")
|
||||||
@@ -545,7 +545,7 @@ struct BoardSessionHistoryTests {
|
|||||||
|
|
||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
let history = session.history
|
let history = try #require(session.history)
|
||||||
let log = StepLog()
|
let log = StepLog()
|
||||||
history.register(log.step("Move Card"))
|
history.register(log.step("Move Card"))
|
||||||
#expect(history.canUndo)
|
#expect(history.canUndo)
|
||||||
@@ -567,12 +567,12 @@ struct BoardSessionHistoryTests {
|
|||||||
|
|
||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
session.history.register(StepLog().step("Move Card"))
|
session.history?.register(StepLog().step("Move Card"))
|
||||||
#expect(session.undoManager.canUndo)
|
#expect(session.undoManager.canUndo)
|
||||||
|
|
||||||
session.store.enterVanishedRootLock()
|
session.store.enterVanishedRootLock()
|
||||||
#expect(session.undoManager.canUndo == false)
|
#expect(session.undoManager.canUndo == false)
|
||||||
#expect(session.history.canUndo, "the stack itself survives the lock")
|
#expect(session.history?.canUndo == true, "the stack itself survives the lock")
|
||||||
|
|
||||||
session.store.handleWatcherEvent(.treeChanged(.appMediated))
|
session.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await session.store.awaitQuiescence()
|
await session.store.awaitQuiescence()
|
||||||
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
|
|||||||
defer { tearDown() }
|
defer { tearDown() }
|
||||||
|
|
||||||
let bound = FakeHistoryProvider()
|
let bound = FakeHistoryProvider()
|
||||||
model.makeHistoryProvider = { _, _ in bound }
|
model.makeHistoryProvider = { _, _, _ in bound }
|
||||||
|
|
||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
@@ -609,7 +609,7 @@ struct BoardSessionHistoryTests {
|
|||||||
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
|
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
|
||||||
var seen: [Tier] = []
|
var seen: [Tier] = []
|
||||||
model.currentTier = { .pro }
|
model.currentTier = { .pro }
|
||||||
model.makeHistoryProvider = { _, tier in
|
model.makeHistoryProvider = { _, tier, _ in
|
||||||
seen.append(tier)
|
seen.append(tier)
|
||||||
return NativeHistoryProvider()
|
return NativeHistoryProvider()
|
||||||
}
|
}
|
||||||
@@ -808,7 +808,7 @@ struct UndoCommandSurfaceTests {
|
|||||||
let boardRow = menuItem("undo:")
|
let boardRow = menuItem("undo:")
|
||||||
let cardRow = menuItem("undo:")
|
let cardRow = menuItem("undo:")
|
||||||
|
|
||||||
session.history.register(StepLog().step("Move 3 Cards"))
|
session.history?.register(StepLog().step("Move 3 Cards"))
|
||||||
|
|
||||||
#expect(boardWindow.validateMenuItem(boardRow))
|
#expect(boardWindow.validateMenuItem(boardRow))
|
||||||
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
|
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
|
||||||
|
|||||||
@@ -426,9 +426,9 @@ struct BoardSessionGitTests {
|
|||||||
let ranker = try #require(provider())
|
let ranker = try #require(provider())
|
||||||
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
|
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
|
||||||
|
|
||||||
// The provider binding is deliberately *not* part of this card: both tiers still bind the
|
// The provider binding arrived with the undo/redo card: a Pro session on a git board binds
|
||||||
// native stack until the undo/redo card builds the git provider over this mode.
|
// the git substrate over exactly this mode (12-editions.md ▸ The provider seam).
|
||||||
#expect(session.history is NativeHistoryProvider)
|
#expect(session.history is GitHistoryProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A Pro session on a plain board is mode none and injects nothing")
|
@Test("A Pro session on a plain board is mode none and injects nothing")
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||||
|
|
||||||
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Every tier ships it: the free tier runs the native stack, and a Lanework Pro subscription binds git behind the same seam without changing a keystroke.
|
- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. The free tier runs this stack on every board; under Lanework Pro a git board binds git behind the same seam instead, without changing a keystroke — see "Undo as forward commits" below.
|
||||||
|
|
||||||
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
|
||||||
|
|
||||||
@@ -59,11 +59,13 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
||||||
|
|
||||||
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **The first of that is built** — see "Git integration" below — and the rest is pro-m1 and pro-m2's remaining work; until it ships both tiers run the same native undo stack, the free one over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **Most of that is built** — see "Git integration", "Auto-commit" and "Undo as forward commits" below — and branches, remotes and push/pull are pro-m1 and pro-m2's remaining work; the free tier keeps the native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
||||||
|
|
||||||
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
- **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins.
|
||||||
|
|
||||||
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes.
|
- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External <[email protected]>` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: branch switching, `.gitignore` seeding, and remotes.
|
||||||
|
- **Undo as forward commits (Lanework Pro)** — on a git board ⌘Z and ⇧⌘Z stop being an in-memory stack and become the commit trail itself. There is no stored stack anywhere: **the stack *is* HEAD's first-parent ancestry**, re-read from the repository, so it survives relaunch for free and nothing beside the repo can ever drift from it. **A restore is a new commit, never a rewind** — no reset, no force, no rewritten history: ⌘Z materializes the earlier state and commits it as "Undo: Move card 'Fix login' to Doing", ⇧⌘Z as "Redo: …", and every commit you have ever made stays exactly where it was, inspectable in any git client. Only the *difference* is written, so a card you happen to be editing that the change never touched is simply left alone. Because the app is not the only writer, the stack re-reads HEAD before every keystroke: an agent that committed its own work in the last twenty minutes becomes the top of the stack, so ⌘Z steps back exactly one commit and can never silently swallow somebody else's session — and any commit arriving from anywhere clears redo, the classic rule. The app's own repairs are **transparent**: a heal commit is never a step and is never reverted by one, so a ⌘Z run walks past it instead of fighting the healer. Anything still pending commits *before* the restore does, so both versions of what you undid exist in the trail. When the change would land on a card you have open in Edit or Raw Source, the restore stops and asks — **Save All**, **Discard**, or **Cancel** — rather than silently committing text you hadn't saved or quietly writing it back a moment later; a raw buffer that won't validate cancels the whole thing and puts you in front of the window that refused. Undo is board-local, disabled while an outside-the-app merge or rebase has the repository paused, and absent altogether on boards the app manages no git for — where the Edit menu's rows and the toolbar's twins simply dim. Adding git to an open board turns it on there and then.
|
||||||
|
- **A card's History (Lanework Pro)** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git and throughout the free tier — no placeholder, no greyed-out promise.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user