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
185 lines
8.0 KiB
Swift
185 lines
8.0 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|