The phone reads the thread — comments arrive on the card's read view, posting and editing behind transactional sheets
The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
**August 2026**
|
||||
|
||||
Cards now show their comments, and you can add a new comment or press and hold an existing one to edit it.
|
||||
|
||||
Card text now renders full Markdown — headings, lists with checkboxes, quotes, tables, and code blocks — instead of plain paragraphs.
|
||||
|
||||
Tapping a card now shows a readable view of its title and text with formatting, instead of dropping straight into the editor.
|
||||
|
||||
@@ -55,6 +55,13 @@ final class BoardSession {
|
||||
/// Cleared by the next walk that succeeds.
|
||||
private(set) var lastError: BoardSessionError?
|
||||
|
||||
/// Bumped every time a walk lands a snapshot. Comment threads live *outside* the snapshot —
|
||||
/// the walk stays O(cards) and never opens comment content, the Mac's own arrangement — so a
|
||||
/// screen rendering a thread keys its re-read on this: it moves after the screen's own write
|
||||
/// (whose `perform` awaits the reload) and after a foreign change the metadata query relayed,
|
||||
/// which are exactly the two moments a thread on screen can have gone stale.
|
||||
private(set) var snapshotGeneration = 0
|
||||
|
||||
/// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure
|
||||
/// optimization — it cannot change what a walk answers, only how many files it opens — and it is
|
||||
/// what keeps the reload after every single write from re-parsing the whole board on a phone.
|
||||
@@ -189,6 +196,7 @@ final class BoardSession {
|
||||
parseMemo = result.memo
|
||||
lastError = nil
|
||||
phase = .ready
|
||||
snapshotGeneration += 1
|
||||
cancelRetry()
|
||||
|
||||
case let .failed(error):
|
||||
@@ -212,6 +220,66 @@ final class BoardSession {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The comment thread
|
||||
|
||||
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
|
||||
/// its own thread: window-scoped, outside the board snapshot, coordinated like every other
|
||||
/// read on the phone.
|
||||
///
|
||||
/// Total, like the read it wraps: `CommentThread.load` never refuses, and a coordinator
|
||||
/// refusal answers `.empty` after a log line rather than surfacing — a thread that momentarily
|
||||
/// will not read renders as "no comments" for one pass and re-reads on the next
|
||||
/// `snapshotGeneration` bump, not an error state the screen has to draw. The defects the read
|
||||
/// reports are dropped here: the phone has no heal engine to hand them to, and healing from
|
||||
/// two apps at once would be two writers racing over one defect.
|
||||
func loadCommentThread(laneID: ItemID, cardID: ItemID) async -> CommentThread {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentThread, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.load(
|
||||
inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved),
|
||||
path: "\(laneID.rawValue)/\(cardID.rawValue)"
|
||||
)
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(thread):
|
||||
return thread
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment thread read failed: \(failure.description, privacy: .public)")
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the card's single draft — what seeds the composer. The draft is a folder inside the
|
||||
/// card, so it syncs like everything else: a comment started on the Mac is offered here to
|
||||
/// finish, exactly as designed ("the card's single draft", 01-storage-format.md § Enhanced
|
||||
/// schema). `nil` is "nothing to restore" — no draft, an unreadable one, or a coordinator
|
||||
/// refusal, none of which the composer can do anything about beyond starting empty.
|
||||
func loadCommentDraft(laneID: ItemID, cardID: ItemID) async -> CommentDraft? {
|
||||
let root = rootURL
|
||||
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentDraft?, CoordinationFailure> in
|
||||
CoordinatedFileAccess.read(itemAt: root) { resolved in
|
||||
CommentThread.loadDraft(inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved))
|
||||
}
|
||||
}.value
|
||||
switch outcome {
|
||||
case let .success(draft):
|
||||
return draft
|
||||
case let .failure(failure):
|
||||
Self.logger.error("comment draft read failed: \(failure.description, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `<root>/<lane>/<card>` — the derivation every comment call anchors on, spelled once and
|
||||
/// `nonisolated` so the detached reads above can use it against the coordinator-resolved root.
|
||||
nonisolated static func cardFolder(laneID: ItemID, cardID: ItemID, inRoot root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: - Writing
|
||||
|
||||
/// Runs a closure of `BoardWriter` calls against this board and reloads.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The read screen's comment thread — the phone's rendering of `CommentThread`, oldest first,
|
||||
/// below the card's own content.
|
||||
///
|
||||
/// **The wording is the Mac's** (`CommentsHeader` / `CommentAuthorLine` in `CardCommentsLayout` —
|
||||
/// pure, but homed in `Kanban/UI`, which the phone deliberately does not compile, so the two lines
|
||||
/// are restated rather than imported): the header is "Comments · N" with the zero rendered as a
|
||||
/// count rather than an absence, and the author line is "author · timestamp · edited" with each
|
||||
/// absent segment dropped and the whole line dropped when there is neither a name nor a date to
|
||||
/// hang "edited" from. Absent means absent — no "Unattributed" placeholder, no avatar: the file
|
||||
/// deliberately carries no identity system, and the render must not invent one.
|
||||
///
|
||||
/// Read-only rows plus two doors: **Add Comment** under the thread opens the composer, and each
|
||||
/// row's context menu carries **Edit** — both sheets, both transactional, both the containing
|
||||
/// screen's to present. A posted comment's attachments render as read-only paperclip rows, the
|
||||
/// Mac's own rule for chips outside an authoring surface.
|
||||
struct CardCommentsSection: View {
|
||||
/// `nil` while the first read is in flight — the header and the Add button render, rows wait.
|
||||
let thread: CommentThread?
|
||||
let onAdd: () -> Void
|
||||
let onEdit: (Comment) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Divider()
|
||||
|
||||
Text("Comments · \(thread?.comments.count ?? 0)")
|
||||
.font(.subheadline.smallCaps().weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let thread {
|
||||
ForEach(thread.comments) { comment in
|
||||
CommentRow(comment: comment)
|
||||
.contextMenu {
|
||||
Button {
|
||||
onEdit(comment)
|
||||
} label: {
|
||||
Label("Edit Comment", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onAdd) {
|
||||
Label("Add Comment", systemImage: "plus.bubble")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One posted comment: author line, body through the same block renderer the card's own body
|
||||
/// uses (a comment's Markdown is the card-body subset, so the renderer is shared by construction),
|
||||
/// and the attachment listing.
|
||||
private struct CommentRow: View {
|
||||
let comment: Comment
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let line = authorLine {
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Only a non-empty body reaches the renderer: `CardBodyView`'s empty case says
|
||||
// "No description", which is a card's word, not a comment's — an attachment-only
|
||||
// comment renders its chips alone.
|
||||
if !comment.body.isEmpty {
|
||||
CardBodyView(body: comment.body)
|
||||
}
|
||||
|
||||
ForEach(comment.attachments, id: \.self) { name in
|
||||
Label(name, systemImage: "paperclip")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
/// `CommentAuthorLine.text(author:timestamp:isEdited:)`, restated. An empty `author` string is
|
||||
/// treated as absent for the Mac's reason: the Writer never writes `author: ""` (it omits the
|
||||
/// key), so one on disk is a hand edit, and a blank name beside a separator would be noise.
|
||||
private var authorLine: String? {
|
||||
var parts: [String] = []
|
||||
if let author = comment.author.value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!author.isEmpty {
|
||||
parts.append(author)
|
||||
}
|
||||
if let created = comment.created.value {
|
||||
parts.append(created.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
guard !parts.isEmpty else { return nil }
|
||||
if comment.isEdited {
|
||||
parts.append("edited")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,13 @@ import SwiftUI
|
||||
/// the whole point of the transactional model: a screen that only ever renders `session.snapshot`
|
||||
/// cannot itself go stale relative to disk, and there is no auto-save timing to reason about
|
||||
/// because there is no save at all on this side of the Edit button.
|
||||
///
|
||||
/// **Comments are the one thing here the snapshot does not carry.** The thread lives outside the
|
||||
/// board walk (the Mac's arrangement, kept), so this screen reads it itself — re-read whenever
|
||||
/// `session.snapshotGeneration` moves, which covers both a comment sheet's own write (whose
|
||||
/// `perform` awaits the reload) and a foreign change landing. The read-only posture survives
|
||||
/// intact: the thread renders read-only, and the two comment writes live behind their own
|
||||
/// transactional sheets (`CommentEditorScreen`), exactly as card edits live behind Edit.
|
||||
struct CardDetailScreen: View {
|
||||
let boardRoot: URL
|
||||
let laneID: ItemID
|
||||
@@ -21,6 +28,9 @@ struct CardDetailScreen: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isPresentingEdit = false
|
||||
@State private var thread: CommentThread?
|
||||
@State private var isComposingComment = false
|
||||
@State private var editingComment: Comment?
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
@@ -40,9 +50,21 @@ struct CardDetailScreen: View {
|
||||
}
|
||||
}
|
||||
.task { session.open() }
|
||||
// The thread's read, keyed on the walk counter: runs at appearance (generation 0 or
|
||||
// whatever the session has reached), then again every time a walk lands — which is
|
||||
// every landing point a thread on this screen can have gone stale at.
|
||||
.task(id: session.snapshotGeneration) {
|
||||
thread = await session.loadCommentThread(laneID: laneID, cardID: cardID)
|
||||
}
|
||||
.fullScreenCover(isPresented: $isPresentingEdit) {
|
||||
CardEditScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
|
||||
}
|
||||
.sheet(isPresented: $isComposingComment) {
|
||||
CommentEditorScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID, mode: .compose)
|
||||
}
|
||||
.sheet(item: $editingComment) { comment in
|
||||
CommentEditorScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID, mode: .edit(comment))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -59,6 +81,11 @@ struct CardDetailScreen: View {
|
||||
titleBlock(for: card)
|
||||
bodyBlock(for: card)
|
||||
detailsFooter(for: card)
|
||||
CardCommentsSection(
|
||||
thread: thread,
|
||||
onAdd: { isComposingComment = true },
|
||||
onEdit: { editingComment = $0 }
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The comment composer and the inline comment edit, one transactional sheet — presented from
|
||||
/// `CardDetailScreen`'s comments section, `CardEditScreen`'s model in miniature: drafts commit on
|
||||
/// the trailing button or not at all, Cancel guards a dirty draft with a discard confirmation, a
|
||||
/// failed write keeps the sheet and its draft and raises an alert, and dismissal happens only on
|
||||
/// the perform's own `.success`. No scenePhase or onDisappear commit anywhere — backgrounding the
|
||||
/// app leaves the draft waiting.
|
||||
///
|
||||
/// ### Compose posts through the card's single draft
|
||||
///
|
||||
/// `Post` is `saveCommentDraft` then `postComment` in **one `session.perform` bracket** — the
|
||||
/// Mac composer's own two primitives, back to back. That makes the on-disk draft load-bearing
|
||||
/// rather than optional: the composer seeds from it when one exists (it syncs — a comment started
|
||||
/// on the Mac is offered here to finish, and its attachments ride into the post untouched),
|
||||
/// because a post that ignored it would silently clobber that other device's words with these.
|
||||
/// Cancel touches nothing on disk: the phone's transactional model, so the typed text is the
|
||||
/// sheet's and the draft folder stays exactly as the last device left it.
|
||||
///
|
||||
/// ### Edit seeds from the row that opened it
|
||||
///
|
||||
/// The one screen in this stack that carries a value rather than IDs alone — deliberately: a
|
||||
/// comment lives outside the board snapshot, so there is nothing to re-read from on each body
|
||||
/// evaluation, and the Mac's inline edit session seeds from open-time bytes the same way. The
|
||||
/// save targets the comment's *id* on disk regardless, so a body that changed elsewhere mid-edit
|
||||
/// is overwritten by an explicit Save — the same last-writer-wins any two editors of one file
|
||||
/// have — and a comment deleted elsewhere refuses the write and alerts rather than recreating it.
|
||||
struct CommentEditorScreen: View {
|
||||
enum Mode {
|
||||
/// A new comment through the card's single draft.
|
||||
case compose
|
||||
/// One posted comment's body.
|
||||
case edit(Comment)
|
||||
}
|
||||
|
||||
let boardRoot: URL
|
||||
let laneID: ItemID
|
||||
let cardID: ItemID
|
||||
let mode: Mode
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var bodyDraft = ""
|
||||
@State private var seededBody = ""
|
||||
@State private var draftAttachments: [String] = []
|
||||
@State private var isConfirmingDiscard = false
|
||||
@State private var isWriting = false
|
||||
@State private var writeError: BoardSessionError?
|
||||
@FocusState private var isBodyFocused: Bool
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
private var isEditing: Bool {
|
||||
if case .edit = mode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var isDirty: Bool { bodyDraft != seededBody }
|
||||
|
||||
/// Post validation, the emptied-draft rule read forwards: there is something to post exactly
|
||||
/// when there would be something to keep — text, or the draft's synced attachments. Edit has
|
||||
/// no such gate; an unchanged Save writes nothing (the writer's identical-bytes rule) and a
|
||||
/// deliberately emptied body is the user's to write.
|
||||
private var canCommit: Bool {
|
||||
switch mode {
|
||||
case .compose:
|
||||
!bodyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !draftAttachments.isEmpty
|
||||
case .edit:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TextEditor(text: $bodyDraft)
|
||||
.focused($isBodyFocused)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.navigationTitle(isEditing ? "Edit Comment" : "New Comment")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { requestDismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isEditing ? "Save" : "Post") { commit() }
|
||||
.disabled(!canCommit || isWriting)
|
||||
}
|
||||
}
|
||||
.task { await seed() }
|
||||
.confirmationDialog(
|
||||
"Discard Comment?",
|
||||
isPresented: $isConfirmingDiscard,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Discard", role: .destructive) { dismiss() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.alert(
|
||||
isEditing ? "Couldn't Save" : "Couldn't Post",
|
||||
isPresented: Binding(get: { writeError != nil }, set: { if !$0 { writeError = nil } }),
|
||||
presenting: writeError
|
||||
) { _ in
|
||||
Button("OK") { writeError = nil }
|
||||
} message: { error in
|
||||
Text(error.description)
|
||||
}
|
||||
}
|
||||
// A sheet — unlike the edit screen's fullScreenCover — has an interactive swipe-dismiss,
|
||||
// which would be a silent Cancel past the dirty guard. Disabling it while dirty routes
|
||||
// every exit through the two buttons; a clean sheet still swipes away freely.
|
||||
.interactiveDismissDisabled(isDirty)
|
||||
}
|
||||
|
||||
/// Seeds once, at presentation. Edit seeds synchronously from the row's value; compose reads
|
||||
/// the card's single draft off the main actor and applies it only if the user has not already
|
||||
/// started typing into the empty editor — the seed must never eat keystrokes.
|
||||
private func seed() async {
|
||||
switch mode {
|
||||
case let .edit(comment):
|
||||
bodyDraft = comment.body
|
||||
seededBody = comment.body
|
||||
case .compose:
|
||||
guard let draft = await session.loadCommentDraft(laneID: laneID, cardID: cardID) else { break }
|
||||
draftAttachments = draft.attachments
|
||||
if bodyDraft.isEmpty {
|
||||
bodyDraft = draft.body
|
||||
seededBody = draft.body
|
||||
}
|
||||
}
|
||||
isBodyFocused = true
|
||||
}
|
||||
|
||||
private func requestDismiss() {
|
||||
if isDirty {
|
||||
isConfirmingDiscard = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// One `session.perform` bracket either way; `perform` awaits its own reload, which bumps
|
||||
/// `snapshotGeneration`, which is what makes the thread under the dismissing sheet already
|
||||
/// show the new comment the instant the animation ends.
|
||||
private func commit() {
|
||||
let body = bodyDraft
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let cardTitle = session.snapshot?.lanes
|
||||
.first { $0.id == laneID }?.cards
|
||||
.first { $0.id == cardID }?.title.value
|
||||
|
||||
isWriting = true
|
||||
Task {
|
||||
let outcome: Result<Void, BoardSessionError>
|
||||
switch mode {
|
||||
case .compose:
|
||||
outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root)
|
||||
try BoardWriter.saveCommentDraft(inCard: folder, body: body, cardTitle: cardTitle)
|
||||
_ = try BoardWriter.postComment(inCard: folder, cardTitle: cardTitle)
|
||||
}
|
||||
case let .edit(comment):
|
||||
let commentID = comment.id
|
||||
outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = BoardSession.cardFolder(laneID: laneID, cardID: cardID, inRoot: root)
|
||||
try BoardWriter.editComment(
|
||||
at: CommentThread.commentFolder(commentID, inCard: folder),
|
||||
body: body,
|
||||
cardTitle: cardTitle
|
||||
)
|
||||
}
|
||||
}
|
||||
isWriting = false
|
||||
switch outcome {
|
||||
case .success:
|
||||
dismiss()
|
||||
case let .failure(error):
|
||||
writeError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,32 +120,8 @@ final class BoardsNavigationUITests: XCTestCase {
|
||||
|
||||
private extension XCUIApplication {
|
||||
|
||||
/// Boards → the fixture's first lane → its first card, landing on `CardDetailScreen`'s read
|
||||
/// view. Shared by every test in this file so the navigation-and-assert boilerplate is written
|
||||
/// once.
|
||||
@MainActor
|
||||
func navigateToFirstCard() {
|
||||
let boardRow = element(labelContaining: RichBoard.title)
|
||||
XCTAssertTrue(
|
||||
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
|
||||
)
|
||||
boardRow.tap()
|
||||
|
||||
let laneRow = element(labelContaining: RichBoard.firstLane)
|
||||
XCTAssertTrue(
|
||||
laneRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the \"\(RichBoard.firstLane)\" lane row never appeared"
|
||||
)
|
||||
laneRow.tap()
|
||||
|
||||
let cardRow = element(labelContaining: RichBoard.firstLaneFirstCard)
|
||||
XCTAssertTrue(
|
||||
cardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
|
||||
)
|
||||
cardRow.tap()
|
||||
}
|
||||
// `navigateToFirstCard()` moved to `MobileUITestSupport.swift` — the comments suite starts on
|
||||
// the same card.
|
||||
|
||||
/// Taps `CardDetailScreen`'s Edit button and waits for `CardEditScreen`'s cover to land.
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
|
||||
/// The read screen's comment thread, end to end: the fixture card's own posted comment renders
|
||||
/// read-only, the composer posts a new comment through the draft-then-rename bracket, and the
|
||||
/// context menu's Edit rewrites a posted body — each write asserted on disk, not just on screen.
|
||||
final class CardCommentsUITests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testCommentsReadAddAndEdit() throws {
|
||||
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
|
||||
app.navigateToFirstCard()
|
||||
|
||||
// Read mode first: the fixture card carries exactly one posted comment (author "rzen",
|
||||
// dated, never edited), so the header's count and the author line are both assertable
|
||||
// before this test writes anything.
|
||||
let header = app.element(labelContaining: "Comments · 1")
|
||||
XCTAssertTrue(
|
||||
header.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the comments header never appeared with the fixture's own count"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
app.element(labelContaining: "rzen").exists,
|
||||
"the fixture comment's author line never rendered"
|
||||
)
|
||||
|
||||
// Post a new comment. The Add button sits at the very bottom of the scroll view, below
|
||||
// the fixture card's body and footer.
|
||||
let addButton = app.buttons["Add Comment"]
|
||||
app.scrollUntilHittable(addButton)
|
||||
addButton.tap()
|
||||
|
||||
let postButton = app.navigationBars.buttons["Post"]
|
||||
XCTAssertTrue(
|
||||
postButton.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the composer sheet never appeared"
|
||||
)
|
||||
|
||||
let composer = app.textViews.firstMatch
|
||||
XCTAssertTrue(
|
||||
composer.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the composer's text editor never appeared"
|
||||
)
|
||||
let sentinel = "Posted from the phone by UI test"
|
||||
composer.tap()
|
||||
composer.typeText(sentinel)
|
||||
postButton.tap()
|
||||
|
||||
// The sheet dismisses only on the perform's success, and the perform awaits its own
|
||||
// reload — so by the time the thread re-reads, both the screen and the disk carry it.
|
||||
XCTAssertTrue(
|
||||
app.element(labelContaining: "Comments · 2").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the header never counted the posted comment"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[sentinel].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the posted comment's body never rendered in the thread"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForFile(under: root, containing: sentinel),
|
||||
"the posted comment never landed on disk under \(root.path)"
|
||||
)
|
||||
|
||||
// Edit the comment just posted, through its row's context menu.
|
||||
let postedBody = app.staticTexts[sentinel]
|
||||
postedBody.press(forDuration: 1.0)
|
||||
|
||||
let editAction = app.buttons["Edit Comment"]
|
||||
XCTAssertTrue(
|
||||
editAction.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the context menu's Edit Comment action never appeared"
|
||||
)
|
||||
editAction.tap()
|
||||
|
||||
let saveButton = app.navigationBars.buttons["Save"]
|
||||
XCTAssertTrue(
|
||||
saveButton.waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the edit sheet never appeared"
|
||||
)
|
||||
let editor = app.textViews.firstMatch
|
||||
XCTAssertEqual(
|
||||
editor.value as? String, sentinel,
|
||||
"the edit sheet did not seed with the posted comment's body"
|
||||
)
|
||||
|
||||
let rewritten = "Rewritten from the phone by UI test"
|
||||
editor.replaceAllText(with: rewritten)
|
||||
saveButton.tap()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[rewritten].waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the rewritten comment never rendered in the thread"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForFile(under: root, containing: rewritten),
|
||||
"the rewritten comment never landed on disk under \(root.path)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
fileDoesNotContain(under: root, substring: sentinel),
|
||||
"the pre-edit body survived on disk — the edit did not replace the body span"
|
||||
)
|
||||
// The edited indicator is `modified` differing from `created` — the save just made that
|
||||
// true, and the author line renders it as a trailing "· edited".
|
||||
XCTAssertTrue(
|
||||
app.element(labelContaining: "· edited").waitForExistence(timeout: XCUIApplication.uiTimeout),
|
||||
"the edited marker never appeared on the rewritten comment's author line"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,32 @@ extension XCUIApplication {
|
||||
confirm.tap()
|
||||
}
|
||||
|
||||
/// Boards → the fixture's first lane → its first card, landing on `CardDetailScreen`'s read
|
||||
/// view. Shared navigation boilerplate for every test that starts on a card.
|
||||
@MainActor
|
||||
func navigateToFirstCard() {
|
||||
let boardRow = element(labelContaining: RichBoard.title)
|
||||
XCTAssertTrue(
|
||||
boardRow.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
|
||||
)
|
||||
boardRow.tap()
|
||||
|
||||
let laneRow = element(labelContaining: RichBoard.firstLane)
|
||||
XCTAssertTrue(
|
||||
laneRow.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the \"\(RichBoard.firstLane)\" lane row never appeared"
|
||||
)
|
||||
laneRow.tap()
|
||||
|
||||
let cardRow = element(labelContaining: RichBoard.firstLaneFirstCard)
|
||||
XCTAssertTrue(
|
||||
cardRow.waitForExistence(timeout: Self.uiTimeout),
|
||||
"the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
|
||||
)
|
||||
cardRow.tap()
|
||||
}
|
||||
|
||||
/// Scrolls the frontmost view upward, a little at a time, until `element` is hittable or
|
||||
/// `maxAttempts` is exhausted. A `Form`/`List` does not auto-scroll to reveal what a test asks
|
||||
/// for, and this suite's Settings screen puts the About section — everything
|
||||
|
||||
@@ -63,7 +63,7 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
|
||||
- **Git-friendly by construction** — the app never runs git, and the format never needs it to be versionable: one folder per item and one file per card, stable UUID folder names as permanent identity, byte-faithful rewrites that never churn what you didn't change, and a seeded one-line `.gitignore` keeping Finder droppings out of diffs — so a board versions cleanly under any tooling you already use (`git init` it, clone it, review board changes in pull requests) while the app reads along live and stays strictly out of `.git`. The app-managed git stack that once shipped here — auto-commit, undo as forward commits, branches, a card's History — was built and then excised before launch (strategy/01-git-excision.md, ruled 2026-08-08; the pre-excision state is tagged `pre-git-excision`).
|
||||
|
||||
- **Lanework for iPhone (in development)** — a companion iOS app, second product rather than second edition (`dev.rzen.indie.KanbanMobile`), whose boards are the same `.kanban` folders in an iCloud Drive container (`iCloud.dev.rzen.indie.Kanban` — a folder the Mac's Finder shows as "Lanework", so the Mac app opens the same boards through the ordinary open panel today and can adopt the container natively later with no migration). It compiles the Mac app's storage engine *verbatim* — same loader, same writer, same byte-fidelity contract, so the two apps cannot drift on the format — and drives it from an `NSMetadataQuery` observer instead of FSEvents. The MVP surface: a Boards tab (boards → lanes → cards → card editor, with per-item download/materialization states honestly rendered), swipe actions to move a card between lanes or send it to `.trash/`, a card editor for title, body, and the typed style attributes (icon, icon color, background), and a Settings tab with an About section for the app's own changelog and license (IndieAbout). No git on the phone — the change signal is the container itself.
|
||||
- **Lanework for iPhone (in development)** — a companion iOS app, second product rather than second edition (`dev.rzen.indie.KanbanMobile`), whose boards are the same `.kanban` folders in an iCloud Drive container (`iCloud.dev.rzen.indie.Kanban` — a folder the Mac's Finder shows as "Lanework", so the Mac app opens the same boards through the ordinary open panel today and can adopt the container natively later with no migration). It compiles the Mac app's storage engine *verbatim* — same loader, same writer, same byte-fidelity contract, so the two apps cannot drift on the format — and drives it from an `NSMetadataQuery` observer instead of FSEvents. The MVP surface: a Boards tab (boards → lanes → cards → card editor, with per-item download/materialization states honestly rendered), swipe actions to move a card between lanes or send it to `.trash/`, a card editor for title, body, and the typed style attributes (icon, icon color, background), the card's comment thread on its read view — rendered read-only, with posting and editing behind transactional sheets that share the Mac's draft-then-post write path — and a Settings tab with an About section for the app's own changelog and license (IndieAbout). No git on the phone — the change signal is the container itself.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
Reference in New Issue
Block a user