GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.
GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.
BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).
Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.
41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
481 lines
23 KiB
Swift
481 lines
23 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
|
|
/// widget in the window's titlebar that opens it.
|
|
///
|
|
/// Two sections, in the design's own order: the board rename and the embedded style editor aimed at
|
|
/// the board. A third, contextual note joins them only on a board that carries an inert `.git` — see
|
|
/// `BoardGitNote` for the wording and why base's posture there is a quiet signpost rather than a
|
|
/// standing section (12-editions.md § Base and `.git`, ruled 2026-07-27, superseding the earlier
|
|
/// reserved-slot placeholder this file once carried).
|
|
///
|
|
/// ### One home, deliberately
|
|
///
|
|
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
|
|
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
|
|
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
|
|
/// class **C** — "the keyboard path is reachability … not bindings").
|
|
|
|
// MARK: - Presentation state
|
|
|
|
/// Whether **this window's** board popover is open.
|
|
///
|
|
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
|
|
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
|
|
/// exactly as the store does. Two board windows each hold their own and can never toggle each
|
|
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
|
|
///
|
|
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
|
|
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
|
|
@MainActor
|
|
@Observable
|
|
final class BoardInfoPresentation {
|
|
|
|
var isPresented = false
|
|
|
|
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
|
|
/// disclosure that could only ever open would leave the popover with no keyboard way out.
|
|
func toggle() {
|
|
isPresented.toggle()
|
|
}
|
|
}
|
|
|
|
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
|
|
/// board-window menu items reach their window this way rather than through the app model.
|
|
struct FocusedBoardInfoKey: FocusedValueKey {
|
|
typealias Value = BoardInfoPresentation
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var boardInfo: BoardInfoPresentation? {
|
|
get { self[FocusedBoardInfoKey.self] }
|
|
set { self[FocusedBoardInfoKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - The window-title widget
|
|
|
|
/// The titlebar widget: a quiet disclosure chevron whose one job is this popover.
|
|
///
|
|
/// **The popover is anchored to the widget itself** — it hangs from the chevron rather than from
|
|
/// the window or the board — which is what makes the affordance and the surface read as one thing.
|
|
/// A `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a
|
|
/// click outside dismisses it), and because the content is SwiftUI either way; the AppKit half of
|
|
/// this is only the *placement* (`boardInfoTitlebarAccessory`).
|
|
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 {
|
|
Button {
|
|
presentation.toggle()
|
|
} label: {
|
|
Image(systemName: "chevron.down")
|
|
.imageScale(.small)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
// Sized like a titlebar control rather than by its glyph: the hit target has to be
|
|
// clickable at titlebar scale, where the chevron alone is a few points across.
|
|
.frame(width: 20, height: 18)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Board Info")
|
|
.accessibilityLabel("Board Info")
|
|
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
|
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
|
|
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
|
|
/// title bar beside the window title rather than in the window's content.
|
|
///
|
|
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
|
|
/// 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
|
|
)
|
|
)
|
|
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
|
// as zero would be an invisible, unclickable widget.
|
|
hosting.sizingOptions = [.intrinsicContentSize]
|
|
hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18)
|
|
|
|
let controller = NSTitlebarAccessoryViewController()
|
|
controller.view = hosting
|
|
controller.layoutAttribute = .leading
|
|
return controller
|
|
}
|
|
|
|
// MARK: - The popover's content
|
|
|
|
/// Two sections and, on a `.git`-bearing board, a contextual note — one view (03-board-ui.md §
|
|
/// Board popover; 12-editions.md § Base and `.git`).
|
|
///
|
|
/// Width is the style editor's — 268 points, the number that keeps the Style… popover narrow enough
|
|
/// to sit beside a card — so the embedded editor lays out here exactly as it does at its other two
|
|
/// anchors rather than being stretched by a container with its own opinion.
|
|
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
|
|
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
|
|
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
|
|
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
|
|
private var inset: CGFloat {
|
|
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
|
}
|
|
|
|
init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) {
|
|
self.store = store
|
|
self.recents = recents
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
sectionHeader("Title")
|
|
BoardRenameField(store: store)
|
|
}
|
|
.padding(inset)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sectionHeader("Styling")
|
|
.padding(.horizontal, inset)
|
|
.padding(.top, inset)
|
|
// Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one
|
|
// ("nothing selected = the board"); this embed is the surface that exists *because*
|
|
// the board is a style target, so it can have no other target (§ Styling ▸
|
|
// Controls: "the board popover's target is the board itself").
|
|
StyleEditorView(store: store, recents: recents, target: .board)
|
|
}
|
|
|
|
// 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
|
|
// now font-derived.
|
|
.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 {
|
|
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
|
|
}
|
|
}
|
|
.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 {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
}
|
|
|
|
// MARK: - Rename
|
|
|
|
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
|
|
///
|
|
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
|
|
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
|
|
/// folder name, because that is what the window title will say, and committing empty is how a user
|
|
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
|
|
///
|
|
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
|
|
/// that means "edit this now"; this one is one control among several on a configuration surface,
|
|
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
|
|
/// (11-command-nexus.md's class **C**).
|
|
///
|
|
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
|
|
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
|
|
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
|
|
private struct BoardRenameField: View {
|
|
|
|
let store: BoardStore
|
|
|
|
@State private var draft = ""
|
|
@FocusState private var isFocused: Bool
|
|
|
|
var body: some View {
|
|
TextField(fallbackName, text: $draft)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1)
|
|
.focused($isFocused)
|
|
.onSubmit { store.renameBoard(draft) }
|
|
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
|
|
// field abandons its edit and keeps the popover open, and an unedited one lets the press
|
|
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
|
|
// safe on the paths where the press never reaches here — the focus-loss commit that
|
|
// follows sees a draft equal to what is on disk and writes nothing.
|
|
.onKeyPress(.escape) {
|
|
guard draft != committed else { return .ignored }
|
|
draft = committed
|
|
return .handled
|
|
}
|
|
.onChange(of: isFocused) { _, focused in
|
|
guard !focused else { return }
|
|
store.renameBoard(draft)
|
|
}
|
|
.onAppear { draft = committed }
|
|
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
|
|
// updates the field, but never under the user's fingers: a draft being typed is the
|
|
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
|
|
// resilience, the same courtesy the inline editors get by tracking their UUID).
|
|
.onChange(of: committed) { _, title in
|
|
guard !isFocused else { return }
|
|
draft = title
|
|
}
|
|
// The popover can be dismissed without the field ever reporting focus loss, and a
|
|
// dismissal is a commit like any other click-away. Idempotent with the handler above.
|
|
.onDisappear { store.renameBoard(draft) }
|
|
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
|
|
// scope) — and the popover *stays open* under it, which is the style popover's settled
|
|
// precedent: the lock is a condition the banner is already explaining, not a reason to
|
|
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
|
|
// field and the editor below it disable as one surface rather than in halves.
|
|
.disabled(!store.acceptsBoardMutations)
|
|
}
|
|
|
|
/// The `title` on disk, as the last reload read it — "" for a board that has none.
|
|
private var committed: String { store.snapshot.title.value ?? "" }
|
|
|
|
/// The folder name, sans extension: what the window title shows when `title` is absent
|
|
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
|
|
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
|
|
/// mid-session shows here immediately.
|
|
private var fallbackName: String {
|
|
store.rootURL.deletingPathExtension().lastPathComponent
|
|
}
|
|
}
|
|
|
|
// 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 branch/source line with switching and creation, the abnormal-state
|
|
/// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`).
|
|
/// The remote half — tracking, Pull/Push, push-on-commit, authentication — 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 .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 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 `BoardInfoView` never instantiates this type at all — the section is
|
|
/// *absent*, 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), which needs it visible past this file even
|
|
/// though nothing outside `BoardInfoPopover.swift` calls it in the app itself.
|
|
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)
|
|
}
|
|
}
|