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:
@@ -226,6 +226,28 @@ public final class CardBodyEditSession {
|
||||
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
|
||||
/// 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
|
||||
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
||||
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
|
||||
/// window**"). `nil` only where a caller has no store to import through.
|
||||
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
|
||||
/// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject,
|
||||
/// relative date, author").
|
||||
/// **The History section** — between Details and Actions, 05's order (05 ▸ History: "the card's
|
||||
/// commit trail, read-only … newest first — semantic subject, relative date, author").
|
||||
///
|
||||
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
|
||||
/// exist here, so a header over empty space would claim a commit trail on every board — and on
|
||||
/// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one
|
||||
/// that can never arrive. What the slot reserves is the **position**, so filling it in moves
|
||||
/// nothing above or below it.
|
||||
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
|
||||
/// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section",
|
||||
/// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host
|
||||
/// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about:
|
||||
/// 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
|
||||
// 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).
|
||||
// A later card: View ▸ History, which focuses this section (11-command-nexus.md).
|
||||
@ViewBuilder
|
||||
private var historySlot: some View {
|
||||
EmptyView()
|
||||
if let history {
|
||||
CardHistorySection(history: history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user