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)") } }