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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user