The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
365 lines
15 KiB
Swift
365 lines
15 KiB
Swift
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.
|
|
struct BoardGitBranchSurface: Equatable {
|
|
|
|
/// What the branch line reads — the branch name, the short hash on a detached HEAD
|
|
/// (`GitRepository.branchName` decides which), or the placeholder while the first read is in
|
|
/// flight.
|
|
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 branch picker and the create action accept a click.
|
|
let controlsEnabled: Bool
|
|
|
|
/// The line the branch display is read as by VoiceOver.
|
|
var accessibilityLabel: String {
|
|
isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
|
|
}
|
|
|
|
static let placeholder = "…"
|
|
|
|
/// 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."
|
|
|
|
static func resolve(
|
|
branch: String?,
|
|
pause: GitRepositoryPause?,
|
|
isSwitching: Bool,
|
|
isWritable: Bool
|
|
) -> BoardGitBranchSurface {
|
|
BoardGitBranchSurface(
|
|
branchLabel: branch ?? placeholder,
|
|
isReadingBranch: branch == nil,
|
|
pauseExplanation: pause?.explanation,
|
|
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, ▸ Interaction with external writers).
|
|
///
|
|
/// Three surfaces, in the order the design lists them: the branch display with switching and
|
|
/// creation, the pause explanation when there is one, and the commit-identity fields.
|
|
///
|
|
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the
|
|
/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they
|
|
/// join as one more block between the branch controls and the identity fields — 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
|
|
|
|
@State private var isNaming = false
|
|
@State private var draftBranch = ""
|
|
|
|
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 isNaming {
|
|
newBranchField
|
|
}
|
|
|
|
if let explanation = surface.pauseExplanation {
|
|
pauseNote(explanation)
|
|
}
|
|
|
|
if let failure = git.switcher?.lastFailure {
|
|
caption(failure.message, tone: .red)
|
|
}
|
|
|
|
BoardGitIdentityFields(git: git, isEnabled: isEnabled)
|
|
}
|
|
// 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, branch switching and creation" one affordance rather than a label
|
|
/// with a button beside it.
|
|
private var branchRow: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
.imageScale(.small)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Menu {
|
|
ForEach(otherBranches, id: \.self) { name in
|
|
Button(name) {
|
|
Task { await git.switcher?.switchTo(name) }
|
|
}
|
|
}
|
|
if !otherBranches.isEmpty {
|
|
Divider()
|
|
}
|
|
Button("New Branch…") {
|
|
draftBranch = ""
|
|
isNaming = true
|
|
}
|
|
} label: {
|
|
Text(surface.branchLabel)
|
|
.font(.callout)
|
|
.foregroundStyle(surface.isReadingBranch ? .secondary : .primary)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
.disabled(!surface.controlsEnabled)
|
|
.accessibilityLabel(surface.accessibilityLabel)
|
|
.accessibilityHint("Switch branches or create a branch")
|
|
|
|
if git.switcher?.isSwitching == true {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.accessibilityLabel("Switching branches")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
private var otherBranches: [String] {
|
|
(git.switcher?.branches ?? []).filter { $0 != git.branch }
|
|
}
|
|
|
|
// MARK: Create and switch
|
|
|
|
/// Named inline rather than in a sheet: the popover is where the operation was asked for, and a
|
|
/// sheet over a transient popover would dismiss the surface it came from.
|
|
private var newBranchField: some View {
|
|
HStack(spacing: 6) {
|
|
TextField("New branch name", text: $draftBranch)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1)
|
|
.onSubmit { create() }
|
|
// Escape steps outward one layer (04-interactions.md ▸ Grammar): it abandons the
|
|
// naming rather than dismissing the popover under it.
|
|
.onKeyPress(.escape) {
|
|
isNaming = false
|
|
draftBranch = ""
|
|
return .handled
|
|
}
|
|
Button("Create", action: create)
|
|
.disabled(trimmedDraft.isEmpty)
|
|
}
|
|
.disabled(!surface.controlsEnabled)
|
|
}
|
|
|
|
private var trimmedDraft: String {
|
|
draftBranch.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func create() {
|
|
let name = trimmedDraft
|
|
guard !name.isEmpty else { return }
|
|
isNaming = false
|
|
draftBranch = ""
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Commit identity
|
|
|
|
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md ▸ Interaction
|
|
/// with external writers: "The board settings sheet's 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").
|
|
///
|
|
/// **Its home is the sheet, and the sheet is not built yet.** The 2026-07-31 popover/sheet split moved
|
|
/// every setup-shaped control — add-git, branch creation, these fields, remote and credentials — out
|
|
/// of the popover and into a board settings sheet (03-board-ui.md ▸ Board settings sheet), leaving the
|
|
/// popover the daily face. This view is the fields, hosted where they were; the sheet's card moves
|
|
/// them, along with the visibility-scoped poll below, which 06 says "rides with the fields".
|
|
///
|
|
/// ### 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 popover 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 popover
|
|
/// stays open. The poll lives and dies with this view.
|
|
private 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 the surface is 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 sheet cadence (a focused
|
|
/// field keeps its keystrokes; dismissing the sheet stops the poll)."
|
|
private static let pollInterval: Duration = .seconds(2)
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Divider()
|
|
|
|
Text("Commit Identity")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
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.
|
|
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: 44, 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) }
|
|
}
|
|
}
|