authorclover caruso <git@paperclover.net> 2026-10-12 22:58:44-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 02:05:14-07:00
log0c2795ab06a608463deba57402a39ada750c526c
treeb5652a3ac7f9432f40660482fca902061b369927
parenta8bb5062f1a874d388e00a07857a053ae0961320
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(ios): refresh page listings off the main thread

A section opens with its rows read on the background thread that opens it, and a poll reporting `listed` reads the rows again off the main thread before telling the views; the rest of the poll (save status, remote changes, rejected edits) is posted at once. Deleting pages releases the arrangement only once the rows are read again. New Page, arranging and moving pages still read the rows synchronously, so Undo, drags and Make Subpage never start from rows that miss the move. A reload or a typed title invalidates a read in flight, which reads again rather than undo it. Refs #144 Assisted-by: gpt-6.1-sol Assisted-by: claude-opus-5.5

2 files changed, 57 insertions(+), 29 deletions(-)

apps/ios/Snowbound/Arranging.swift+4-10
......@@ -152,7 +152,7 @@ enum Arranging {
152152 reported = moved
153153 release()
154154 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
155 section.listed()
155 section.reloadRows()
156156 }
157157 if moved == nil { failed("Can’t Move Page") }
158158 }
......@@ -242,8 +242,8 @@ enum Arranging {
242242 }
243243 if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } }
244244 release()
245 source.listed()
246 target.listed()
245 source.reloadRows()
246 target.reloadRows()
247247 if !succeeded {
248248 failed("Can’t Move All Pages", reported.isEmpty
249249 ? "Check both sections, then try moving the remaining pages again."
......@@ -306,14 +306,8 @@ extension Section {
306306 /// Puts page `id` where `order` places it, as one edit; false where it isn't listed.
307307 func arrange(_ order: [Placed], moving id: String) -> Bool {
308308 guard sb_section_arrange(handle, json(order), json([id])) else { return false }
309 listed()
310 return true
311 }
312
313 /// Lists the pages again and tells the views.
314 func listed() {
315309 reloadRows()
316 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
310 return true
317311 }
318312}
319313
apps/ios/Snowbound/Notebooks.swift+53-19
......@@ -629,7 +629,7 @@ private struct Weak<T: AnyObject> { weak var value: T? }
629629
630630/// A section open for editing: pages listed and saved through its replica.
631631final class Section {
632 /// Sent on the main thread with `flags` from `sb_section_poll` after each poll.
632 /// Sent on the main thread with `flags` from `sb_section_poll`, `listed` once the rows are read again.
633633 static let changed = Notification.Name("SectionChanged")
634634 static let listed: UInt32 = 1
635635 static let remote: UInt32 = 2
......@@ -642,17 +642,18 @@ final class Section {
642642 let tab: Tab
643643 let handle: OpaquePointer
644644 private let token: Int
645 private(set) var rows: [Row] = []
645 private(set) var rows: [Row]
646646 private(set) var status = SaveStatus.unknown
647 private var listing: (invalidated: Bool, completions: [() -> Void])?
647648
648649 static var all: [Section] { open.values.compactMap(\.value) }
649650
650 private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) {
651 private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int, rows: [Row]) {
651652 self.notebook = notebook
652653 self.tab = tab
653654 self.handle = handle
654655 self.token = token
655 reloadRows()
656 self.rows = rows
656657 }
657658
658659 deinit {
......@@ -671,21 +672,53 @@ final class Section {
671672 let token = tokens
672673 let pointer = Int(bitPattern: library)
673674 let author = Author.name ?? ""
674 background({ () -> (Int, String?) in
675 background({ () -> (Int, String?, [Row]) in
675676 var error: UnsafeMutablePointer<CChar>?
676677 let handle = sb_section_open(
677678 OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error)
678 return (Int(bitPattern: handle), take(error))
679 }) { handle, error in
679 let rows = handle.flatMap { decode([Row].self, sb_section_pages($0)) } ?? []
680 return (Int(bitPattern: handle), take(error), rows)
681 }) { handle, error, rows in
680682 guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) }
681 let section = Section(notebook: notebook, tab: tab, handle: handle, token: token)
683 let section = Section(notebook: notebook, tab: tab, handle: handle, token: token, rows: rows)
682684 open[token] = Weak(value: section)
683685 done(section, nil)
686 // Wakes before `open[token]` was set went unheard.
687 section.poll()
684688 }
685689 }
686690
691 /// Lists the pages again and tells the views.
687692 func reloadRows() {
693 listing?.invalidated = true
688694 rows = decode([Row].self, sb_section_pages(handle)) ?? rows
695 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
696 }
697
698 /// Lists the pages again off the main thread, tells the views, then runs `then`.
699 private func refreshRows(then: @escaping () -> Void = {}) {
700 guard listing == nil else {
701 listing?.invalidated = true
702 listing?.completions.append(then)
703 return
704 }
705 listing = (false, [then])
706 readRows()
707 }
708
709 private func readRows() {
710 background({ [self] in decode([Row].self, sb_section_pages(handle)) }) { [self] rows in
711 // A read begun before a reload or retitle would undo it.
712 if listing!.invalidated, rows != nil {
713 listing?.invalidated = false
714 return readRows()
715 }
716 self.rows = rows ?? self.rows
717 let completions = listing!.completions
718 listing = nil
719 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
720 for completion in completions { completion() }
721 }
689722 }
690723
691724 /// The row listing page or conflict page `id`, and the version when it is one.
......@@ -701,17 +734,19 @@ final class Section {
701734 var status: UInt8 = 0
702735 let flags = sb_section_poll(handle, &status)
703736 let changed = SaveStatus(rawValue: status) ?? .unknown
704 let unchanged = flags == 0 && changed == self.status
737 if flags & Self.listed != 0 { refreshRows() }
738 let unlisted = flags & ~Self.listed
739 let unchanged = unlisted == 0 && changed == self.status
705740 self.status = changed
706 if flags & Self.listed != 0 { reloadRows() }
707741 NotificationCenter.default.post(
708742 name: Self.changed, object: self,
709 userInfo: unchanged ? ["flags": flags, "unchanged": true] : ["flags": flags])
743 userInfo: unchanged ? ["flags": unlisted, "unchanged": true] : ["flags": unlisted])
710744 }
711745
712746 /// A page's title as typed, shown in the list before the section reports it.
713747 func retitle(_ id: String, _ title: String) {
714748 guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return }
749 listing?.invalidated = true
715750 rows[index].title = title
716751 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)])
717752 }
......@@ -721,7 +756,6 @@ final class Section {
721756 let (date, time) = titleDate()
722757 let id = take(sb_section_new_page(handle, parent, date, time))
723758 reloadRows()
724 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
725759 return id
726760 }
727761
......@@ -735,13 +769,13 @@ final class Section {
735769 }
736770 return true
737771 }) { [self] deleted in
738 release()
739 reloadRows()
740 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
741 if deleted { done() }
742 else {
743 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
744 window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.")
772 refreshRows {
773 release()
774 if deleted { done() }
775 else {
776 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
777 window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.")
778 }
745779 }
746780 }
747781 }