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 {...@@ -152,7 +152,7 @@ enum Arranging {
152 reported = moved152 reported = moved
153 release()153 release()
154 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {154 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
155 section.listed()155 section.reloadRows()
156 }156 }
157 if moved == nil { failed("Can’t Move Page") }157 if moved == nil { failed("Can’t Move Page") }
158 }158 }
...@@ -242,8 +242,8 @@ enum Arranging {...@@ -242,8 +242,8 @@ enum Arranging {
242 }242 }
243 if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } }243 if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } }
244 release()244 release()
245 source.listed()245 source.reloadRows()
246 target.listed()246 target.reloadRows()
247 if !succeeded {247 if !succeeded {
248 failed("Can’t Move All Pages", reported.isEmpty248 failed("Can’t Move All Pages", reported.isEmpty
249 ? "Check both sections, then try moving the remaining pages again."249 ? "Check both sections, then try moving the remaining pages again."
...@@ -306,14 +306,8 @@ extension Section {...@@ -306,14 +306,8 @@ extension Section {
306 /// Puts page `id` where `order` places it, as one edit; false where it isn't listed.306 /// Puts page `id` where `order` places it, as one edit; false where it isn't listed.
307 func arrange(_ order: [Placed], moving id: String) -> Bool {307 func arrange(_ order: [Placed], moving id: String) -> Bool {
308 guard sb_section_arrange(handle, json(order), json([id])) else { return false }308 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() {
315 reloadRows()309 reloadRows()
316 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])310 return true
317 }311 }
318}312}
319313
apps/ios/Snowbound/Notebooks.swift+53-19
...@@ -629,7 +629,7 @@ private struct Weak<T: AnyObject> { weak var value: T? }...@@ -629,7 +629,7 @@ private struct Weak<T: AnyObject> { weak var value: T? }
629629
630/// A section open for editing: pages listed and saved through its replica.630/// A section open for editing: pages listed and saved through its replica.
631final class Section {631final 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.
633 static let changed = Notification.Name("SectionChanged")633 static let changed = Notification.Name("SectionChanged")
634 static let listed: UInt32 = 1634 static let listed: UInt32 = 1
635 static let remote: UInt32 = 2635 static let remote: UInt32 = 2
...@@ -642,17 +642,18 @@ final class Section {...@@ -642,17 +642,18 @@ final class Section {
642 let tab: Tab642 let tab: Tab
643 let handle: OpaquePointer643 let handle: OpaquePointer
644 private let token: Int644 private let token: Int
645 private(set) var rows: [Row] = []645 private(set) var rows: [Row]
646 private(set) var status = SaveStatus.unknown646 private(set) var status = SaveStatus.unknown
647 private var listing: (invalidated: Bool, completions: [() -> Void])?
647648
648 static var all: [Section] { open.values.compactMap(\.value) }649 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]) {
651 self.notebook = notebook652 self.notebook = notebook
652 self.tab = tab653 self.tab = tab
653 self.handle = handle654 self.handle = handle
654 self.token = token655 self.token = token
655 reloadRows()656 self.rows = rows
656 }657 }
657658
658 deinit {659 deinit {
...@@ -671,21 +672,53 @@ final class Section {...@@ -671,21 +672,53 @@ final class Section {
671 let token = tokens672 let token = tokens
672 let pointer = Int(bitPattern: library)673 let pointer = Int(bitPattern: library)
673 let author = Author.name ?? ""674 let author = Author.name ?? ""
674 background({ () -> (Int, String?) in675 background({ () -> (Int, String?, [Row]) in
675 var error: UnsafeMutablePointer<CChar>?676 var error: UnsafeMutablePointer<CChar>?
676 let handle = sb_section_open(677 let handle = sb_section_open(
677 OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error)678 OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error)
678 return (Int(bitPattern: handle), take(error))679 let rows = handle.flatMap { decode([Row].self, sb_section_pages($0)) } ?? []
679 }) { handle, error in680 return (Int(bitPattern: handle), take(error), rows)
681 }) { handle, error, rows in
680 guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) }682 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)
682 open[token] = Weak(value: section)684 open[token] = Weak(value: section)
683 done(section, nil)685 done(section, nil)
686 // Wakes before `open[token]` was set went unheard.
687 section.poll()
684 }688 }
685 }689 }
686690
691 /// Lists the pages again and tells the views.
687 func reloadRows() {692 func reloadRows() {
693 listing?.invalidated = true
688 rows = decode([Row].self, sb_section_pages(handle)) ?? rows694 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 }
689 }722 }
690723
691 /// The row listing page or conflict page `id`, and the version when it is one.724 /// The row listing page or conflict page `id`, and the version when it is one.
...@@ -701,17 +734,19 @@ final class Section {...@@ -701,17 +734,19 @@ final class Section {
701 var status: UInt8 = 0734 var status: UInt8 = 0
702 let flags = sb_section_poll(handle, &status)735 let flags = sb_section_poll(handle, &status)
703 let changed = SaveStatus(rawValue: status) ?? .unknown736 let changed = SaveStatus(rawValue: status) ?? .unknown
704 let unchanged = flags == 0 && changed == self.status737 if flags & Self.listed != 0 { refreshRows() }
738 let unlisted = flags & ~Self.listed
739 let unchanged = unlisted == 0 && changed == self.status
705 self.status = changed740 self.status = changed
706 if flags & Self.listed != 0 { reloadRows() }
707 NotificationCenter.default.post(741 NotificationCenter.default.post(
708 name: Self.changed, object: self,742 name: Self.changed, object: self,
709 userInfo: unchanged ? ["flags": flags, "unchanged": true] : ["flags": flags])743 userInfo: unchanged ? ["flags": unlisted, "unchanged": true] : ["flags": unlisted])
710 }744 }
711745
712 /// A page's title as typed, shown in the list before the section reports it.746 /// A page's title as typed, shown in the list before the section reports it.
713 func retitle(_ id: String, _ title: String) {747 func retitle(_ id: String, _ title: String) {
714 guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return }748 guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return }
749 listing?.invalidated = true
715 rows[index].title = title750 rows[index].title = title
716 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)])751 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)])
717 }752 }
...@@ -721,7 +756,6 @@ final class Section {...@@ -721,7 +756,6 @@ final class Section {
721 let (date, time) = titleDate()756 let (date, time) = titleDate()
722 let id = take(sb_section_new_page(handle, parent, date, time))757 let id = take(sb_section_new_page(handle, parent, date, time))
723 reloadRows()758 reloadRows()
724 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
725 return id759 return id
726 }760 }
727761
...@@ -735,13 +769,13 @@ final class Section {...@@ -735,13 +769,13 @@ final class Section {
735 }769 }
736 return true770 return true
737 }) { [self] deleted in771 }) { [self] deleted in
738 release()772 refreshRows {
739 reloadRows()773 release()
740 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])774 if deleted { done() }
741 if deleted { done() }775 else {
742 else {776 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
743 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first777 window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.")
744 window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.")778 }
745 }779 }
746 }780 }
747 }781 }