HistoryStore.compose(boardRoot📒) returns non-optional and runs for every session — the nil the gate produced was the only nil it ever had. makeHistoryProvider is a one-axis decision: git-mode boards bind the git provider, everything else native, in every tier; Session.tier stays recorded, dormant. BoardGitSection shrinks to the four mode postures (.absent and .proPointer die, BoardGitNote and the .git probe with them); every board carries all three popover tabs (BoardInfoTab.available retired); the titlebar branch shows on any git-mode board; the settings sheet and card History section stop reading tier. InertGitTests is repurposed as UntouchedGitTests — the file layer still never opens .git, now load-bearing for mode-none boards. The accessibility audit reaches the settings sheet at last: the fixture board hosts it in every tier, so the free-fixture disabled-row test becomes an open-and-audit test. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
192 lines
8.4 KiB
Swift
192 lines
8.4 KiB
Swift
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). So the host builds one of these only
|
|
/// where the board's history is genuinely git-backed — its `HistoryStore` in mode `git` — and `nil`
|
|
/// is the whole of the absence: no placeholder, no empty header, nothing to explain.
|
|
///
|
|
/// **Per board, never per tier** (12-editions.md ▸ PIVOT 2026-08-07). 12's old tier matrix listed
|
|
/// the card History sidebar as a Pro row, so the section's absence used to have two causes at once —
|
|
/// a gitless board, or a free-tier session with no git state to ask. Git is tier-independent now:
|
|
/// the one question left is whether *this board* has a repository the app manages, which is the
|
|
/// question 05 was always asking.
|
|
///
|
|
/// ### 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)")
|
|
}
|
|
}
|