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