import Foundation import Observation // MARK: - CardTitleEditSession /// The card window header's title field — **a body-edit session in miniature** /// (`CardBodyEditSession`'s own phrase for `CommentEditSession`, applied one level up), scoped to the /// single line the header shows in Edit mode. /// /// ### What it borrows from `CardBodyEditSession`, verbatim /// /// The buffer/disk pair, the single write predicate (*write if and only if the buffer differs from /// disk*), dirty-buffer-wins on `adopt(diskTitle:)`, the ~700 ms trailing debounce, `flush()` / /// `flushOrThrow()`, and the begin/end pair whose end registers the session's one undo step. **There /// is no session boundary of this type's own** — the card's title is only ever editable while the /// body column is in Edit mode (m6-card-body's rule, stated on `CardWindowView`'s header), so this /// session's `beginEditSession()` / `endEditSession()` are called from the exact places /// `CardBodyEditSession`'s are (`CardWindowHost.configureSession`'s `bodyPresentation.flushEdits` / /// `.beginEdits`, and `CardWindowSession.endSession()`), never on a schedule of their own. That is the /// literal reading of "title dirtiness rides the same gates/flush points as body". /// /// ### What differs, and why it is a sibling rather than a merge /// /// The write target is a different `WriteOperation` — a title commits through the same `.rename` /// vocabulary the board's inline rename uses (`BoardStore.commitCardTitle`, `BoardStore.setTitle`), /// never `.editBody`. Folding the two into one session would mean one buffer standing for two /// unrelated frontmatter keys with two different validation rules (a title trims and empty-removes; /// a body does neither), so a sibling object — driven by the same triggers, writing through its own /// seam — is the shape that keeps each write's rules in one place. /// /// ### The value is `String?`, so "not yet captured" needs its own bit /// /// `CardBodyEditSession.sessionOriginBody` uses `nil` for "no save has landed this session" because a /// body is never itself optional. A title's on-disk value legitimately *is* `nil` — a card can be /// untitled — so this type keeps `sessionHasOrigin` beside `sessionOriginTitle` rather than reusing /// `nil` for two different meanings. /// /// ### Single-line, by construction and by filter /// /// A plain `TextField` already turns Return into a submit rather than a newline (`CardWindowView`'s /// header wires `.onSubmit` to `flush()`), which is the whole of "Return ends editing rather than /// inserting" on the keystroke that matters. `edited(_:)` additionally strips `\n`/`\r` from /// whatever arrives — typing cannot produce either, but a paste can — so the buffer this type shows /// and writes is single-line even when the text came in some other way. Trimming leading/trailing /// whitespace and turning an empty result into `nil` happen at commit (`normalize(_:)`), mirroring /// the board's inline rename exactly (`BoardStore.commitRename`): a title of three spaces reads as /// untouched, never as a real (if blank) name. @MainActor @Observable public final class CardTitleEditSession { // MARK: State /// What the field is showing — raw, as typed, never trimmed. Preview's header reads this too /// (`CardWindowView.bodyColumn`), for `CardBodyEditSession.text`'s own reason: a flushed buffer is /// ahead of the snapshot by a reload, so the buffer is the truer of the two in both modes. public private(set) var text: String = "" /// What the last snapshot said the card's `title` key holds. `nil` is untitled, a legitimate /// resting state rather than a missing reading. public private(set) var disk: String? /// Whether the buffer, once normalized the way a commit would, differs from disk. Comparing the /// *normalized* text rather than the raw one is what keeps typing spaces around an untouched title /// from reading as dirty — `commitRename`'s own "whitespace commits as empty" rule, read one level /// earlier. public var isDirty: Bool { Self.normalize(text) != disk } /// Whether an Edit session is open right now — `CardBodyEditSession.isEditing`'s own bookkeeping, /// kept here too because this type's undo step coalesces the same way body's does: one step per /// session, captured from the first landed save to the session's end. public private(set) var isEditing = false // MARK: Seams /// The debounce interval — body's own ~700 ms default, and settable for the same reason /// (`DragSession.holdTimeout`'s precedent: production default on the property, a test dials it /// down). @ObservationIgnored public var debounceInterval: Duration = .milliseconds(700) /// Where a save goes — `BoardStore.commitCardTitle(inCard:title:)`, filled in by the window once it /// has joined its board. Takes the *normalized* title (trimmed, empty turned to `nil`), which is /// what the store's own write gate compares against the card's current title. @ObservationIgnored public var save: ((String?) -> CardBodyWriteOutcome)? /// Where this session's one undo step goes, called at `endEditSession()` with the title disk held /// when the session's first save landed and the title it holds now — /// `CardBodyEditSession.registerUndo`'s seam, one field over. @ObservationIgnored public var registerUndo: ((_ priorTitle: String?, _ newTitle: String?) -> Void)? /// Whether a save has landed this session — `CardBodyEditSession.sessionOriginBody`'s `nil` check, /// spelled as its own flag because `String?` is a real value here, not a "not captured" marker. @ObservationIgnored private var sessionHasOrigin = false /// What disk said before this session's first landed save, when `sessionHasOrigin` is `true`. @ObservationIgnored private var sessionOriginTitle: String? @ObservationIgnored private var pending: Task? /// How many saves have actually been attempted through `save`. @ObservationIgnored public private(set) var saveAttempts = 0 public init() {} // MARK: - Disk → buffer /// A snapshot arrived. Dirty-buffer-wins, `CardBodyEditSession.adopt(diskBody:)`'s exact shape: /// `disk` always follows it; `text` follows it only when the buffer had nothing unsaved. public func adopt(diskTitle: String?) { let wasDirty = isDirty disk = diskTitle guard !wasDirty else { return } let shown = diskTitle ?? "" if text != shown { text = shown } } // MARK: - Buffer → disk /// The field changed. Strips embedded newlines first (paste's own hazard — typing a single Return /// never reaches here at all, since a plain `TextField` treats it as a submit), then restarts the /// debounce, or cancels it when the change brought the buffer back to what a commit of it would /// leave on disk. public func edited(_ newText: String) { let sanitized = Self.stripNewlines(newText) guard text != sanitized else { return } text = sanitized guard isDirty else { cancelPending() return } scheduleSave() } /// Saves now if there is anything to save, cancelling the pending debounce first — the mode-exit /// and window-close flush, `CardBodyEditSession.flush()`'s own reason. @discardableResult public func flush() -> CardBodyWriteOutcome { cancelPending() return saveNow() } /// The start of one Edit session, called alongside `CardBodyEditSession.beginEditSession()` from /// the same door (`CardBodyPresentation.beginEdits`). Idempotent for the same reason: a /// re-published focus value or a redundant menu validation must not renotify observers over an /// already-open session. public func beginEditSession() { guard !isEditing else { return } isEditing = true } /// The end of one Edit session, called alongside `CardBodyEditSession.endEditSession()` from the /// same door. Flushes, then registers the session's one undo step when something of this session's /// actually landed and left a net change. @discardableResult public func endEditSession() -> CardBodyWriteOutcome { let outcome = flush() if sessionHasOrigin, sessionOriginTitle != disk { registerUndo?(sessionOriginTitle, disk) } sessionHasOrigin = false sessionOriginTitle = nil if isEditing { isEditing = false } return outcome } /// Throws the buffer away and takes disk's word for it — the Discard branch of the save-or-discard /// step, `CardBodyEditSession.discardBuffer()`'s exact shape. public func discardBuffer() { cancelPending() text = disk ?? "" sessionHasOrigin = false sessionOriginTitle = nil if isEditing { isEditing = false } } /// `DirtyBufferGuard`'s `attemptSave`, folded together with the body's own in /// `CardWindowSession.init()`: the same flush, with a real failure raised instead of reported. The /// three non-failures are `CardBodyEditSession.flushOrThrow()`'s own — written/unchanged land the /// text or need not, vanished has nowhere to write, suspended is the standing read-only lock row /// rather than a failure — and none of them is a reason to block the close. public func flushOrThrow() throws(BoardWriteError) { switch flush() { case .written, .unchanged, .vanished, .suspended: return case let .failed(error): throw error } } // MARK: - Private private func scheduleSave() { cancelPending() let interval = debounceInterval pending = Task { [weak self] in try? await Task.sleep(for: interval) guard !Task.isCancelled, let self else { return } self.pending = nil _ = self.saveNow() } } private func cancelPending() { pending?.cancel() pending = nil } /// The gate, and the one place `save` is called. A landing moves `disk` up to the normalized text /// that landed, so the echo arriving a reload later finds the buffer already clean. private func saveNow() -> CardBodyWriteOutcome { guard isDirty else { return .unchanged } guard let save else { return .vanished } saveAttempts += 1 let priorTitle = disk let normalized = Self.normalize(text) let outcome = save(normalized) switch outcome { case .written: if !sessionHasOrigin { sessionHasOrigin = true sessionOriginTitle = priorTitle } disk = normalized case .unchanged: disk = normalized case .suspended, .vanished, .failed: break } return outcome } /// Strips `\r\n`, `\n` and `\r` outright — the single-line field's own hard rule, applied to /// whatever arrives rather than only to what a commit would write, so the on-screen text never /// shows a line break a paste tried to bring in. private static func stripNewlines(_ raw: String) -> String { raw .replacingOccurrences(of: "\r\n", with: "") .replacingOccurrences(of: "\n", with: "") .replacingOccurrences(of: "\r", with: "") } /// Trimmed, empty turned to `nil` — `BoardStore.commitRename`'s own reading of a typed title, /// applied here so `isDirty` and the value handed to `save` agree with what the board's inline /// rename would do with the same keystrokes. private static func normalize(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } }