The popover's third tab settles (03 ▸ Git tab, 2026-08-07): BoardGitTabView takes the parked section — the six-posture matrix, the Pro pointer, the nested/unverifiable prose, the branch controls, the Board Settings… door — with the tab label doing the old "Git" header's naming work, and one new caption for the no-repository posture. BoardInfoTab.available drops the Git tab exactly where BoardGitSection resolves absent, carrying 12's absent-no-placeholder rule up to the tab strip; selection resets to Info per open, now a ruling. Daily face only — the remote block still joins with pro-m2. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
300 lines
16 KiB
Swift
300 lines
16 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// **The board popover's Git tab** (03-board-ui.md § Board popover ▸ Git tab, settled 2026-08-07) —
|
|
/// the pre-tab closing git section rehomed *whole*, and nothing more: **the daily face, and only
|
|
/// that** (the 2026-07-31 popover/sheet split stands, unchanged by the move). A repository-facts
|
|
/// dossier in the Info tab's register — commit counts, last-commit dates — was considered in the Git
|
|
/// session and declined: the popover's git surface is for *operating*, and per-item history is the
|
|
/// card window's History section (05-card-window.md).
|
|
///
|
|
/// ### No "Git" header anywhere in this tab
|
|
///
|
|
/// The tab's own label does the naming work the section's header used to (the design's phrasing), so
|
|
/// every posture below renders its content bare where the pre-tab section stacked it under a
|
|
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
|
|
/// divider; a second rule and a second "Git" would be the surface naming itself twice.
|
|
///
|
|
/// ### Membership is the posture, so this view never renders an empty state
|
|
///
|
|
/// `BoardInfoTab.available(tier:mode:hasGitDirectory:)` drops the Git tab from the strip exactly when
|
|
/// `BoardGitSection` resolves to `.absent`, so this view is never built for the free tier's ordinary
|
|
/// board. That is 12-editions.md's "absent, no placeholder" rule carried up to the tab strip — a
|
|
/// standing Git tab on every free board would be the standing ad for Pro that 12 forbids.
|
|
|
|
// MARK: - The posture
|
|
|
|
/// **What the Git tab shows, for one board** (03-board-ui.md ▸ Board popover ▸ Git tab;
|
|
/// 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: 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). `unverifiable` (the git-detection axis)
|
|
/// joins as a fourth Pro case, structurally identical to `repoNested` but worded as its own honest
|
|
/// prose — a denial is not a nesting.
|
|
///
|
|
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
|
|
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
|
|
/// all (the case was called `.addGit` when it did — a name that would now be describing a control
|
|
/// that lives in another file, so it is `.noRepository`), and the git-mode case lost branch creation
|
|
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
|
|
/// test survived the move unchanged.
|
|
///
|
|
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** — the same six cases, now
|
|
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
|
|
/// single pane. `.absent` grew one further job in the move: it is what `BoardInfoTab.available` reads
|
|
/// to decide whether the Git tab is in the strip at all, which is why membership can never disagree
|
|
/// with what the tab would have rendered.
|
|
enum BoardGitSection: Equatable, CaseIterable {
|
|
|
|
/// Nothing at all — the free tier's ordinary board, where "the popover is rename + style,
|
|
/// complete in itself". Since the restructure this is a **missing tab**, not an empty one
|
|
/// (`BoardInfoTab.available`).
|
|
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`: a board that could have a history and has none. There is no daily surface
|
|
/// for that — the tab is one caption stating the fact above the Board Settings… door, where
|
|
/// add-git now lives (03 ▸ Board settings sheet). The header-plus-door posture blessed
|
|
/// 2026-08-06, restated for a surface whose header is now the tab label.
|
|
case noRepository
|
|
|
|
/// Pro, repo-nested: the honest explanation, no action — and no Board Settings… row either,
|
|
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`).
|
|
case repoNested
|
|
|
|
/// Pro, unverifiable: **not** `.repoNested` — a denied ancestor check, not a found repository
|
|
/// (06 ▸ Rules ▸ Detection, "Denial is not absence"). Structurally identical to `.repoNested`
|
|
/// (no action, no Board Settings… row, `BoardSettingsAvailability` false), but its own case so
|
|
/// the view renders its own honest prose rather than the nested sentence — "unverifiable" is not
|
|
/// "nested".
|
|
case unverifiable
|
|
|
|
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state
|
|
/// explanation when the surface is held, and the Board Settings… row. The remote half —
|
|
/// tracking, Pull/Push, the status badges — is 07-sync-collab.md's own card and joins this same
|
|
/// posture.
|
|
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 .noRepository
|
|
case .git: return .branch
|
|
case .repoNested: return .repoNested
|
|
case .unverifiable: return .unverifiable
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The notes
|
|
|
|
/// **The no-repository caption** (03-board-ui.md ▸ Board popover ▸ Git tab) — the fact, stated, above
|
|
/// the Board Settings… door.
|
|
///
|
|
/// One sentence in its siblings' register, and deliberately not a header: the tab label already says
|
|
/// "Git", so what is left to say is what this board's git story currently *is*. "Yet" is the whole
|
|
/// posture in a word — the door directly below it is where a user says otherwise.
|
|
private struct BoardGitNoRepositoryNote: View {
|
|
|
|
var body: some View {
|
|
Text("This board has no git history yet.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.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 unverifiable explanation** (06-history-undo.md ▸ Rules ▸ Detection, "Denial is not
|
|
/// absence", ruled 2026-07-31), worded as its own honest sentence rather than borrowing
|
|
/// `BoardGitNestedNote`'s — a denied ancestor check is not a found repository, and telling a user
|
|
/// their board is nested when the truth is "couldn't check" would be a lie dressed as caution.
|
|
private struct BoardGitUnverifiableNote: View {
|
|
|
|
var body: some View {
|
|
Text("Lanework couldn't verify whether this board sits inside a repository, so it isn't offering to add one here.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
|
|
/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second
|
|
/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the
|
|
/// Settings Pro section).
|
|
///
|
|
/// On an ordinary board nothing instantiates this type at all — the board has no Git tab to put it in
|
|
/// (`BoardInfoTab.available`), matching the card window's absent History section (12: "absent, no
|
|
/// placeholder"). Only a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is
|
|
/// inert" — the free tier never reads or writes it, whether the board's own or one a lapsed
|
|
/// subscription left behind) earns this note, worded exactly as 12 rules: an honest explanation of
|
|
/// what the folder is, named exactly where the question arises, never a standing ad for Pro.
|
|
///
|
|
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
|
|
/// (a fixture board with `.git` → true, without → false), and `BoardInfoView.init` is its one caller
|
|
/// in the app — the popover asks the question once at open and hands the answer down, both to this
|
|
/// tab and to `BoardInfoTab.available`.
|
|
struct BoardGitNote: View {
|
|
|
|
var body: some View {
|
|
Text("This board has a git history. Lanework Pro works with it.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
/// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately
|
|
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
|
|
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
|
|
/// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload
|
|
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
|
|
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
|
|
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
|
|
/// open — a gap this note's own posture makes harmless, since it is a signpost, not a control.
|
|
static func hasGitDirectory(at boardRoot: URL) -> Bool {
|
|
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
|
|
}
|
|
}
|
|
|
|
// MARK: - The tab
|
|
|
|
/// The Git tab's surface: whichever of the six postures this board is in, and the Board Settings…
|
|
/// door where it applies.
|
|
struct BoardGitTabView: View {
|
|
|
|
let store: BoardStore
|
|
|
|
/// The tier and the git state this board's **session** composed with, handed down from
|
|
/// `BoardInfoView` rather than re-derived — 12-editions.md ▸ The entitlement ("a lapse never
|
|
/// interrupts an open session"). `git` is `@Observable`, so a branch switch or a pause landing
|
|
/// while the tab is open repaints it.
|
|
let tier: Tier
|
|
let git: HistoryStore?
|
|
|
|
/// The window's settings sheet, so this tab can carry the **Board Settings…** row that opens it.
|
|
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
|
|
let settings: BoardSettingsPresentation?
|
|
|
|
/// The free tier's one question about the board's folder, asked once in `BoardInfoView.init` and
|
|
/// passed down — the same answer the tab strip's membership was computed from, so the tab and its
|
|
/// content can never disagree about which posture this board is in.
|
|
let hasGitDirectory: Bool
|
|
|
|
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
|
|
/// `BoardThemeTabView`'s own parameter — the tab pads by this amount instead of restating the
|
|
/// derivation.
|
|
let inset: CGFloat
|
|
|
|
/// **The popover's own dismissal**, used by exactly one control: the Board Settings… row, whose
|
|
/// job is to close this surface and open the sheet. The popover is presented by `isPresented`, so
|
|
/// the environment action drives the same flag the widget's button does — nothing here has to be
|
|
/// handed the widget's binding to put it down.
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
posture
|
|
.padding(inset)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
/// The tab's content, posture by posture — see `BoardGitSection`. No section header and no
|
|
/// dividers: the tab label names this surface, and the tabs already sit under the popover
|
|
/// header's rule.
|
|
@ViewBuilder
|
|
private var posture: some View {
|
|
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) {
|
|
case .absent:
|
|
// Unreachable, and kept honest rather than fatal: `BoardInfoTab.available` drops the Git
|
|
// tab from the strip on exactly this posture, so nothing ever selects a tab that would
|
|
// land here. Written as `EmptyView` because that is what the case *means* — if the
|
|
// membership rule ever loosened, the free tier's ordinary board would render nothing,
|
|
// which is still the design's answer for it (12: "absent, no placeholder").
|
|
EmptyView()
|
|
|
|
case .proPointer:
|
|
BoardGitNote()
|
|
|
|
case .noRepository:
|
|
// Nothing daily to show on a board with no repository — so the tab is the fact and the
|
|
// door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is
|
|
// the honest signpost that this board *could* have a history and where to say so.
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
BoardGitNoRepositoryNote()
|
|
boardSettingsRow
|
|
}
|
|
|
|
case .repoNested:
|
|
BoardGitNestedNote()
|
|
|
|
case .unverifiable:
|
|
BoardGitUnverifiableNote()
|
|
|
|
case .branch:
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let git {
|
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
boardSettingsRow
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **The popover's one setup affordance** (03-board-ui.md ▸ Board popover) — the sheet's first
|
|
/// door, the menu row being the second (11-command-nexus.md).
|
|
///
|
|
/// **Shown only where the sheet is reachable** (`BoardSettingsAvailability`): this tab describes
|
|
/// *this board*, so a row pointing at a surface this board cannot have would be the disabled
|
|
/// button 06 rules out one level up. The menu row is the opposite case and stays visible — a menu
|
|
/// is an inventory of the app.
|
|
///
|
|
/// **Dismiss first, then present.** The popover is transient and the sheet is not; leaving a
|
|
/// transient surface hanging over a modal one would read as two surfaces arguing about which the
|
|
/// user is in.
|
|
///
|
|
/// Not disabled by the read-only lock: opening a configuration surface is not a mutation, and the
|
|
/// controls inside it disable themselves (the Board Info ⌘I rule).
|
|
@ViewBuilder
|
|
private var boardSettingsRow: some View {
|
|
if let settings, BoardSettingsAvailability.resolve(
|
|
tier: tier,
|
|
mode: git?.mode ?? .none,
|
|
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
|
|
) {
|
|
Button("Board Settings…") {
|
|
dismiss()
|
|
settings.present()
|
|
}
|
|
.accessibilityHint("Opens the board settings sheet")
|
|
}
|
|
}
|
|
}
|