Build HistoryStore — opt-in git and mode detection
The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the pathfinder's pin) joins the one target; new Kanban/Git/ holds BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts, NSString ancestor walk), HistoryStore (@MainActor @Observable; compose() is the tier gate — free tier gets no object, no detection, no stat), GitRepository (scope-confined SwiftGitX handles: create = init + HEAD forced to main + whole-tree "Initial board state" commit; branch reads incl. unborn/detached; path-history ranks), GitIdentity (derived default as a pure function + repo-local config reader — not libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy ranker). beginSession composes the git state beside the tier and feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the git-backed IdentityHistoryRanker to BoardLoader. The popover's git slot resolves a pure five-way matrix: free tier unchanged (absent / BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on repo-nested, read-only branch line on git. Provider binding unchanged: both tiers still bind native until the undo/redo card. 42 new tests across 8 suites, all repositories built through bundled libgit2; InertGitTests untouched and green. 2194 tests / 375 suites. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -68,6 +68,13 @@ struct BoardInfoWidget: View {
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
/// The tier and the git state this board's **session** composed with — read once, at the moment
|
||||
/// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
|
||||
/// flipping the mode redraws the popover without anything here being re-created.
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
|
||||
var body: some View {
|
||||
@@ -87,7 +94,7 @@ struct BoardInfoWidget: View {
|
||||
.help("Board Info")
|
||||
.accessibilityLabel("Board Info")
|
||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
||||
BoardInfoView(store: store, recents: recents)
|
||||
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,15 +107,23 @@ struct BoardInfoWidget: View {
|
||||
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
|
||||
/// SwiftUI's, and anything hung on it has to be taken back off.
|
||||
@MainActor
|
||||
/// `tier`/`git` default to the free tier's posture — a popover with no git section at all — so that
|
||||
/// a caller with no session in hand (the accessory-installation tests, which are about AppKit
|
||||
/// plumbing rather than about git) describes a board honestly rather than by accident. The app's own
|
||||
/// call site passes the session's values explicitly.
|
||||
func boardInfoTitlebarAccessory(
|
||||
store: BoardStore,
|
||||
recents: StyleRecents,
|
||||
tier: Tier = .free,
|
||||
git: HistoryStore? = nil,
|
||||
presentation: BoardInfoPresentation
|
||||
) -> NSTitlebarAccessoryViewController {
|
||||
let hosting = NSHostingView(
|
||||
rootView: BoardInfoWidget(
|
||||
store: store,
|
||||
recents: recents,
|
||||
tier: tier,
|
||||
git: git,
|
||||
presentation: presentation
|
||||
)
|
||||
)
|
||||
@@ -135,10 +150,16 @@ struct BoardInfoView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
|
||||
/// Whether this board carries a `.git` — checked once, off disk, when the view is built (which
|
||||
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
|
||||
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
|
||||
///
|
||||
/// **The free tier's input only.** Under Pro the section reads the session's detected mode
|
||||
/// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this
|
||||
/// stays what it always was: the one quiet question the free tier asks of a board's folder.
|
||||
private let hasGitDirectory: Bool
|
||||
|
||||
/// The style editor brings its own padding, so the sections around it carry the same number by
|
||||
@@ -149,10 +170,14 @@ struct BoardInfoView: View {
|
||||
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
init(store: BoardStore, recents: StyleRecents) {
|
||||
init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) {
|
||||
self.store = store
|
||||
self.recents = recents
|
||||
self.hasGitDirectory = BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||
self.tier = tier
|
||||
self.git = git
|
||||
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
|
||||
// board is the only one this question is for (12-editions.md ▸ The free tier and `.git`).
|
||||
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -176,15 +201,12 @@ struct BoardInfoView: View {
|
||||
StyleEditorView(store: store, recents: recents, target: .board)
|
||||
}
|
||||
|
||||
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary board adds
|
||||
// nothing here at all — no header, no divider, no placeholder — and the popover ends at
|
||||
// Styling, complete in itself. Only a board that actually carries an inert `.git` earns
|
||||
// this closing note.
|
||||
if hasGitDirectory {
|
||||
Divider()
|
||||
BoardGitNote()
|
||||
.padding(inset)
|
||||
}
|
||||
// Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier
|
||||
// board adds nothing here at all — no header, no divider, no placeholder — and the
|
||||
// popover ends at Styling, complete in itself. What a Pro board adds instead is the
|
||||
// mode-aware git section (03-board-ui.md ▸ Board popover), which is a *section*, header
|
||||
// and all, because under Pro git is a feature of the board rather than a signpost.
|
||||
gitSection
|
||||
}
|
||||
// The style editor's popover width, taken from the editor rather than restated: the embed
|
||||
// below must lay out here exactly as it does at its other two anchors, and that number is
|
||||
@@ -192,6 +214,49 @@ struct BoardInfoView: View {
|
||||
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
|
||||
}
|
||||
|
||||
/// The popover's closing section, whichever of the five postures this board is in — see
|
||||
/// `BoardGitSection`.
|
||||
@ViewBuilder
|
||||
private var gitSection: some View {
|
||||
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
||||
case .absent:
|
||||
EmptyView()
|
||||
|
||||
case .proPointer:
|
||||
Divider()
|
||||
BoardGitNote()
|
||||
.padding(inset)
|
||||
|
||||
case .addGit:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
if let git {
|
||||
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
|
||||
}
|
||||
}
|
||||
.padding(inset)
|
||||
|
||||
case .repoNested:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
BoardGitNestedNote()
|
||||
}
|
||||
.padding(inset)
|
||||
|
||||
case .branch:
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Git")
|
||||
if let git {
|
||||
BoardGitBranchLine(git: git)
|
||||
}
|
||||
}
|
||||
.padding(inset)
|
||||
}
|
||||
}
|
||||
|
||||
/// The section titles, matching the style editor's own headers so the popover reads as one
|
||||
/// surface rather than borrowed ones.
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
@@ -278,6 +343,125 @@ private struct BoardRenameField: View {
|
||||
|
||||
// MARK: - Git
|
||||
|
||||
/// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover;
|
||||
/// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two
|
||||
/// facts, so the posture matrix is provable without a popover on screen.
|
||||
///
|
||||
/// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an
|
||||
/// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the
|
||||
/// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most
|
||||
/// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent
|
||||
/// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules).
|
||||
enum BoardGitSection: Equatable, CaseIterable {
|
||||
|
||||
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
||||
/// complete in itself".
|
||||
case absent
|
||||
|
||||
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer
|
||||
/// to Pro (12 ▸ Tier naming).
|
||||
case proPointer
|
||||
|
||||
/// Pro, mode `none`: the add-git action (06 ▸ Rules ▸ Opt-in init).
|
||||
case addGit
|
||||
|
||||
/// Pro, repo-nested: the honest explanation, no action.
|
||||
case repoNested
|
||||
|
||||
/// Pro, git mode: the read-only branch/source line. Branch switching and creation, the commit
|
||||
/// identity fields and the remote controls are later cards — nothing here is a control.
|
||||
case branch
|
||||
|
||||
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
|
||||
switch tier {
|
||||
case .free:
|
||||
// Detection never runs under the free tier, so the mode is not consulted here — the one
|
||||
// question asked is whether the folder carries a `.git`, which is what the pointer is
|
||||
// about (12: "any `.git` is inert … a stray like any other, preserved verbatim").
|
||||
return hasGitDirectory ? .proPointer : .absent
|
||||
case .pro:
|
||||
switch mode {
|
||||
case .none: return .addGit
|
||||
case .git: return .branch
|
||||
case .repoNested: return .repoNested
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that
|
||||
/// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than
|
||||
/// a promise: there is no other caller of `HistoryStore.addGit`.
|
||||
///
|
||||
/// The caption states what pressing it does, in the order it happens, because it is not undoable in
|
||||
/// the ordinary sense: a repository appears in the board's folder and its current state becomes the
|
||||
/// first commit.
|
||||
private struct BoardGitAddAction: View {
|
||||
|
||||
let git: HistoryStore
|
||||
/// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses
|
||||
/// writes refuses this one too — initializing a repository is a write, and a commit is several.
|
||||
let isEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button("Add Git") {
|
||||
Task { await git.addGit() }
|
||||
}
|
||||
.disabled(!isEnabled || git.isAddingGit)
|
||||
|
||||
Text("Creates a git repository in this board's folder and commits its current state.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let failure = git.lastFailure {
|
||||
Text(failure.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it:
|
||||
/// short prose in place of an action, never a hidden or greyed-out add-git.
|
||||
private struct BoardGitNestedNote: View {
|
||||
|
||||
var body: some View {
|
||||
Text("This board lives inside a repository; Lanework leaves it to that repository.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The branch/source display** (03-board-ui.md ▸ Board popover) — read-only, and deliberately the
|
||||
/// whole of the git-mode section for now: switching, creation, identity and remotes are each their
|
||||
/// own card, and a control shown before it works is worse than one that isn't there yet.
|
||||
///
|
||||
/// The name is read when the popover appears rather than at session composition, so the open path
|
||||
/// never waits on libgit2 (`HistoryStore.branch`).
|
||||
private struct BoardGitBranchLine: View {
|
||||
|
||||
let git: HistoryStore
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(git.branch ?? "…")
|
||||
.font(.callout)
|
||||
.foregroundStyle(git.branch == nil ? .secondary : .primary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(git.branch.map { "Branch \($0)" } ?? "Reading branch")
|
||||
.task { await git.refreshBranch() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and
|
||||
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
|
||||
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
|
||||
|
||||
Reference in New Issue
Block a user