Build the board popover — title widget, rename, styling, git slot

The window-title widget arrives as a leading titlebar accessory — a
quiet chevron on board windows only, installed and removed by the
window controller's own attach lifecycle — anchoring the one
board-level surface as a transient popover (the board window
deliberately grows no toolbar item for it). Inside: board rename
editing frontmatter title only (the folder is never renamed; an empty
commit removes the key and the window title falls back to the folder
name), the embedded shared style editor permanently targeting the
board, and the labeled Git section that this milestone only reserves
— a mode-none explanation and a disabled stub where m7's add-git,
branch, remote, and authentication controls land. Cmd-I (File >
Board Info) toggles it per window through a focused scene value,
kept apart from board-scoped transient state since a titlebar
popover belongs to one window, not to the board. Escape reverts a
dirty rename field and falls through to dismiss otherwise; a foreign
rename resyncs the field only while unfocused. 12 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:08:06 -04:00
parent c6298c2e41
commit aa6aaf2a11
8 changed files with 745 additions and 11 deletions
+315 -1
View File
@@ -1,9 +1,11 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// The two inline editors' **write** paths `BoardStore.commitRename` and
/// `BoardStore.commitPlaceholder` plus the lane drag's `moveLane`.
/// `BoardStore.commitPlaceholder` plus the lane drag's `moveLane` and the board popover's
/// `renameBoard`, which is the same rename vocabulary aimed at the root.
///
/// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the
/// **raw bytes** back rather than the app's own read path: the interesting claims are about the
@@ -656,3 +658,315 @@ struct NewLaneWriteTests {
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - The board rename
/// The board root's own `index.md` richer than `Item.board` because a rename has to leave all of
/// it alone: an unknown key with its inline comment, a `created` from before today, a foreign
/// `modified-by`, and the board description body.
private let richBoardIndex = """
---
schema: 1
title: Roadmap
project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
Board description — with *markdown*.
"""
/// A board with no `title` key at all: the folder-name fallback state a rename both starts from and
/// returns a board to (01-storage-format.md § Board naming).
private let untitledBoardIndex = """
---
schema: 1
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
---
Board description.
"""
/// Readable, uneditable, at board level: the whole-frontmatter flow mapping, which loads and renders
/// fine but has no line for the editor to key on.
private let uneditableBoardIndex = "---\n{schema: 1, title: Odd}\n---\nodd body\n"
/// A board root and one lane, so the fixture is a board the store will actually load.
@MainActor
private func makeBoardRoot(_ index: String) throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", index)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
return fixture
}
@MainActor
@Suite("BoardStore ▸ board rename")
struct BoardRenameWriteTests {
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
func writesTheTitleAndStamps() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("")
store.renameBoard("Q3 plan")
let after = try fixture.indexText("")
#expect(after.contains("title: Q3 plan"))
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
// The unknown key with its comment, the original `created`, and the description survive
// exactly, in order the round-trip contract, at board level like every other.
#expect(untouchedLines(after) == untouchedLines(before))
let model = try load(fixture)
#expect(model.title == .valid("Q3 plan"))
#expect(model.modifiedBy.isMissing)
let modified = try #require(model.modified.value)
#expect(abs(modified.timeIntervalSinceNow) < 60)
#expect(store.banners.oneShots.isEmpty)
}
@Test("The folder is never renamed — only the frontmatter moves")
func theFolderIsLeftAlone() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let name = fixture.root.lastPathComponent
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard("Something else entirely")
// "The folder is never renamed by the app; the Finder document name is Finder's to change"
// (03-board-ui.md § Board popover) the display name and the document name may diverge.
#expect(fixture.root.lastPathComponent == name)
#expect(fixture.exists(""))
#expect(store.rootURL == fixture.root)
}
@Test("An empty commit removes the title key and the board falls back to its folder name")
func emptyCommitRemovesTheKey() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("")
store.renameBoard("")
let after = try fixture.indexText("")
#expect(!after.contains("title:"))
#expect(!after.contains("title: \"\""), "the key leaves; it is never blanked")
#expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")
#expect(try load(fixture).title.isMissing)
// And the fallback is the *folder* name, never "Untitled" 01-storage-format.md's
// board-naming rule, which is also what the empty field's placeholder promises.
let reopened = try BoardStore(rootURL: fixture.root)
#expect(AppModel.displayName(of: reopened) == fixture.root.deletingPathExtension().lastPathComponent)
}
@Test("nil commits as empty — the field's absent-title state and its cleared state agree")
func nilRemovesTheKeyToo() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard(nil)
#expect(try !fixture.indexText("").contains("title:"))
#expect(try load(fixture).title.isMissing)
}
@Test("Whitespace commits as empty, and a padded title is trimmed rather than quoted")
func whitespaceIsEmpty() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.renameBoard(" ")
#expect(try load(fixture).title.isMissing)
let padded = try makeBoardRoot(richBoardIndex)
defer { padded.tearDown() }
let other = try BoardStore(rootURL: padded.root)
other.renameBoard(" Q3 plan ")
#expect(try load(padded).title == .valid("Q3 plan"))
}
@Test("An unchanged title writes nothing at all")
func unchangedTitleIsANoOp() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let titled = try fixture.indexData("")
// A popover opened and dismissed with Return must not stamp `modified` or (on a git board)
// mint a commit `commitRename`'s rule, for its reason.
store.renameBoard("Roadmap")
#expect(try fixture.indexData("") == titled)
// Same for an untitled board committed still untitled: the key must not materialize and
// then vanish, nor the file be rewritten to say nothing new.
let untitled = try makeBoardRoot(untitledBoardIndex)
defer { untitled.tearDown() }
let untouched = try untitled.indexData("")
let second = try BoardStore(rootURL: untitled.root)
second.renameBoard("")
#expect(try untitled.indexData("") == untouched)
second.renameBoard(nil)
#expect(try untitled.indexData("") == untouched)
#expect(try untitled.entryNames("").sorted() == [Ident.lane1, "index.md"], "no temp-file residue either")
}
@Test("A readable-but-uneditable board refuses the write, banners it, and keeps its bytes")
func uneditableBoardBanners() throws {
let fixture = try makeBoardRoot(uneditableBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData("")
store.renameBoard("Renamed")
#expect(try fixture.indexData("") == before)
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
// Enriched off the document the write refused, so the banner names the board by what it is
// still called rather than by the name that never landed.
#expect(posted.error.operation == .rename(title: "Odd"))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
}
@Test("A read-only board refuses the rename without a second banner")
func readOnlyBoardRefusesQuietly() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("")
store.renameBoard("Q3 plan")
#expect(try fixture.indexData("") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
}
// MARK: - The board popover's presentation
@MainActor
@Suite("Board popover ▸ presentation")
struct BoardInfoPresentationTests {
@Test("⌘I toggles: it opens a closed popover and closes an open one")
func toggles() {
let presentation = BoardInfoPresentation()
#expect(!presentation.isPresented, "a window opens with its popover closed")
presentation.toggle()
#expect(presentation.isPresented)
presentation.toggle()
#expect(!presentation.isPresented, "the shortcut is the keyboard's way back out")
}
@Test("Two board windows never fight over one flag")
func isPerWindow() {
let first = BoardInfoPresentation()
let second = BoardInfoPresentation()
first.toggle()
#expect(first.isPresented)
#expect(!second.isPresented)
}
}
// MARK: - The window-title widget
/// The AppKit half of the board popover: the titlebar accessory and the SwiftUI widget it hosts.
///
/// Deliberately **not** a test of what the widget looks like or of the popover's behaviour, neither
/// of which a unit test can see. It is a test that a window survives having one an accessory is
/// installed during window setup, where a mistake crashes at open rather than misdraws, and this is
/// the cheapest place to find that out. `HostedWindowController`'s two promises about it (once per
/// window, off again on detach) are checked at the same time, because both are the kind of thing a
/// later refactor breaks silently.
@MainActor
@Suite("Board popover ▸ the window-title widget")
struct BoardInfoAccessoryTests {
/// A defaults domain of this suite's own `StyleEditorView` reaches the recents list, and a
/// test that wrote into `UserDefaults.standard` would be editing the developer's quick-style row.
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
let name = "BoardInfoAccessoryTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a defaults suite")
return (StyleRecents(defaults: .standard), {})
}
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
}
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 500),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
}
@Test("It installs on a real window, refuses a second, and comes back off on detach")
func installsOnceAndRemoves() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (recents, teardown) = makeRecents()
defer { teardown() }
let window = makeWindow()
let controller = HostedWindowController()
controller.attach(to: window)
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
#expect(window.titlebarAccessoryViewControllers.count == 1)
// A host that configures itself twice must not give the titlebar two chevrons AppKit keeps
// accessories in an array and would happily hold both.
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
#expect(window.titlebarAccessoryViewControllers.count == 1)
controller.detach()
#expect(window.titlebarAccessoryViewControllers.isEmpty, "the window is SwiftUI's; what we hang on it comes off")
}
@Test("An accessory handed over before the window exists goes in when one arrives")
func installsAfterTheWindowAttaches() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (recents, teardown) = makeRecents()
defer { teardown() }
// The board window's real order: the load returns (and the accessory is built) before or
// after `viewDidMoveToWindow`, and neither ordering may drop it.
let controller = HostedWindowController()
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
let window = makeWindow()
controller.attach(to: window)
#expect(window.titlebarAccessoryViewControllers.count == 1)
controller.detach()
}
}