Cards open to be read — the mobile detail screen turns read-only, editing moves behind a transactional Save

CardDetailScreen renders title, inline-Markdown body, and a quiet dates
footer; all writing moves to the new CardEditScreen, a full-screen cover
with segmented Details/Body panes. Drafts commit in one perform bracket
on Save only — Cancel guards dirty drafts with a discard confirmation,
and a failed write keeps the sheet and its drafts and raises an alert
instead of dismissing. CardAttributesSection becomes a pure
binding-driven editor with no write path of its own. The UI test walk
crosses the new split, with body-pane and discard coverage, and a
deterministic replaceAllText helper retires the flaky ⌘A select-all.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 14:47:50 -04:00
parent 28abaac2d9
commit c4591ead2c
6 changed files with 580 additions and 164 deletions
+276
View File
@@ -0,0 +1,276 @@
import SwiftUI
/// The card editor title, body, and attributes, all drafted and committed together presented
/// full-screen from `CardDetailScreen`'s Edit button.
///
/// Holds board-relative IDs only, never a `Card` value, for the same reason every other screen in
/// this stack does: the card is re-read from `session.snapshot` on every body evaluation, so a
/// write landing from elsewhere while this sheet is open (a trash from another device, say) is
/// noticed rather than edited into thin air.
///
/// ### Transactional, not auto-saving
///
/// Every earlier screen in this app commits a field the moment it stops being edited `onSubmit`,
/// `onDisappear`, a scene-phase change. This one commits nothing until `Save`, deliberately: a
/// `fullScreenCover` has no interactive swipe-to-dismiss, so the only way out mid-edit is a button
/// this screen controls, which makes "ask before discarding" both possible and the more honest
/// default for a screen with five drafted fields open across two panes at once. There is no
/// scenePhase or onDisappear commit anywhere below backgrounding the app or losing focus leaves
/// the drafts exactly where they were, waiting for `Save` or `Cancel`.
///
/// `Cancel` on a clean sheet dismisses immediately; on a dirty one it raises a confirmation before
/// discarding. `Save` writes every changed field in one `session.perform` bracket title through
/// `.rename`, the three style keys through one `.style` call, body through `writeBody` writes
/// nothing at all when nothing changed, and dismisses only on success: a failed write keeps the
/// sheet and its drafts and raises an alert (see `save()`).
struct CardEditScreen: View {
let boardRoot: URL
let laneID: ItemID
let cardID: ItemID
@Environment(BoardIndexStore.self) private var index
@Environment(\.dismiss) private var dismiss
private enum Pane: String, CaseIterable, Hashable {
case details = "Details"
case body = "Body"
}
@State private var pane: Pane = .details
@State private var titleDraft = ""
@State private var bodyDraft = ""
@State private var iconDraft: String?
@State private var iconColorDraft: String?
@State private var backgroundDraft: String?
@State private var isConfirmingDiscard = false
@State private var saveError: BoardSessionError?
@FocusState private var isBodyFocused: Bool
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
private var card: Card? {
session.snapshot?.lanes.first { $0.id == laneID }?.cards.first { $0.id == cardID }
}
var body: some View {
NavigationStack {
content
.navigationTitle("Card")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { requestDismiss() }
}
if card != nil {
ToolbarItem(placement: .principal) {
Picker("Pane", selection: $pane) {
ForEach(Pane.allCases, id: \.self) { pane in
Text(pane.rawValue).tag(pane)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 220)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
}
}
}
.task { session.open() }
// Seeds the drafts once per card. `.task(id:)` re-runs only when `card?.id`
// changes nil while the session is still opening, then the card's own id once
// it lands so a reload that lands *while this sheet is open* never clobbers a
// draft the user is mid-typing. Unlike the old in-place editor, nothing this
// screen does triggers such a reload before `Save` but another device's write
// can still land, and the same guard covers that case too.
.task(id: card?.id) {
guard let card else { return }
titleDraft = card.title.value ?? ""
bodyDraft = card.body
iconDraft = card.icon.value
iconColorDraft = card.iconColor.value
backgroundDraft = card.background.value
}
.confirmationDialog(
"Discard Changes?",
isPresented: $isConfirmingDiscard,
titleVisibility: .visible
) {
Button("Discard", role: .destructive) { dismiss() }
Button("Cancel", role: .cancel) {}
}
.alert(
"Couldn't Save",
isPresented: Binding(get: { saveError != nil }, set: { if !$0 { saveError = nil } }),
presenting: saveError
) { _ in
Button("OK") { saveError = nil }
} message: { error in
Text(error.description)
}
}
}
@ViewBuilder
private var content: some View {
if let card {
switch pane {
case .details:
detailsForm(for: card)
case .body:
TextEditor(text: $bodyDraft)
.focused($isBodyFocused)
.padding(.horizontal, 12)
.padding(.top, 8)
}
} else if case .ready = session.phase {
// The card is gone from a `.ready` snapshot deleted elsewhere while this sheet was
// open. There is nothing left to edit; dismiss the cover rather than leave drafts for
// a card that no longer exists.
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
.task { dismiss() }
} else {
ProgressView("Opening card")
}
}
@ViewBuilder
private func detailsForm(for card: Card) -> some View {
Form {
if session.lastError != nil {
Section {
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Title") {
TextField("Untitled Card", text: $titleDraft)
}
CardAttributesSection(icon: $iconDraft, iconColor: $iconColorDraft, background: $backgroundDraft)
Section("Details") {
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "")
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "")
}
}
}
/// Any draft differing from the card's own current snapshot value title compared trimmed,
/// exactly as `Save` compares it, so this predicate and the write it gates never disagree.
private var isDirty: Bool {
guard let card else { return false }
let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedTitle != (card.title.value ?? "")
|| bodyDraft != card.body
|| iconDraft != card.icon.value
|| iconColorDraft != card.iconColor.value
|| backgroundDraft != card.background.value
}
private func requestDismiss() {
if isDirty {
isConfirmingDiscard = true
} else {
dismiss()
}
}
/// One `session.perform` bracket, three independent writes inside it gated on whatever
/// actually changed a clean `Save` (nothing dirty) makes no write at all and just dismisses.
/// Awaits the perform before dismissing: `perform` already awaits its own reload, so by the
/// time this returns `CardDetailScreen`'s snapshot underneath already reflects the write, and
/// the read screen shows the new values the instant the cover animates away.
///
/// Dismissal is gated on the perform's own `Result` the one caller of `perform` in this app
/// that can't ignore it. The auto-committing screens could fire and forget because their
/// drafts outlived the write attempt; here dismissing *is* draft destruction, so a failed
/// write keeps the sheet (and every draft) exactly where it was and raises an alert instead.
/// `perform` reloads even on failure, so a partially-applied multi-call write (title landed,
/// body didn't) re-seeds nothing the drafts keep the user's full intent and a retry rewrites
/// only what still differs.
private func save() {
guard let card else {
dismiss()
return
}
let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
let titleChanged = trimmedTitle != (card.title.value ?? "")
let newTitle: String? = trimmedTitle.isEmpty ? nil : trimmedTitle
let iconChanged = iconDraft != card.icon.value
let iconColorChanged = iconColorDraft != card.iconColor.value
let backgroundChanged = backgroundDraft != card.background.value
let styleChanged = iconChanged || iconColorChanged || backgroundChanged
let bodyChanged = bodyDraft != card.body
guard titleChanged || styleChanged || bodyChanged else {
dismiss()
return
}
let laneID = self.laneID
let cardID = self.cardID
let cardTitle = card.title.value
let newIcon = iconDraft
let newIconColor = iconColorDraft
let newBackground = backgroundDraft
let newBody = bodyDraft
Task {
// The closure's throws type must be spelled out a trailing closure literal does not
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
let outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
let folder = root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
if titleChanged {
// The Mac's own rename path, verbatim (`BoardStore.setTitle`): plain
// `set`/`remove` `setStyleValue` is the style gesture's helper, not the
// title's and `.rename(title: nil)` so `updateIndex` enriches the operation
// from the document it reads rather than trusting a snapshot that may have
// aged.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
if let newTitle {
document.set(FrontmatterKeys.title, to: .string(newTitle))
} else {
document.remove(FrontmatterKeys.title)
}
}
}
if styleChanged {
// One `.style` call for all three keys `operation: .style` is the
// vocabulary's own case for "`updateIndex` on behalf of styling flows"
// (`WriteOperation.style`), and each changed key writes through
// `setStyleValue` exactly as the old immediate-write attributes section did.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
if iconChanged { document.setStyleValue(newIcon, for: FrontmatterKeys.icon) }
if iconColorChanged { document.setStyleValue(newIconColor, for: FrontmatterKeys.iconColor) }
if backgroundChanged { document.setStyleValue(newBackground, for: FrontmatterKeys.background) }
}
}
if bodyChanged {
_ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
}
}
switch outcome {
case .success:
dismiss()
case let .failure(error):
saveError = error
}
}
}
private static func formatted(_ date: Date) -> String {
date.formatted(date: .abbreviated, time: .shortened)
}
}