The git surfaces leave the glass — tab, trail, branch line, and the remote pair, peeled

Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 10:52:57 -04:00
parent ae7be98eaa
commit 1d97a2931c
16 changed files with 119 additions and 4525 deletions
+6 -25
View File
@@ -718,38 +718,19 @@ struct BoardWindowHost: View {
// card windows share that machinery and have no board to describe. It goes in after the // card windows share that machinery and have no board to describe. It goes in after the
// load rather than at attach because it carries the store; the controller installs it once, // load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second. // whichever of the two arrives second.
//
// The git state comes from the **session**, which `start()` began a moment ago, rather than
// from the entitlement or the disk: a board's popover must describe the board as it opened
// (12-editions.md The entitlement, "an open board finishes with the provider it composed";
// 06-history-undo.md Rules, mode is an open-time fact). A `nil` session cannot happen on
// this path `beginSession` precedes `configureWindow` and reads as a board with no
// repository, which is the harmless direction.
//
// **The tier is no longer passed down** (12 PIVOT 2026-08-07): git is tier-independent, so
// every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier`
// still exists and is still recorded it just has no git-facing consumer here.
//
// The settings sheet used to adopt the same fact here, so its two doors could validate on it
// (2026-07-31 2026-08-07). The sheet is retired and the popover is the one configuration
// home, so the git state is handed to exactly one surface again this widget and the
// mid-session transition 06 sanctions (add-git flipping the mode) re-resolves the Git tab's
// postures under it live, with no second reader to disagree.
let session = appModel.session(for: ref)
windowController.installTitlebarAccessory( windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory( boardInfoTitlebarAccessory(
store: store, store: store,
recents: appModel.styleRecents, recents: appModel.styleRecents,
git: session?.git,
presentation: boardInfo presentation: boardInfo
) )
) )
// The widget above now says the board's name (and, on a git-mode board, its branch) // The widget above now says the board's name itself, so the system title display would only
// itself, so the system title display would only repeat it the card-window seam // repeat it the card-window seam (`CardWindowHost.configureWindow`,
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for // `HostedWindowController.hideTitle`), applied here for the same reason.
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched // `.navigationTitle(windowTitle)` a few lines up in `body` is untouched `window.title`
// `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the // keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the title bar's own
// title bar's own rendering of that string is suppressed. // rendering of that string is suppressed.
// //
// **After the load, and only after it**, which is why it is not in the loading half above: // **After the load, and only after it**, which is why it is not in the loading half above:
// this line and the widget it defers to are one exchange, and a loading window that hid its // this line and the widget it defers to are one exchange, and a loading window that hid its
+1 -38
View File
@@ -257,7 +257,7 @@ final class CardWindowSession: CardSessionFlushing {
/// ///
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The /// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns the title /// two-column composition inside it is `CardWindowView`'s, and what fills those columns the title
/// field, Preview/Edit, the sidebar's five sections arrives card by card underneath a composition /// field, Preview/Edit, the sidebar's four sections arrives card by card underneath a composition
/// that does not move. The *window-scoped* state those surfaces need lives here, because a window is /// that does not move. The *window-scoped* state those surfaces need lives here, because a window is
/// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet /// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet
/// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can /// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can
@@ -288,9 +288,6 @@ struct CardWindowHost: View {
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail /// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`). /// on every reload (`AttachmentThumbnailCache`).
@State private var thumbnails = AttachmentThumbnailCache() @State private var thumbnails = AttachmentThumbnailCache()
/// This card's commit trail (05-card-window.md History). Held here for `thumbnails`' reason
/// it must survive every snapshot and surfaced to the view only in git mode (`cardHistory`).
@State private var history = CardHistory()
/// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
/// flush; cleared by the resolution that lets the close resume. /// flush; cleared by the resolution that lets the close resume.
@State private var isClosePending = false @State private var isClosePending = false
@@ -417,31 +414,6 @@ struct CardWindowHost: View {
.onDisappear { finish() } .onDisappear { finish() }
} }
/// **This card's commit trail, or nothing at all** (05-card-window.md History).
///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: a mode other than `git` means a board the app manages no history for, on any tier. The
/// question stopped having a tier half at 12-editions.md PIVOT 2026-08-07 every session
/// composes a git state now, so what decides the section is whether *this board's* history is
/// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`'
/// reason exactly.
private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history
}
/// What a trail re-read depends on: this card, and the number of commits the board has landed.
///
/// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every
/// commit the app makes the debounced ones, the launch catch-up, and a restore's. A foreign
/// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the
/// next commit or the next open, which is the same freshness bound the popover's branch line has
/// and a great deal cheaper than polling HEAD from a sidebar.
private func historyReloadKey(store: BoardStore) -> String {
let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0
return "\(ref.cardID)#\(commits)"
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md /// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps /// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had. /// the minimum it always had.
@@ -475,20 +447,11 @@ struct CardWindowHost: View {
comments: session.comments, comments: session.comments,
thumbnails: thumbnails, thumbnails: thumbnails,
undo: session.undo, undo: session.undo,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in onToggleTask: { offset, checked in
store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
} }
) )
// **The trail, re-read when a commit lands** (05 History). The id is the pair of facts
// the answer depends on: which card this is, and how many commits this board has made
// so the section refreshes after the app's own commits, after an agent's that the watcher
// committed, and after a Z's restore, with nothing here knowing what a committer is.
.task(id: historyReloadKey(store: store)) {
guard let cardHistory else { return }
await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID)
}
// **The listing is the snapshot's, republished** `Card.attachments`, which the loader // **The listing is the snapshot's, republished** `Card.attachments`, which the loader
// fills from `attachments/`'s top-level files in Finder order. Every write in the // fills from `attachments/`'s top-level files in Finder order. Every write in the
// section is bracketed, so the reload that refreshes this arrives by itself and the // section is bracketed, so the reload that refreshes this arrives by itself and the
+6 -20
View File
@@ -98,10 +98,12 @@ struct FindSteppingCommands: View {
/// did not move, the validation and the action filled in and the pair also carries the clause that /// did not move, the validation and the action filled in and the pair also carries the clause that
/// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md). /// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md).
/// ///
// m7-git: History is a plain command that focuses the sidebar's History section, and disables // View History used to anticipate the sidebar's git commit-trail section (`CardWindowView`),
// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md, // which left with app-managed git (strategy/01-git-excision.md, 2026-08-08). The row is re-tagged
// 07-sync-collab.md). It remains unconditionally disabled here the sidebar reserves the section's // rather than removed: it now anticipates the foreign-change journal successor (01-git-excision.md
// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus. // Successors deferred, its design and timing not yet ruled). It stays unconditionally disabled
// here there is no journal yet to focus and the sidebar no longer reserves a place for it; the
// row itself is the only surviving reservation of the slot.
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments /// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits, /// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`). /// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
@@ -114,19 +116,3 @@ struct CardViewCommands: View {
FutureCommand(title: "History") FutureCommand(title: "History")
} }
} }
// MARK: - Board Pull / Push
/// Board Pull / Push no default chord, remote-backed boards only (11-command-nexus.md;
/// 07-sync-collab.md: "also Board-menu items").
///
// m7-git: menu-bar twins of the board popover's own Pull/Push buttons (07-sync-collab.md).
// Validation will be remote-mode plus 06's abnormal-state pause ("disabled during 06's
// abnormal-state pause ... and on an unresolvable remote" 11-command-nexus.md). Unconditionally
// disabled here: there is no remote model, no popover twin, and no git mode to validate against yet.
struct RemoteCommands: View {
var body: some View {
FutureCommand(title: "Pull")
FutureCommand(title: "Push")
}
}
+7 -9
View File
@@ -265,15 +265,17 @@ struct KanbanApp: App {
} }
// The Board menu (11-command-nexus.md), complete and in its inventoried row order Open // The Board menu (11-command-nexus.md), complete and in its inventoried row order Open
// Card, Rename, Style, the card moves, the lane moves, the width pair, then the remote // Card, Rename, Style, the card moves, the lane moves, the width pair. Its items act on
// pair. Its items act on the frontmost board window, which they reach through the focus // the frontmost board window, which they reach through the focus system rather than through
// system rather than through the app model see `BoardCommands.swift`, which also owns // the app model see `BoardCommands.swift`, which also owns their validation.
// their validation.
// //
// **Board Settings came out 2026-08-07** with the sheet it opened (03 Board settings // **Board Settings came out 2026-08-07** with the sheet it opened (03 Board settings
// sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured // sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured
// in its popover, whose keyboard door is File Board Info I, so this menu's last divider // in its popover, whose keyboard door is File Board Info I, so this menu's last divider
// went with the row rather than being left to separate the remote pair from nothing. // went with the row.
//
// **The Board Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git
// itself (strategy/01-git-excision.md): the width pair is now the menu's last row.
CommandMenu("Board") { CommandMenu("Board") {
OpenCardCommand() OpenCardCommand()
BoardRenameCommand() BoardRenameCommand()
@@ -287,10 +289,6 @@ struct KanbanApp: App {
Divider() Divider()
LaneWidthCommands() LaneWidthCommands()
Divider()
RemoteCommands()
} }
CommandGroup(after: .windowList) { CommandGroup(after: .windowList) {
-390
View File
@@ -1,390 +0,0 @@
import SwiftUI
// MARK: - The git-mode section's state
/// **What the popover's git section shows on a git-mode board** a pure function of four facts, so
/// the surface 06-history-undo.md describes is assertable without a popover on screen.
///
/// It exists for the same reason `BoardGitSection.resolve` does one level up: the *posture* is the
/// part worth pinning, and the SwiftUI that renders it is not. Three rules live here
///
/// - **A paused repository names its state and disables the controls** (06 Rules Abnormal repo
/// states: "Undo/Redo and the branch controls disable the popover's git section names the state
/// plainly and says resolving it belongs to the tool that created it").
/// - **A read-only board disables them too** (02-architecture.md The lock's scope, which names "the
/// popover's git controls" outright).
/// - **A switch in flight disables them**, so a second click cannot start a second checkout.
/// - **An unreadable repository reads as broken, never as still loading** (06 Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31: "Never a silent placeholder discovered only in
/// the popover").
struct BoardGitBranchSurface: Equatable {
/// What the branch line reads the branch name, the short hash on a detached HEAD
/// (`GitRepository.branchName` decides which), the placeholder while the first read is in
/// flight, or `unavailableLabel` when there is no readable repository for it to name.
let branchLabel: String
/// Whether that label is the placeholder rather than an answer.
let isReadingBranch: Bool
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
let pauseExplanation: String?
/// **Whether the pause is the unopenable repository** the one pause whose surface is not the
/// pause note: nothing is in progress and no tool is coming to finish it, so the section says
/// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for.
let isRepositoryUnreadable: Bool
/// Whether the branch controls accept a click the switch picker, and the New Branch reveal
/// behind its divider (`BoardGitControls`), so that a paused repository, a read-only board and a
/// switch in flight close the whole branch affordance by one rule. (The settings sheet's standing
/// Create field resolved this same surface between 2026-07-31 and the 2026-08-07 reversal; the
/// field came back into the menu, the rule never moved.)
let controlsEnabled: Bool
/// The line the branch display is read as by VoiceOver.
///
/// The broken case is spelled out rather than left to fall through "Branch \(label)": the label
/// is a *state* there, not a name, and "Branch Unavailable" would read as a branch somebody
/// called Unavailable.
var accessibilityLabel: String {
if isRepositoryUnreadable { return "Branch unavailable" }
return isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
}
static let placeholder = ""
/// **What the branch line reads when the repository will not open** an answer, not a
/// placeholder, which is the whole of the ruling's "fails loudly" at this one control: the
/// placeholder means "still reading" and would go on meaning it forever here.
static let unavailableLabel = "Unavailable"
/// The second half of the paused sentence 06's "says resolving it belongs to the tool that
/// created it", said in the app's own voice and paired with the promise that makes it safe to
/// wait: the app is not going to touch the repository behind the user's back.
static let pauseCaption =
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched."
/// **The popover's own sentence for an unreadable repository** (06 Rules, the corrupt-`.git`
/// loud failure, ruled 2026-07-31: "with the whole git surface paused and the popover's git
/// section naming the state").
///
/// A sibling of the nested and unverifiable notes (`BoardGitTabView`) and written in their
/// register one sentence, the state first and the consequence after rather than the pause
/// note's two lines, because both of *those* lines would be wrong here: nothing is "in
/// progress", and there is no tool whose job it is to finish it. What it keeps from the pause
/// note is the promise that matters most on a repository the app cannot read, in the ruling's
/// own words.
///
/// It is deliberately **not** the banner's sentence (`BannerCenter.repositoryUnreadableMessage`)
/// re-used: the strip announces a condition to somebody who has not asked, and this answers
/// somebody looking straight at the git section the register the neighbouring notes set.
static let unreadableNote =
"Lanework can't read this board's git repository, so history is paused; the repository is left untouched."
static func resolve(
branch: String?,
pause: GitRepositoryPause?,
isSwitching: Bool,
isWritable: Bool
) -> BoardGitBranchSurface {
// Derived from the pause rather than passed in beside it: the pause *is* how this state is
// carried everywhere else (`GitRepositoryPause.unreadable`, seeded by the detection-time
// probe and refreshed by every later read), so a second parameter would be a second answer
// to one question and a caller could hold them apart.
let unreadable = pause == .unreadable
return BoardGitBranchSurface(
branchLabel: unreadable ? unavailableLabel : (branch ?? placeholder),
// Never "reading" on an unreadable repository: there is nothing in flight, and the line
// the ruling forbids is exactly the one that says otherwise forever.
isReadingBranch: !unreadable && branch == nil,
pauseExplanation: pause?.explanation,
isRepositoryUnreadable: unreadable,
controlsEnabled: pause == nil && isWritable && !isSwitching
)
}
}
// MARK: - The git-mode section
/// **The popover's git section on a board that has a repository** (03-board-ui.md Board popover;
/// 06-history-undo.md Branch switching).
///
/// **The whole branch affordance**: the branch display with its **switch** picker, the *New Branch*
/// entry behind that menu's divider and the inline field it reveals, and the pause explanation when
/// the surface is held.
///
/// **Creation left and came back.** The 2026-07-31 popover/sheet split moved it to the board settings
/// sheet as a standing Create field; the **2026-08-07 reversal** retired that sheet and restored the
/// shape the sheet's own doc comment had described as "the right shape *there*" an entry inside the
/// switch menu that reveals an inline field. The commit-identity fields came back the same day, to
/// the Git tab a level up (`BoardGitSetup.swift`), which is why they are not here: they are the
/// board's setup, and this is the branch.
///
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push and the status badges are
/// 07-sync-collab.md's own cards, and this section is arranged so they join as one more block under
/// the branch controls nothing here is nested inside anything they would have to be pulled out of,
/// and nothing about the branch controls assumes there is no upstream to show beside them.
struct BoardGitControls: View {
let git: HistoryStore
/// The read-only lock's reach (02-architecture.md The lock's scope): a board that refuses writes
/// refuses a checkout most of all it rewrites the tree the lock exists to stop describing.
let isEnabled: Bool
/// Whether **New Branch** has been picked and its field is standing open the reveal, which is
/// the menu entry's whole behaviour (03-board-ui.md Board popover Git tab, 2026-08-07). It
/// starts closed on every popover open, because the popover is rebuilt fresh each time
/// (`BoardInfoWidget`), which is exactly the transience the reveal shape is for.
@State private var isCreatingBranch = false
/// The name being typed, uncommitted. Cleared by a create and by Escape's first press.
@State private var draft = ""
/// Focus lands in the field the moment it appears, unlike the popover's rename field: this one is
/// opened by a gesture that means "name a branch now", which is the inline editors' case rather
/// than the configuration-surface case (11-command-nexus.md's class **C** distinguishes the two).
@FocusState private var isFieldFocused: Bool
private var surface: BoardGitBranchSurface {
BoardGitBranchSurface.resolve(
branch: git.branch,
pause: git.committer?.pause,
isSwitching: git.switcher?.isSwitching ?? false,
isWritable: isEnabled
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
branchRow
if isCreatingBranch {
creationField
}
// **The unreadable repository names itself in its own sentence** (06 Rules, the
// corrupt-`.git` loud failure) checked before the pause note because it *is* a pause,
// and the pause note's second line ("finishing it belongs to the tool that started it")
// would be advice about an operation nobody started.
if surface.isRepositoryUnreadable {
caption(BoardGitBranchSurface.unreadableNote, tone: .primary)
} else if let explanation = surface.pauseExplanation {
pauseNote(explanation)
}
if let failure = git.switcher?.lastFailure {
caption(failure.message, tone: .red)
}
}
// Every read the section needs, taken when it appears rather than held live: the popover is
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
// watcher could deliver `.git` is filtered out of the watch by design.
.task {
await git.refreshBranch()
await git.committer?.refreshPause()
await git.switcher?.refreshBranches()
}
}
// MARK: The branch line
/// The branch display and the switch, as one control: the line *is* the picker, which is what
/// makes "branch/source display and switching" one affordance rather than a label with a button
/// beside it.
///
/// **Two halves under a divider** since the 2026-08-07 reversal: the switch targets above, and
/// **New Branch** below, which reveals the inline field under this row rather than acting. That
/// is the shape the 2026-07-31 split took creation *out* of into the settings sheet's standing
/// Create field and the shape the reversal restored when the sheet retired; the sheet's own doc
/// comment had called it "the right shape *there*", meaning here.
///
/// **The menu is therefore never empty**, which is what the divider guarantees rather than the
/// content: New Branch always applies to a board that has a repository. **A single-branch board
/// still opens onto a disabled explanatory row** in the upper half (03-board-ui.md Board popover
/// Git tab, ruled 2026-08-06 and built with the tab): an unexplained gap above the divider would
/// read as a menu that lost something, not as a board with one branch.
private var branchRow: some View {
// Resolved once and handed to both halves of the menu builder: the emptiness *is* the
// condition being rendered, so asking twice would be asking the same question of a store
// that could answer differently between the two reads.
let targets = otherBranches
return HStack(spacing: 6) {
Image(systemName: "arrow.triangle.branch")
.imageScale(.small)
.foregroundStyle(.secondary)
Menu {
if targets.isEmpty {
// **The single-branch board's row** (03-board-ui.md Board popover Git tab,
// ruled 2026-08-06). It teaches the one thing an empty upper half leaves a user
// to guess at: *why* there is nothing to pick this half holds only the
// **other** local branches, and there are none. (It carried a second teaching
// while creation lived in the settings sheet, standing where "New Branch" used
// to; the 2026-08-07 reversal put that entry back one line below, so the row's
// second job is done and the sentence is now exactly what it says.)
//
// A bare `Text` inside a `Menu` is AppKit's standard disabled item: greyed,
// unclickable, and read by VoiceOver as disabled text rather than as an
// actionable row which is exactly the register a sentence explaining an absence
// wants (06 Rules: teach, never look broken; never a disabled button pretending
// to be a control).
Text("No other branches")
} else {
ForEach(targets, id: \.self) { name in
Button(name) {
Task { await git.switcher?.switchTo(name) }
}
}
}
Divider()
// **Creation, revealed rather than performed** the entry opens the field below the
// row and nothing else, so the act of naming a branch happens in the surface the user
// can see and correct rather than inside a menu that has already closed.
Button("New Branch…") {
isCreatingBranch = true
}
} label: {
Text(surface.branchLabel)
.font(.callout)
.foregroundStyle(surface.isReadingBranch ? .secondary : .primary)
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(!surface.controlsEnabled)
.accessibilityLabel(surface.accessibilityLabel)
.accessibilityHint("Switch branches")
if git.switcher?.isSwitching == true {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Switching branches")
}
}
}
/// This board's switch targets, off the live switcher see `switchTargets(branches:current:)`
/// for the rule itself.
private var otherBranches: [String] {
Self.switchTargets(branches: git.switcher?.branches ?? [], current: git.branch)
}
/// **Every local branch except the one already checked out** a picker offering the current
/// branch would be offering a no-op, and the switch refuses one anyway
/// (`BranchSwitchSequenceTests`' "a switch to the branch already checked out does nothing").
///
/// A pure static seam rather than a computed property alone, for `BoardGitBranchSurface.resolve`'s
/// reason one level up: the *rule* is the part worth pinning (`BranchSwitchTargetTests`) and the
/// `Menu` it fills is SwiftUI. It is also the predicate the disabled "No other branches" row hangs
/// on (03-board-ui.md Board popover Git tab, 2026-08-06), which makes "when is that row
/// shown" a question with one testable answer rather than a shape buried in a view builder.
///
/// A `nil` `current` the moment before the first branch read answers passes every branch
/// through rather than none: the list is a set of candidates, and the switch's own gate is what
/// decides whether one can be taken. `nonisolated` because nothing here touches the view: a pure
/// filter over plain values, reachable from a test with no actor to hop to.
///
/// The repository's own ordering survives untouched `GitBranchOperation.localBranches` is what
/// libgit2 listed, and a picker that re-sorted it would be inventing an order the repository
/// never had.
nonisolated static func switchTargets(branches: [String], current: String?) -> [String] {
branches.filter { $0 != current }
}
// MARK: The creation reveal
/// **What New Branch reveals** (03-board-ui.md Board popover Git tab, restored 2026-08-07):
/// a name field and a Create button, under the branch row, for exactly as long as the user is
/// naming something. It is a detour off the daily face and it looks like one which is the
/// argument the 2026-07-31 split made *against* it in a sheet, and for it here.
///
/// **The sequence is not this view's.** `GitBranchSwitcher.createAndSwitch(to:)` runs the
/// identical settle flush stamp switch sequence the picker's switch does "no at-HEAD fast
/// path" (06-history-undo.md Branch switching, blessed 2026-07-31) and `create()` calls
/// exactly that method.
///
/// **Failures answer where they were asked**: the section's existing switcher caption below is
/// this control's answer too (06 Rules, the form-anchored rule as the 2026-08-06 create-and-
/// switch overlap settled it "inline while the asking surface is up, banner once it is gone").
/// The popover *is* the asking surface now that the sheet is retired, so there is one caption for
/// both halves of the branch affordance and no second slot to keep in sync.
///
/// No `ProgressView` of its own, for the same reason: `branchRow` already shows the in-flight
/// spinner, and a switch has one state whichever control started it.
private var creationField: some View {
HStack(spacing: 6) {
TextField("New branch name", text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFieldFocused)
.onAppear { isFieldFocused = true }
.onSubmit { create() }
// **Escape steps outward one layer per press** (04-interactions.md Grammar, the
// rename field's rule and the retired sheet section's before it): a dirty field
// abandons its draft and the reveal stays open; an empty one closes the reveal, which
// is the layer above it; a further press then reaches the popover's own dismissal.
.onKeyPress(.escape) {
if !draft.isEmpty {
draft = ""
return .handled
}
isCreatingBranch = false
return .handled
}
.accessibilityLabel("New branch name")
Button("Create", action: create)
.disabled(trimmedDraft.isEmpty)
}
// The branch controls' one rule, applied to the reveal as it is to the picker: a paused
// repository, a read-only board and a switch in flight close it (`BoardGitBranchSurface`).
.disabled(!surface.controlsEnabled)
}
private var trimmedDraft: String {
draft.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Creates the named branch and switches to it, then puts the reveal away a create is the end
/// of the detour, and a field left standing over a branch that now exists would be inviting a
/// second one nobody asked for.
private func create() {
let name = trimmedDraft
guard !name.isEmpty, surface.controlsEnabled else { return }
draft = ""
isCreatingBranch = false
Task { await git.switcher?.createAndSwitch(to: name) }
}
// MARK: The pause
/// **The abnormal-state surface** (06 Rules Abnormal repo states) deferred here from the
/// auto-commit card, which built the hold this explains.
///
/// Two sentences, both of them the design's: what the repository is doing, and whose job it is to
/// finish. Never a Repair button "the app never mutates repo state it didn't create".
private func pauseNote(_ explanation: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(explanation)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(BoardGitBranchSurface.pauseCaption)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}
private func caption(_ text: String, tone: Color) -> some View {
Text(text)
.font(.caption)
.foregroundStyle(tone)
.fixedSize(horizontal: false, vertical: true)
}
}
-229
View File
@@ -1,229 +0,0 @@
import SwiftUI
/// **The Git tab's two setup controls** add-git and the commit-identity fields (03-board-ui.md
/// Board popover Git tab; 06-history-undo.md Rules Opt-in init and Interaction with external
/// writers).
///
/// ### One configuration home again (ruled 2026-08-07)
///
/// These two lived in the popover, moved to the board settings sheet with the **2026-07-31
/// popover/sheet split**, and came back with the **2026-08-07 reversal** that retired that sheet: the
/// popover is the board's one configuration surface, and its Git tab is where a board's repository is
/// both operated and set up. Nothing about either control's *substance* moved in either direction
/// what moved is the container, and every rule stated below is the same rule the sheet carried,
/// re-pointed at the tab.
///
/// The one thing the reversal does change is what "the form is visible" means: it is the **popover's**
/// visibility now, and a popover is transient where a sheet was not. That is fine for both rules that
/// depend on it add-git's inline-answer window and the identity fields' poll are both scoped to "the
/// surface the user asked from is still under their eye", and a popover dismissed by a stray click
/// ends that window exactly as Done ended the sheet's (06 Rules: "inline while the asking surface is
/// up, banner once it is gone").
///
/// They live in their own file rather than in `BoardGitTabView.swift` because they are the *setup*
/// half the tab's postures and its daily branch face are that file's and `BoardGitControls.swift`'s
/// and because a pro-m2 card adding the remote and credential surfaces adds them beside these,
/// under the same rules, rather than into the posture switch.
// MARK: - Geometry
/// The setup form's one figure, **derived from the body font** like every other surface's
/// (10-accessibility.md Text scaling: "relative text styles everywhere, no fixed point sizes").
///
/// It is what survives `BoardSettingsSheetLayout`, which retired with the sheet 2026-08-07: the
/// sheet's width and section inset were a *window's* geometry and the popover supplies both itself
/// (`BoardInfoView`), but the identity form's label column is the form's own and travelled with it.
enum BoardGitSetupLayout {
/// The identity form's label column. 3.4 em: 44pt at the standard body, which is what those
/// fields have always drawn.
static func labelColumn(bodyPointSize: CGFloat) -> CGFloat {
BoardMetrics.em(3.4, bodyPointSize: bodyPointSize)
}
}
// MARK: - Add git
/// **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.
///
/// **It renders inline in the Git tab's no-repository posture** since the 2026-08-07 reversal under
/// the note that states the fact, where the Board Settings door stood between 2026-07-31 and that
/// day. The posture is unchanged in every other respect: the fact, then the offer.
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.
/// The popover **stays open** under the lock and disables in place, which is the style popover's
/// settled precedent (03 Board popover).
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 form add-git answers at** (06 Interaction with external writers, ruled 2026-07-31
// "Form-anchored operations answer at the form first"): inline while this control is on
// screen, the banner once it is gone. Appearing claims the inline surface; disappearing gives
// it up, which both dismisses the stale error and sends any answer still in flight to the
// banner instead of to nobody.
//
// The control's visibility *is* the Git tab's, and the tab's is the popover's a narrower
// window than the sheet's was until 2026-08-07, and the right one: a popover dismissed by a
// click outside is precisely the user leaving the form. Two other disappearances are not
// dismissals and both are correct: switching to Info or Theme, which puts the question away
// as surely as closing the popover, and a *successful* add-git flipping the mode out from
// under this posture the failure slot empties because there is nothing left to fail.
.onAppear { git.noteFormVisible(true) }
.onDisappear { git.noteFormVisible(false) }
}
}
// MARK: - Commit identity
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md Interaction
/// with external writers: "the identity section exposes name/email fields that write that repo-local
/// config the setting *is* the file, portable to any git client, per-board by nature").
///
/// The fields moved to the sheet with the 2026-07-31 split and back to the popover's Git tab with the
/// 2026-08-07 reversal, poll included 06 says the visibility-scoped re-read "rides with the fields",
/// so hosting the view here *is* the re-point: the `.task` below now lives and dies with the tab.
///
/// ### The placeholder is the whole of the identity rule made visible
///
/// An empty field shows the **derived default** the macOS account's full name and
/// `shortname@hostname` as a placeholder, never as a value. That is the difference between "this
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
/// first focus loss.
///
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
///
/// A foreign config edit landing while the tab is open updates an *unfocused* field and never a
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md Board popover). The
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
/// the alternative to a small periodic read is a field that is stale for as long as the surface stays
/// open.
struct BoardGitIdentityFields: View {
let git: HistoryStore
let isEnabled: Bool
@State private var name = ""
@State private var email = ""
@FocusState private var focused: Field?
private enum Field: Hashable {
case name
case email
}
/// **The fields re-read the config at 2 s while they are visible** (06 Interaction with
/// external writers, blessed 2026-07-31): "the watcher never delivers `.git`, so no board event
/// can carry a terminal-side config edit the unfocused-resync courtesy needs its own signal, and
/// a visibility-scoped poll is the 15 s paused-state re-read's shape at form cadence (a focused
/// field keeps its keystrokes; dismissing the surface stops the poll)." The surface the ruling
/// named was the sheet; since 2026-08-07 it is the popover's Git tab, which is a *shorter* life
/// than the sheet's and therefore a strictly smaller poll.
private static let pollInterval: Duration = .seconds(2)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
if let failure = git.identityFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.task {
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
// is the popover closing or the tab strip moving off Git.
while !Task.isCancelled {
await git.refreshIdentity()
try? await Task.sleep(for: Self.pollInterval)
}
}
.onAppear {
name = git.identityName
email = git.identityEmail
}
.onChange(of: git.identityName) { _, value in
guard focused != .name else { return }
name = value
}
.onChange(of: git.identityEmail) { _, value in
guard focused != .email else { return }
email = value
}
// A dismissal is a commit like any other click-away `BoardRenameField`'s rule, and the same
// idempotence makes the overlap harmless.
.onDisappear { commit() }
}
private func field(
_ label: String,
text: Binding<String>,
placeholder: String,
tag: Field
) -> some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(
width: BoardGitSetupLayout.labelColumn(bodyPointSize: BoardMetrics.bodyPointSize),
alignment: .leading
)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($focused, equals: tag)
.onSubmit { commit() }
.disabled(!isEnabled)
.accessibilityLabel("Commit \(label.lowercased())")
}
.onChange(of: focused) { previous, _ in
// Focus leaving *this* field is this field's commit the inline editors' exit, applied
// to a form where Tab moves between two of them.
guard previous == tag else { return }
commit()
}
}
/// Writes both fields, and only when one of them differs from what the file says an unchanged
/// value must not rewrite `.git/config` every time the popover closes.
private func commit() {
guard isEnabled else { return }
guard name != git.identityName || email != git.identityEmail else { return }
Task { await git.writeIdentity(name: name, email: email) }
}
}
-348
View File
@@ -1,348 +0,0 @@
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 then, later the same day, **the board
/// settings sheet's contents rehomed into it too**. 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 *setting up*, and per-item history is the card
/// window's History section (05-card-window.md).
///
/// ### Operating and setup, one surface again (ruled 2026-08-07)
///
/// The tab settled as "the daily face, and only that" the 2026-07-31 popover/sheet split's half
/// with a **Board Settings** row pointing at the sheet that held add-git, branch creation and the
/// commit identity. That split is **reversed**: the sheet retires, the row with it, and the two setup
/// controls render inline in the postures they belong to (`BoardGitSetup.swift`). Branch creation
/// went back where it came from, the switch menu (`BoardGitControls.branchRow`). What is left is one
/// configuration home per board the popover reached by the widget or Board Info I, and no
/// surface that has to be validated into existence before a door can point at it.
///
/// ### 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.
///
/// ### Every board has this tab (pivot 2026-08-07)
///
/// 12-editions.md PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what
/// that does to this surface: with no free-only postures left, every board resolves one of the four
/// mode postures below and **the tab is always in the strip**. The membership rule the Git session
/// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` it simply never hears
/// "absent" any more, because there is no longer a posture that says nothing.
// MARK: - The posture
/// **What the Git tab shows, for one board** (03-board-ui.md Board popover Git tab;
/// 06-history-undo.md Rules) a pure function of the board's **mode**, so the posture matrix is
/// provable without a popover on screen.
///
/// The four 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) is 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, and the
/// 2026-08-07 reversal filled them back in.** Setup left the popover for the board settings sheet,
/// so mode `none` rendered no action here at all for a week (the case was called `.addGit` when it
/// did, and was renamed `.noRepository` when the control left) and the git-mode case lost branch
/// creation and the identity fields; the reversal retired that sheet and brought all three back
/// inline. The case name stays `.noRepository` it describes the board, which is what a posture is
/// for, and it survived the round trip precisely because it never named a control. What each case
/// *is* is a posture, which is why the matrix and its test survived both moves unchanged.
///
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** the same cases, now
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
/// single pane.
///
/// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md PIVOT 2026-08-07, the
/// same day). The two free-tier cases `.absent` on an ordinary board, `.proPointer` on a board
/// carrying an inert `.git`, both settled 2026-07-27 described a gate that no longer exists: git
/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every
/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are
/// **retired**, and what is left is the mode which is what the postures were always about.
enum BoardGitSection: Equatable, CaseIterable {
/// 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, and **add-git directly under it** since the
/// 2026-08-07 reversal (`BoardGitAddAction`), where the Board Settings door stood for the week
/// the sheet existed. The fact-then-offer posture blessed 2026-08-06, restated for a surface
/// whose header is the tab label and whose offer is the control itself rather than a door to it.
///
/// **Every tier's posture since the pivot** (03 Git tab, pivot note 2026-08-07), and git stays
/// **opt-in per board**: the button is an offer, never an auto-init.
case noRepository
/// Repo-nested: the honest explanation, no action and nothing setup-shaped either, since
/// nothing setup-shaped can apply (`BoardGitSetupSection.resolve`, empty here).
case repoNested
/// 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 setup `BoardGitSetupSection.resolve` empty), but its own case so the view
/// renders its own honest prose rather than the nested sentence "unverifiable" is not
/// "nested".
case unverifiable
/// Git mode: the branch/source line with the **switch** picker (which carries New Branch again
/// since 2026-08-07), the abnormal-state explanation when the surface is held, and the commit
/// identity block. 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(mode: BoardGitMode) -> BoardGitSection {
switch mode {
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
case .unverifiable: return .unverifiable
}
}
}
// MARK: - The setup inventory
/// **Which setup controls this tab hosts for one board** a pure function of the mode and of whether
/// the repository opens, so the rehomed inventory is provable without a popover on screen.
///
/// It is `BoardSettingsSection.resolve`'s successor, and deliberately its same shape: that enum was
/// the *sheet's* inventory (2026-07-31 2026-08-07) and it retired with the sheet, but the rule it
/// carried is about the **board**, not about the container, so it survives the reversal re-pointed at
/// the tab. Branch creation is not a case here for the same reason it was one there and is not now:
/// it went back into the switch menu (`BoardGitControls.branchRow`), which is a daily control with an
/// inline reveal rather than a standing form. pro-m2's remote and credential cards each add a case
/// here and a block in `BoardGitTabView.posture` nothing else.
///
/// Ordered as the tab lays them out, and the order is trivially the postures' own: no board is ever
/// in both modes, so the array is one element or none. It stays an array rather than an `Optional`
/// because the pro-m2 cards land in the git-mode posture beside `.commitIdentity`.
enum BoardGitSetupSection: String, Equatable, CaseIterable, Identifiable {
/// Mode `none`: **add-git** (06-history-undo.md Rules Opt-in init) the offer, on every tier
/// since 12-editions.md PIVOT 2026-08-07, and still never an auto-init.
case addGit
/// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
/// (06 Interaction with external writers).
case commitIdentity
var id: String { rawValue }
/// The block's header a heading VoiceOver navigates by (10-accessibility.md's navigable-header
/// rule, carried over from the sheet's sections). `.addGit` has none: it renders directly under
/// the no-repository note, which already states what the posture is, and a "Git" header inside
/// the Git tab would be the surface naming itself twice (this file's opening note).
var title: String? {
switch self {
case .addGit: nil
case .commitIdentity: "Commit Identity"
}
}
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
/// `git` every other mode has no repository for the probe to have failed on, and a caller
/// that has no git state to ask is describing one of those boards.
///
/// `nonisolated` for `BoardGitControls.switchTargets`' reason: a pure answer over plain values,
/// reachable from a test with no actor to hop to.
nonisolated static func resolve(
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> [BoardGitSetupSection] {
switch mode {
case .none:
return [.addGit]
case .git:
// **An unreadable repository hosts no setup** (06-history-undo.md Rules, the
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
// repo-nested's own reasoning, one step further along: writing an identity is a *write*
// into the repository's own config, and there is no repository the app can open to write
// it into. The posture is not empty, though that is the difference the sheet could not
// express and the tab can: `BoardGitControls` still renders, holding, with its own
// sentence explaining the state (`BoardGitBranchSurface.unreadableNote`). What the
// unreadable board loses is the setup block alone.
//
// The mode stays `.git` throughout this is emptiness *within* git mode, never a fall
// to mode none, which is what would let add-git be offered against an existing `.git`.
return isRepositoryUnreadable ? [] : [.commitIdentity]
case .repoNested:
// **Nothing setup-shaped can apply** (06 Rules): the board lives inside a repository
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
// is *absent*, "prose, not a disabled button") and no repo-local config of ours to write.
// The posture's whole content is its explanation.
return []
case .unverifiable:
// **Structurally the same emptiness as `.repoNested`, for the same reason** (06 Rules
// Detection, "Denial is not absence"): a denied ancestor check can never be told apart
// from a repository actually being there, so add-git stays unreachable. Only the
// posture's *prose* tells the two apart this inventory does not, because there is
// nothing to set up on either.
return []
}
}
}
// MARK: - The notes
/// **The no-repository caption** (03-board-ui.md Board popover Git tab) the fact, stated, above
/// the add-git button that offers to change it.
///
/// 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 button directly below it is where a user says otherwise. (It read "above
/// the Board Settings door" between 2026-07-31 and the 2026-08-07 reversal; the sentence never
/// changed, only what stands under it.)
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)
}
}
// MARK: - The tab
/// The Git tab's surface: whichever of the four postures this board is in, with the setup controls
/// that posture hosts rendered inline in it (`BoardGitSetupSection`).
struct BoardGitTabView: View {
let store: BoardStore
/// 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.
///
/// Optional because a caller with no session in hand (previews, the accessory-installation tests)
/// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a
/// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a
/// *tier* signal every session composes one (12 PIVOT 2026-08-07).
let git: HistoryStore?
/// 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
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(mode: git?.mode ?? .none) {
case .noRepository:
// Nothing daily to show on a board with no repository so the tab is the fact and the
// offer. Add-git spent a week behind the settings sheet's door (2026-07-31 the
// 2026-08-07 reversal) and is back inline under the note that says why it is there. A
// `nil` git previews, the accessory-installation tests renders the note alone: the
// fact is the honest thing to say about a board nothing has been detected about, and
// there is nothing to add a repository *to*.
VStack(alignment: .leading, spacing: 6) {
BoardGitNoRepositoryNote()
if let git, setup.contains(.addGit) {
BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations)
}
}
case .repoNested:
BoardGitNestedNote()
case .unverifiable:
BoardGitUnverifiableNote()
case .branch:
// The daily face first the branch line with its switch menu, which carries New Branch
// again since the 2026-08-07 reversal then the one setup block this posture hosts.
// The identity block is gated on the setup inventory rather than on a condition spelled
// out here, which is what keeps "an unreadable repository hosts no setup" one rule with
// one test (`BoardGitSetupSection.resolve`); `BoardGitControls`' own unreadable sentence
// stands alone under it.
//
// `inset` as the gap rather than the 6pt row rhythm: the identity block is a *section*
// under its own heading, and the popover's section spacing is the figure the retired
// sheet used between its sections for the same reason (`BoardInfoView.inset`).
VStack(alignment: .leading, spacing: inset) {
if let git {
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
}
if let git, setup.contains(.commitIdentity) {
setupBlock(.commitIdentity) {
BoardGitIdentityFields(git: git, isEnabled: store.acceptsBoardMutations)
}
}
}
}
}
/// What this board's setup half holds the sheet's inventory rule, re-pointed at the tab it
/// rehomed into (`BoardGitSetupSection`). Read once per posture branch rather than re-derived
/// beside each block.
private var setup: [BoardGitSetupSection] {
BoardGitSetupSection.resolve(
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
)
}
/// A setup block under its own header, where the section carries one.
///
/// **The header is an accessibility structure, not decoration** (10-accessibility.md's
/// navigable-header rule, which the retired sheet's sections carried and which came along with
/// them): the rotor jumps between headings rather than walking one flat run of controls. This is
/// the tab's *only* header the no-repository posture's control has none, and the tab label
/// still does the naming for the surface as a whole (this file's opening note).
@ViewBuilder
private func setupBlock(
_ section: BoardGitSetupSection,
@ViewBuilder content: () -> some View
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let title = section.title {
Text(title)
.font(.subheadline.weight(.semibold))
.accessibilityAddTraits(.isHeader)
}
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+63 -142
View File
@@ -4,21 +4,17 @@ import SwiftUI
/// **The board popover** "the one board-level surface" (03-board-ui.md § Board popover), and the /// **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. /// widget in the window's titlebar that opens it.
/// ///
/// **Tabbed since 2026-08-07 the restructure is complete, all three tab sessions settled.** The /// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs
/// symbol/name header stays at the top; below it sit the tabs **Info**, **Theme**, **Git** each /// **Info**, **Theme**, **Sync** each the settings surface for one aspect of board configuration,
/// the settings surface for one aspect of board configuration, each settled in its own dedicated /// each settled in its own dedicated design session: `BoardInfoTabView`, the metrics dossier;
/// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color / /// `BoardThemeTabView`, the Solid color / Pattern picker (the Background tab's original name, before
/// Pattern picker (the Background tab's original name, before the same session widened it past the /// the same session widened it past the generated-only picker and folded manual styling back out to
/// generated-only picker and folded manual styling back out to Style S); and `BoardGitTabView`, /// Style S); and `BoardSyncTabView`, a standing placeholder for the future ops-based sync service.
/// which the pre-tab body's mode-aware git section rehomed into whole postures and notes, none of /// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md,
/// them re-ruled by the move. That tab carried a **Board Settings** row to a separate sheet until /// 2026-08-08).
/// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents
/// rehomed into the tab (see below).
/// ///
/// **Tab membership is the git posture's**, and **selection resets to Info on every open** both /// **Selection resets to Info on every open** the tab session's ruling, unaffected by the git
/// the Git session's rulings. Since 12-editions.md PIVOT 2026-08-07 the first of those is a /// excision: see `BoardInfoTab`.
/// structural rule with nothing left to exclude: every board carries all three tabs (03 Board
/// popover, the same-day pivot note) see `BoardInfoTab`.
/// ///
/// ### One home, deliberately /// ### One home, deliberately
/// ///
@@ -29,11 +25,9 @@ import SwiftUI
/// ///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31 /// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and /// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render /// a row here pointing at it. The sheet is retired and Board Board Settings left the menu bar with
/// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the /// it. So "one home per control" the split's own promise is now satisfied by there being one
/// switch menu (`BoardGitControls`), and Board Board Settings left the menu bar with them. So /// surface, and I is the door to all of it.
/// "one home per control" the split's own promise is now satisfied by there being one surface,
/// and I is the door to all of it.
// MARK: - Presentation state // MARK: - Presentation state
@@ -74,17 +68,13 @@ extension FocusedValues {
// MARK: - The window-title widget // MARK: - The window-title widget
/// The titlebar widget: the board's glyph beside a two-line identity block its name, and on a /// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose
/// git-mode board its branch under the name with a trailing disclosure chevron, whose one job is /// one job is this popover.
/// this popover.
/// ///
/// **A two-line stack since 2026-08-07** (03-board-ui.md Board popover, the window-title widget /// **Was a two-line stack** (03-board-ui.md Board popover, the window-title widget passage): a
/// passage). It was one line reading `glyph Title branch `, and the em-dash was the tell: a /// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line
/// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it /// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the
/// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash /// single title line, vertically centred beside the glyph.
/// retired, and the glyph grew to span both lines an icon sized to the block it labels rather than
/// to whichever line it happened to sit on. A board with no branch is the single title line, vertically
/// centred beside the same glyph, which is the same block with one row.
/// ///
/// **The popover is anchored to the widget itself** it hangs from the button rather than from the /// **The popover is anchored to the widget itself** it hangs from the button rather than from the
/// window or the board which is what makes the affordance and the surface read as one thing. A /// window or the board which is what makes the affordance and the surface read as one thing. A
@@ -93,37 +83,25 @@ extension FocusedValues {
/// only the *placement* (`boardInfoTitlebarAccessory`). /// only the *placement* (`boardInfoTitlebarAccessory`).
/// ///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron /// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/branch/chevron button): the title and branch strings sit inside the same /// button to the full name/chevron button): the title string sits inside the same `Button`, so a
/// `Button`, so a click anywhere across the board's name or its branch, when shown opens the /// click anywhere across the board's name opens the popover exactly as a click on the chevron
/// popover exactly as a click on the chevron always has. /// always has.
struct BoardInfoWidget: View { struct BoardInfoWidget: View {
let store: BoardStore let store: BoardStore
let recents: StyleRecents let recents: StyleRecents
/// 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,
/// or a branch switch, redraws the widget without anything here being re-created.
///
/// It stopped being a tier signal at 12 PIVOT 2026-08-07 every session composes a git state
/// now, whatever the tier so a `nil` here means only "this caller had no session to hand over"
/// (previews, the accessory-installation tests), which reads as mode `none`.
let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation @Bindable var presentation: BoardInfoPresentation
/// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere. /// The widget's title string, computed fresh on every body evaluation rather than cached
/// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once** /// anywhere. That matters here specifically: `boardInfoTitlebarAccessory` builds this view
/// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and /// exactly **once** at install, so a value read anywhere but inside `body` would freeze at the
/// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both /// widget's birth and never see a later rename. `store.snapshot` is `@Observable`, so reading it
/// `@Observable`, so reading them here is what makes the title and branch live. /// here is what makes the title live.
private var summary: BoardInfoTitlebarSummary { private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary( BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value, snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL, rootURL: store.rootURL
mode: git?.mode ?? .none,
branch: git?.branch
) )
} }
@@ -151,11 +129,8 @@ struct BoardInfoWidget: View {
// everything VoiceOver needs (`accessibilityLabel` below). // everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true) .accessibilityHidden(true)
// The identity block: the name, and the branch beneath it on a git-mode board. On // The identity block: the board's name. The `HStack`'s own centring puts it level
// any other board this is the single title line and the `HStack`'s own centring puts // with the glyph.
// it level with the glyph the stack is the same shape with one row, never a
// special case.
VStack(alignment: .leading, spacing: 1) {
Text(summary.title) Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for // Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget). // (`BoardWindowHost` hides the system title display in favor of this widget).
@@ -163,21 +138,8 @@ struct BoardInfoWidget: View {
.foregroundStyle(.primary) .foregroundStyle(.primary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
// Yields space to the chevron first when the title doesn't fit inside the width cap
if let branch = summary.branch { // below the board's identity is the more load-bearing half of the pair.
// Smaller and secondary the qualifier under the name it qualifies. The
// em-dash that separated the two on one line retired with the stack: a
// hierarchy that a layout can state does not need punctuation to state it.
Text(branch)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
// Yields space to the chevron first when the block doesn't fit inside the width cap
// below the board's identity is the more load-bearing half of the pair, and both
// its lines truncate rather than the disclosure disappearing.
.layoutPriority(1) .layoutPriority(1)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
@@ -186,11 +148,9 @@ struct BoardInfoWidget: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.font(.system(size: 13)) .font(.system(size: 13))
// A long board name (or branch) must not swallow the whole titlebar capped rather // A long board name must not swallow the whole titlebar capped rather than left to
// than left to grow, with the truncation above doing the rest. The height is the // grow, with the truncation above doing the rest. The height keeps the accessory
// two-line block's (2026-08-07; it was the original chevron's 18 while the widget was // titlebar-appropriate: no taller than a standard title bar carries.
// one line), which is what keeps the accessory titlebar-appropriate: tall enough for
// name-over-branch, and no taller than a standard title bar carries.
.frame(maxWidth: 400, alignment: .leading) .frame(maxWidth: 400, alignment: .leading)
.frame(height: 32) .frame(height: 32)
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -199,24 +159,16 @@ struct BoardInfoWidget: View {
.help("Board Info") .help("Board Info")
.accessibilityLabel(accessibilityLabel) .accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info") .accessibilityHint("Shows board info")
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
// has actually been opened once). The widget is on screen from the start, so it is the
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
// costs nothing on the other four postures.
.task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, git: git) BoardInfoView(store: store, recents: recents)
} }
} }
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's /// What VoiceOver reads for the button: the board's name `.help` keeps the shorter "Board
/// name, plus the branch when the widget is showing one `.help` keeps the shorter "Board Info" /// Info" wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names
/// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what /// what the button does.
/// the button does.
private var accessibilityLabel: String { private var accessibilityLabel: String {
guard let branch = summary.branch else { return summary.title } summary.title
return "\(summary.title), branch \(branch)"
} }
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary /// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
@@ -231,10 +183,9 @@ struct BoardInfoWidget: View {
// MARK: - The widget's strings // MARK: - The widget's strings
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its /// **The window-title widget's title string, as one pure function** of the board's on-disk title and
/// folder, and the session's git posture pulled out so the fallback rule and the branch-visibility /// its folder pulled out so the fallback rule is assertable without a widget on screen
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same /// (`BoardInfoTitlebarSummaryTests`).
/// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`.
/// ///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title /// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window /// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
@@ -245,26 +196,19 @@ struct BoardInfoWidget: View {
/// with plain values and no fixture board on disk the one duplication this card leaves behind /// with plain values and no fixture board on disk the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites. /// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
/// ///
/// **Branch.** Shown only when the board is actually git-mode `mode == .git` with a non-`nil` /// **Branch.** The widget once carried a second line the board's git branch, shown on a git-mode
/// branch the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause /// board as a `branch` field here. That line left with app-managed git
/// is gone** (12-editions.md PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a /// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now.
/// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
/// .git` until that day is recorded here rather than restated in code. A board with no repository or
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable { struct BoardInfoTitlebarSummary: Equatable {
let title: String let title: String
let branch: String?
init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) { init(snapshotTitle: String?, rootURL: URL) {
if let snapshotTitle, !snapshotTitle.isEmpty { if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle self.title = snapshotTitle
} else { } else {
self.title = rootURL.deletingPathExtension().lastPathComponent self.title = rootURL.deletingPathExtension().lastPathComponent
} }
self.branch = mode == .git ? branch : nil
} }
} }
@@ -276,21 +220,15 @@ struct BoardInfoTitlebarSummary: Equatable {
/// window, removed on detach for the same reason it owns the delegate proxying: the window is /// 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. /// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor @MainActor
/// `git` defaults to no session, so that a caller with none in hand (the accessory-installation
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather
/// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The
/// app's own call site passes the session's value explicitly.
func boardInfoTitlebarAccessory( func boardInfoTitlebarAccessory(
store: BoardStore, store: BoardStore,
recents: StyleRecents, recents: StyleRecents,
git: HistoryStore? = nil,
presentation: BoardInfoPresentation presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController { ) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView( let hosting = NSHostingView(
rootView: BoardInfoWidget( rootView: BoardInfoWidget(
store: store, store: store,
recents: recents, recents: recents,
git: git,
presentation: presentation presentation: presentation
) )
) )
@@ -314,31 +252,22 @@ func boardInfoTitlebarAccessory(
// MARK: - Tabs // MARK: - Tabs
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab /// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme** /// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) and **Sync**
/// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) and **Sync** (`BoardSyncTabView`), added /// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position
/// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says /// now, the surface says honestly that nothing lives there yet, and the future sync-service
/// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get /// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision
/// ruled. The raw values are the segmented control's own labels, so the strip needs no separate /// (strategy/01-git-excision.md, 2026-08-08) removed it. The raw values are the segmented control's
/// label function. /// own labels, so the strip needs no separate label function.
enum BoardInfoTab: String, CaseIterable, Identifiable { enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info" case info = "Info"
case theme = "Theme" case theme = "Theme"
case git = "Git"
case sync = "Sync" case sync = "Sync"
var id: Self { self } var id: Self { self }
// **Membership is the git posture's, and the posture never says "absent" any more.** The Git // Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) every board
// session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has // carries the whole strip, unconditionally.
// something true to say, which then meant dropping it on a free board with no `.git` the
// "absent, no placeholder" rule carried up to the strip. **12-editions.md PIVOT 2026-08-07**,
// the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
// `.proPointer` died with it, and 03-board-ui.md Board popover records the consequence "the
// absent posture is unreachable and every board carries all three tabs. The membership rule
// stands structurally the strip still asks the posture it just never hears 'absent'
// anymore." So there is no `available()` filter here to ask it with: membership is `allCases`,
// in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway.
} }
// MARK: - The popover's content // MARK: - The popover's content
@@ -348,18 +277,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
/// ///
/// Width is the style editor's the number that keeps the Style popover narrow enough to sit /// Width is the style editor's the number that keeps the Style popover narrow enough to sit
/// beside a card kept through the restructure so the popover's footprint didn't wander while the /// beside a card kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface /// tabs filled in; every settled tab (Info, Theme, Sync) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet. /// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View { struct BoardInfoView: View {
let store: BoardStore let store: BoardStore
let recents: StyleRecents let recents: StyleRecents
let git: HistoryStore?
/// The selected tab, and **it resets to Info on every open** a ruling, not an accident (the /// The selected tab, and **it resets to Info on every open** a ruling, not an accident: the
/// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover /// popover is transient and Info is the board's face, and a remembered tab could strand
/// is transient and Info is the board's face, and a remembered tab could strand selection on a /// selection on a tab the next board doesn't offer. `@State` on the popover's content, which
/// tab the next board's posture doesn't offer. `@State` on the popover's content, which
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more. /// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info @State private var tab: BoardInfoTab = .info
@@ -373,12 +300,10 @@ struct BoardInfoView: View {
init( init(
store: BoardStore, store: BoardStore,
recents: StyleRecents, recents: StyleRecents
git: HistoryStore? = nil
) { ) {
self.store = store self.store = store
self.recents = recents self.recents = recents
self.git = git
} }
var body: some View { var body: some View {
@@ -425,10 +350,8 @@ struct BoardInfoView: View {
// The tab bar: a segmented control rather than a `TabView`, because the popover is a // The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching // compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays // between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name. It iterates `allCases` the strip's membership is // the control's accessibility name. It iterates `allCases` every board carries the
// still the board's git posture in principle (the Git session's ruling), and since // whole strip, unconditionally (03-board-ui.md Board popover).
// 12-editions.md PIVOT 2026-08-07 that posture is never "absent", so every board
// carries the whole strip (03-board-ui.md Board popover, the same-day pivot note).
Picker("Board configuration", selection: $tab) { Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue) Text(tab.rawValue)
@@ -439,7 +362,7 @@ struct BoardInfoView: View {
.padding(.horizontal, inset) .padding(.horizontal, inset)
.padding(.top, inset) .padding(.top, inset)
// The selected tab's surface the first three settled 2026-08-07, each in its own // The selected tab's surface Info and Theme settled 2026-08-07, each in its own
// dedicated session and its own file; Sync is that day's standing placeholder. Each pads // dedicated session and its own file; Sync is that day's standing placeholder. Each pads
// itself by `inset`, so the switch adds nothing. // itself by `inset`, so the switch adds nothing.
switch tab { switch tab {
@@ -447,8 +370,6 @@ struct BoardInfoView: View {
BoardInfoTabView(store: store, inset: inset) BoardInfoTabView(store: store, inset: inset)
case .theme: case .theme:
BoardThemeTabView(store: store, inset: inset) BoardThemeTabView(store: store, inset: inset)
case .git:
BoardGitTabView(store: store, git: git, inset: inset)
case .sync: case .sync:
BoardSyncTabView(inset: inset) BoardSyncTabView(inset: inset)
} }
+10 -10
View File
@@ -4,17 +4,17 @@ import SwiftUI
/// added 2026-08-07, the same night the settings sheet retired). /// added 2026-08-07, the same night the settings sheet retired).
/// ///
/// The tab exists ahead of its contents, deliberately: the strip claims the position where the /// The tab exists ahead of its contents, deliberately: the strip claims the position where the
/// remote half of the git story will live tracking, Pull/Push, the status badges, and whatever /// future **ops-based sync service** will live semantic ops, a server-side worktree API, with the
/// home the setup surfaces (remote, credentials, SSH) are ruled into so that when /// phone and (eventually) the web as worktree clients speaking that protocol
/// 07-sync-collab.md's cards land they land *in* a surface rather than re-arguing the strip. None /// (strategy/01-git-excision.md Successors) so that when the sync-service workstream's cards
/// of that is ruled by this file: the open question is the Redesign board's /// land they land *in* a surface rather than re-arguing the strip. **Git is gone**
/// ("Rule a home for 07's remote and credential setup surfaces"), and a placeholder that decided it /// (strategy/01-git-excision.md, 2026-08-08): this tab was never the remote half of the git story
/// by accident would be the worst way to answer it. /// and now carries no trace of one. None of the service's shape is ruled by this file: that is its
/// own workstream's, with its own docs, and a placeholder that decided it by accident would be the
/// worst way to answer it.
/// ///
/// What renders meanwhile is one honest sentence in the posture notes' register /// What renders meanwhile is one honest sentence: the fact, stated plainly never a mock control,
/// (`BoardGitTabView`'s siblings): the fact, stated plainly never a mock control, never a /// never a greyed-out preview of features that do not exist (teach, never look broken).
/// greyed-out preview of features that do not exist (06-history-undo.md Rules: teach, never look
/// broken).
struct BoardSyncTabView: View { struct BoardSyncTabView: View {
/// The popover's own padding figure (`BoardInfoView.inset`), matching every other tab's /// The popover's own padding figure (`BoardInfoView.inset`), matching every other tab's
-191
View File
@@ -1,191 +0,0 @@
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)")
}
}
+11 -38
View File
@@ -28,9 +28,7 @@ import UniformTypeIdentifiers
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that /// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
/// reads or writes beyond that is later work and is marked where it lands: /// reads or writes beyond that is later work and is marked where it lands:
/// ///
/// - the title as an editable field (commit on Return / focus loss, Escape abandons), /// - the title as an editable field (commit on Return / focus loss, Escape abandons).
/// - the sidebar's History section, whose place in the stack is reserved and whose content waits on
/// a git mode to be honest about (`historySlot`).
/// ///
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are /// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
/// settled (05 The attributes sidebar), so the shell states them and the sections fill in /// settled (05 The attributes sidebar), so the shell states them and the sections fill in
@@ -96,10 +94,6 @@ struct CardWindowView: View {
/// issued in this window registers on this window's stack. It lives on the window's session so /// issued in this window registers on this window's stack. It lives on the window's session so
/// the close can fold it, which is why it arrives here rather than being made here. /// the close can fold it, which is why it arrives here rather than being made here.
let undo: CardWindowUndo let undo: CardWindowUndo
/// **This card's commit trail** (05 History), or `nil` on every board with no app-managed git
/// mode none, repo-nested, unverifiable. The `nil` *is* the section's absence rule; see
/// `historySlot`. A per-board question on every tier since 12-editions.md PIVOT 2026-08-07.
let history: CardHistory?
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole /// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
/// window**"). `nil` only where a caller has no store to import through. /// window**"). `nil` only where a caller has no store to import through.
let fileDrop: CardWindowDropDelegate? let fileDrop: CardWindowDropDelegate?
@@ -284,8 +278,8 @@ struct CardWindowView: View {
/// (05 Composition) the whole line disappears when the card carries none of the three. /// (05 Composition) the whole line disappears when the card carries none of the three.
/// ///
/// The "by" segment renders only with the self-reported provenance stamp present /// The "by" segment renders only with the self-reported provenance stamp present
/// (01-storage-format.md), which is the point of showing it at all: provenance made visible where /// (01-storage-format.md), which is the point of showing it at all: provenance made visible with
/// git history may not exist. /// no commit trail to read it from.
private var dateLine: String? { private var dateLine: String? {
var parts: [String] = [] var parts: [String] = []
if let created = card.created.value { if let created = card.created.value {
@@ -306,14 +300,16 @@ struct CardWindowView: View {
// MARK: - Attributes sidebar // MARK: - Attributes sidebar
/// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, History, /// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, Actions.
/// Actions.
/// ///
/// Two of the five are conditional, and both conditions are the section's own rather than a rule /// One of the four is conditional, and the condition is the section's own rather than a rule
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys /// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
/// ("shown only when any exist"), and **History** is absent on boards without app-managed git. /// ("shown only when any exist"). Everything else in the stack is unconditional, so the
/// Everything else in the stack is unconditional, so the composition a user learns on one card is /// composition a user learns on one card is the composition they get on the next.
/// the composition they get on the next. ///
/// The History section that once sat between Details and Actions left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); View History (`FutureCommands.swift`) is the only
/// surviving reservation of that slot, and it anticipates the foreign-change journal successor.
private var sidebar: some View { private var sidebar: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
@@ -325,35 +321,12 @@ struct CardWindowView: View {
// keys and their order included, and `Card` has carried it since (`BoardModel`). // keys and their order included, and `Card` has carried it since (`BoardModel`).
CardDetailsSection(rows: CardDetails.rows(of: card.document)) CardDetailsSection(rows: CardDetails.rows(of: card.document))
historySlot
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder) CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
} }
} }
/// **The History section** between Details and Actions, 05's order (05 History: "the card's
/// commit trail, read-only newest first semantic subject, relative date, author").
///
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
/// app-managed git (mode none, repo-nested) same honesty rule as the popover's git section".
/// The host builds a `CardHistory` only where the board's history is git-backed (mode `git`), so
/// there is no placeholder here to decide about: what the slot reserves is the **position**, and
/// on every other board that position is empty.
///
/// **The tier is not one of the inputs** (12-editions.md PIVOT 2026-08-07 git left the
/// paywall, retiring 12's tier matrix row that made this a Pro surface): a git board shows its
/// trail on any tier, a gitless one shows nothing on any tier.
///
// A later card: View History, which focuses this section (11-command-nexus.md).
@ViewBuilder
private var historySlot: some View {
if let history {
CardHistorySection(history: history)
}
}
} }
// MARK: - The window-wide drop // MARK: - The window-wide drop
-155
View File
@@ -1,155 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **What the popover's Git tab sets up, board by board** (03-board-ui.md Board popover Git tab;
/// 06-history-undo.md Rules) `BoardGitSetupSection.resolve`, the tab's setup inventory, pinned
/// for `BoardGitSection.resolve`'s reason one seam over: the *contents* are the decision worth
/// asserting and the SwiftUI that renders them is not. Every case below is a plain value no board
/// on disk, no window, no session.
///
/// ### This suite is the settings sheet's, inherited
///
/// It was `BoardSettingsSectionTests` and `BoardSettingsAvailabilityTests` against the sheet's own
/// inventory (2026-07-31 2026-08-07). The **2026-08-07 reversal** retired that sheet and rehomed
/// its controls into the tab, so the *subject* moved and most of the rules did not: an unreadable
/// repository still hosts no setup, repo-nested and unverifiable still host none, and mode `none`
/// still means add-git and nothing else.
///
/// Two things did change, and both are asserted below rather than described:
///
/// - **Branch creation is not in this inventory.** It went back into the switch menu as New Branch
/// with an inline reveal (`BoardGitControls`), which is a daily control rather than a standing
/// form so the git-mode posture's setup half is the commit identity alone.
/// - **Emptiness no longer means "no surface".** The sheet's `BoardSettingsAvailability` existed to
/// answer "does this surface exist at all", because a sheet with no sections had no honest empty
/// state and two doors had to validate before opening it. The tab always exists (every board
/// carries all three tabs since 12-editions.md PIVOT 2026-08-07) and always has a posture to
/// render, so an empty inventory now means only "this posture hosts no setup block" there is no
/// door left to disable, and the availability seam retired with the doors.
@Suite("Board popover ▸ Git tab ▸ the setup inventory")
struct BoardGitSetupSectionTests {
@Test("Mode none: add-git and nothing else")
func modeNoneHoldsAddGit() {
// "add-git (mode none; opt-in init 06)", rendered under the no-repository note since the
// reversal put it back where the pre-split popover had it. Every tier's since
// 12-editions.md PIVOT 2026-08-07, and still an offer rather than an auto-init.
#expect(BoardGitSetupSection.resolve(mode: .none) == [.addGit])
}
@Test("Git mode: the commit identity — creation is the switch menu's again")
func gitModeHoldsTheIdentity() {
// "commit identity name/email (06 the visibility-scoped 2 s config re-read rides with the
// fields)". Branch creation was this posture's other section while the sheet existed; the
// reversal moved it back into the menu, so it is deliberately absent here.
#expect(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity])
}
@Test("Every section is reachable from some posture, and no posture invents one")
func theInventoryIsTotal() {
let offered = Set(BoardGitMode.allCases.flatMap { BoardGitSetupSection.resolve(mode: $0) })
#expect(offered == Set(BoardGitSetupSection.allCases))
}
@Test("Repo-nested: nothing setup-shaped applies — the posture is its explanation")
func repoNestedHoldsNothing() {
// "not a hidden 'add git' but a short explanation the option is absent because it *can't*
// apply" (06 Rules). The posture still renders its note is the whole of it but there
// is no setup block under it.
#expect(BoardGitSetupSection.resolve(mode: .repoNested) == [])
}
@Test("Unverifiable: the same emptiness, for the denial-not-absence reason")
func unverifiableHoldsNothing() {
// "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check
// can never be told apart from a repository actually being there, so add-git stays as
// unreachable here as it is on a genuinely nested board.
#expect(BoardGitSetupSection.resolve(mode: .unverifiable) == [])
}
@Test("Git mode with an unreadable repository: no setup block, but still the branch posture")
func anUnreadableRepositoryHoldsNoSetup() {
// **The corrupt-`.git` loud failure** (06 Rules, ruled 2026-07-31): writing an identity is
// a *write* into the repository's own config, and there is no repository the app can open to
// write it into.
#expect(BoardGitSetupSection.resolve(mode: .git, isRepositoryUnreadable: true) == [])
// and the mode is still `git` throughout: this is emptiness *within* git mode, never the
// fall to mode none that would let add-git be offered against an existing `.git`.
#expect(BoardGitSetupSection.resolve(mode: .git) == [.commitIdentity])
// The difference the tab can express and the sheet could not: the *posture* is unchanged, so
// the board still gets its branch surface, holding and explaining itself
// (`BoardGitBranchSurface.unreadableNote`). Only the setup block goes.
#expect(BoardGitSection.resolve(mode: .git) == .branch)
}
@Test("Only the identity block carries a header")
func headersAreNamed() {
// 10-accessibility.md's navigable-header rule, carried over from the retired sheet's
// sections: a heading the VoiceOver rotor jumps to. Add-git has none it renders directly
// under the note that states the posture, and a "Git" header inside the Git tab would be the
// surface naming itself twice.
#expect(BoardGitSetupSection.commitIdentity.title == "Commit Identity")
#expect(BoardGitSetupSection.addGit.title == nil)
}
}
/// The same inventory asked about **a board on disk** the two ends of the range a real
/// `HistoryStore` puts into it, so the seam's inputs are shown to be the ones a composed session
/// actually produces rather than values a test invented.
///
/// `@MainActor` because composing a `HistoryStore` is (the inventory itself is `nonisolated`, which
/// is exactly what the suite above exercises with no actor in sight).
@MainActor
@Suite("Board popover ▸ Git tab ▸ the setup inventory, against a composed session")
struct BoardGitSetupCompositionTests {
@Test("A board carrying an unopenable `.git` is a git board with no setup")
func anInertGitIsAnUnreadableGit() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
// The retired posture, asserted where it used to bite: a `.git` at a board root was **inert**
// off Pro never read, never written, the popover's one-line Pro pointer that board's whole
// story. Detection runs at every board open on every tier now (12-editions.md PIVOT
// 2026-08-07), so this composes in git mode with no tier asked for.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
// And what these three bytes actually are is a repository libgit2 will not open, so the board
// takes **06's corrupt-`.git` posture, not the retired inert one** (06 Rules, ruled
// 2026-07-31): git mode throughout never a fall to mode none that would offer add-git
// against an existing `.git` with no setup block, because every control there would write
// into something the app cannot open.
#expect(git.isRepositoryUnreadable)
#expect(
BoardGitSetupSection.resolve(
mode: git.mode,
isRepositoryUnreadable: git.isRepositoryUnreadable
) == []
)
}
@Test("A fresh mode-none board resolves to the add-git offer")
func aFreshBoardOffersAddGit() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
// Composed with no tier in sight: git is tier-independent since 12-editions.md PIVOT
// 2026-08-07, so this is every session's git state, not Pro's.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none)
#expect(
BoardGitSetupSection.resolve(
mode: git.mode,
isRepositoryUnreadable: git.isRepositoryUnreadable
) == [.addGit]
)
}
}
-170
View File
@@ -1,170 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **The popover's git slot, posture by posture** (03-board-ui.md Board popover Git tab;
/// 06-history-undo.md Rules).
///
/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the board's
/// **mode** precisely so the matrix is assertable the views it selects are SwiftUI and stay
/// untested.
///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07** (03's Git-tab note records the
/// consequence): git left the paywall, so the two free-tier postures `.absent` on an ordinary
/// board and the `.proPointer` on one carrying an inert `.git`, both settled 2026-07-27 described
/// a gate that no longer exists and retired with it. With them went the `.git`-on-disk probe that
/// fed them (`BoardGitNote.hasGitDirectory(at:)`) and the tab-strip membership filter that read
/// `.absent` (`BoardInfoTab.available`, whose suite retired here the same day): detection now runs at
/// every board open on every tier, and the mode is the one input left.
@Suite("Board popover ▸ the git section's posture")
struct BoardGitSectionTests {
@Test("Mode none has no repository to describe, git mode shows the branch")
func thePostureFollowsTheMode() {
#expect(BoardGitSection.resolve(mode: .none) == .noRepository)
#expect(BoardGitSection.resolve(mode: .git) == .branch)
}
@Test("A repo-nested board explains itself — setup is absent, not disabled")
func repoNestedExplainsRatherThanDisables() {
let section = BoardGitSection.resolve(mode: .repoNested)
// The design is insistent here: "not a hidden 'add git' but a short explanation the option
// is absent because it *can't* apply, and the UI should teach that rather than look broken"
// (06 Rules). A posture that rendered a disabled setup control would satisfy neither half
// and since the 2026-07-31 split the same sentence is what keeps the settings sheet itself
// out of reach on such a board (`BoardSettingsAvailabilityTests`).
#expect(section == .repoNested)
#expect(section != .noRepository)
}
@Test("An unverifiable board gets its own posture — structurally like nested, never the same case")
func unverifiableIsItsOwnPostureNotRepoNested() {
// "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check
// is not a found repository, so the two must resolve to different cases even though both are
// action-less, prose-only sections.
let section = BoardGitSection.resolve(mode: .unverifiable)
#expect(section == .unverifiable)
#expect(section != .repoNested, "a denial is not a nesting")
#expect(section != .noRepository)
}
/// **A board whose repository will not open keeps the git section it has** (06 Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31) the *branch* section, held and explaining
/// itself, never the mode-none posture that would imply a board with no history to have.
///
/// The section case is deliberately blind to readability: what changes on such a board is what
/// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation
/// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The
/// posture matrix stays a function of the mode alone.
@Test("An unreadable repository is still the branch section — never the no-repository posture")
func anUnreadableRepositoryKeepsTheBranchSection() {
let section = BoardGitSection.resolve(mode: .git)
#expect(section == .branch)
#expect(section != .noRepository, "the board has a repository; it is unreadable, not absent")
}
@Test("Every posture is reachable, and none of them is two postures")
func theMatrixIsTotal() {
let resolved = Set(BoardGitMode.allCases.map { BoardGitSection.resolve(mode: $0) })
// One posture per mode, and every posture spoken for which is also what makes the tab
// strip's membership rule trivially true since the pivot: no mode resolves to nothing, so
// the Git tab is never dropped (03-board-ui.md Board popover, the pivot note).
#expect(resolved == Set(BoardGitSection.allCases))
#expect(resolved.count == BoardGitMode.allCases.count, "no two modes share a posture")
}
}
/// **The tab strip carries every tab, always** (03-board-ui.md Board popover, the tabbed-
/// popover paragraph as the 2026-08-07 pivot note leaves it): "the absent posture is unreachable and
/// every board carries all three tabs. The membership rule stands structurally the strip still asks
/// the posture it just never hears 'absent' anymore." (Three became four the same night: **Sync**
/// joined as a standing placeholder, membership still `allCases`.)
///
/// So there is no `available()` seam left to pin; what is worth keeping is the strip's *order*,
/// which the popover's `Picker` takes from `allCases` and which the Git session fixed: Info first
/// it is the board's face and the tab selection resets to it on every open then Theme, then Git,
/// then the Sync placeholder at the end of the line, where a surface with nothing in it belongs.
@Suite("Board popover ▸ the tab strip")
struct BoardInfoTabStripTests {
@Test("Info, Theme, Git, Sync — in that order, on every board")
func theStripIsTheWholeSet() {
#expect(BoardInfoTab.allCases == [.info, .theme, .git, .sync])
}
@Test("The raw values are the segmented control's own labels")
func labelsAreTheRawValues() {
// The strip needs no separate label function, which is the only reason this enum is
// `String`-backed at all.
#expect(BoardInfoTab.allCases.map(\.rawValue) == ["Info", "Theme", "Git", "Sync"])
}
}
/// **The window-title widget's two strings** (03-board-ui.md Board popover, the card that widened
/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure
/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its mode
/// input with. No disk I/O the title half of this seam takes a raw `URL`, not a `BoardStore`, so a
/// plain `/tmp/...` path is enough.
///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07.** The branch rule read `tier ==
/// .pro && mode == .git` until that day; git is tier-independent now, so a git-mode board shows its
/// branch and nothing else does.
@Suite("Board popover ▸ the widget's strings")
struct BoardInfoTitlebarSummaryTests {
private let root = URL(fileURLWithPath: "/tmp/My Board.board")
@Test("The title is the on-disk title, falling back to the folder name sans extension")
func titleFallsBackToTheFolderName() {
let named = BoardInfoTitlebarSummary(
snapshotTitle: "Sprint 12", rootURL: root, mode: .none, branch: nil
)
#expect(named.title == "Sprint 12")
let untitled = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, mode: .none, branch: nil
)
#expect(untitled.title == "My Board")
let emptyTitled = BoardInfoTitlebarSummary(
snapshotTitle: "", rootURL: root, mode: .none, branch: nil
)
#expect(
emptyTitled.title == "My Board",
"an empty title reads the same as no title at all — the window-title rule this seam restates"
)
}
@Test("The branch shows in git mode, once it has been read")
func branchShowsInGitMode() {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, mode: .git, branch: "main"
)
#expect(summary.branch == "main")
}
@Test("No other mode shows a branch, whatever the git state hands it")
func onlyGitModeShowsABranch() {
for mode in BoardGitMode.allCases where mode != .git {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, mode: mode, branch: "main"
)
#expect(
summary.branch == nil,
"mode \(mode): a board the app manages no repository for must never surface a stray branch value"
)
}
}
@Test("A git-mode board whose branch has not been read yet shows none, honestly")
func unreadBranchShowsNone() {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, mode: .git, branch: nil
)
#expect(summary.branch == nil)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -10,7 +10,7 @@ import XCTest
/// The last of those is **one surface shorter than the design's sentence** since 2026-08-07: the /// The last of those is **one surface shorter than the design's sentence** since 2026-08-07: the
/// board settings sheet retired that day (03-board-ui.md Board settings sheet, marked retired /// board settings sheet retired that day (03-board-ui.md Board settings sheet, marked retired
/// the 2026-07-31 popover/sheet split reversed), and everything it held now renders inside the board /// the 2026-07-31 popover/sheet split reversed), and everything it held now renders inside the board
/// popover's Git tab. So the popover's own audit is where those controls are looked at, and the /// popover itself. So the popover's own audit is where those controls are looked at, and the
/// every-surface claim is satisfied by there being one surface fewer rather than by a test skipping /// every-surface claim is satisfied by there being one surface fewer rather than by a test skipping
/// one. /// one.
/// ///
@@ -25,10 +25,12 @@ import XCTest
/// into Terminal (`UITestLaunch` ships in the app binary on purpose). The 2026-08-07 pivot /// into Terminal (`UITestLaunch` ships in the app binary on purpose). The 2026-08-07 pivot
/// (12-editions.md PIVOT 2026-08-07 git left the paywall) made it reachable and it gained a test /// (12-editions.md PIVOT 2026-08-07 git left the paywall) made it reachable and it gained a test
/// here; the **reversal later the same day retired the sheet outright**, and the test with it. What /// here; the **reversal later the same day retired the sheet outright**, and the test with it. What
/// remains is the precedent the no-bypass objection stands for whatever the next split gates and /// remains is the precedent the no-bypass objection stands for whatever the next split gates
/// one live consequence for this file: the surfaces that sheet held are now the board popover's Git /// and `testBoardInfoPopover` opens the popover onto its Info tab, which the audit covers.
/// tab, which `testBoardInfoPopover` opens onto its Info tab. Auditing the Git tab specifically wants ///
/// a click on the popover's segmented strip and is a card of its own, filed rather than faked here. /// The popover carried a fourth tab, Git, until the git excision (strategy/01-git-excision.md,
/// 2026-08-08) removed it; the popover is Info, Theme, Sync now, and there is no tab-specific audit
/// left to file.
/// ///
/// ### No waiving /// ### No waiving
/// ///
@@ -195,7 +197,7 @@ final class AccessibilityAuditTests: XCTestCase {
try app.performAccessibilityAudit() try app.performAccessibilityAudit()
} }
/// The board popover File Board Info (I): "labeled controls throughout", with the git slot's /// The board popover File Board Info (I): "labeled controls throughout", with every tab's
/// information readable as text and never by colour or shape alone (10 Board popover). /// information readable as text and never by colour or shape alone (10 Board popover).
@MainActor @MainActor
func testBoardInfoPopover() throws { func testBoardInfoPopover() throws {