authorclover caruso <git@paperclover.net> 2026-10-11 14:24:03-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 16:44:36-07:00
logd3eb3c31df31f73890cbbca83e695d85e916e0f3
tree79341c92b2174399384ce565541357fe79190d3e
parent706bb3a78129719645128ee615252d6e4c879ec3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(ios): preserve page ancestry and coordinate batch mutations

Move only selected pages at their selected-ancestor depth. Keep actual receipts and order snapshots as one reciprocal Undo/Redo, restoring only attributable first-child promotions whose resulting level is unchanged. A host reservation excludes queued moves and page deletions from notebook close and filesystem Delete/Rename through actual approval and mutation. Use singular deletion titles for one selected item. Fixes #125 Fixes #126 Assisted-by: gpt-6.1-sol

3 files changed, 169 insertions(+), 37 deletions(-)

apps/ios/Snowbound/Arranging.swift+144-24
......@@ -29,6 +29,7 @@ private func json(_ value: some Encodable) -> String {
2929/// either list.
3030enum Arranging {
3131 static let history = UndoManager()
32 private(set) static var busy = false
3233 private static let owner = NSObject()
3334
3435 /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own.
......@@ -55,11 +56,26 @@ enum Arranging {
5556 }
5657 private static let queue = DispatchQueue(label: "Arranging")
5758
58 private static func failed(_ title: String) {
59 private static func failed(_ title: String, _ message: String = "Check that the notebook can be reached, then try again.") {
5960 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
6061 var top = window?.rootViewController
6162 while let presented = top?.presentedViewController { top = presented }
62 top?.alert(title, "Check that the notebook can be reached, then try again.")
63 top?.alert(title, message)
64 }
65
66 private static func available() -> Bool {
67 guard !busy else {
68 failed("Notebook Is Busy", "Wait for the current notebook change to finish, then try again.")
69 return false
70 }
71 return true
72 }
73
74 /// Excludes queued moves and filesystem mutations through their asynchronous outcomes.
75 static func reserve() -> (() -> Void)? {
76 guard available() else { return nil }
77 busy = true
78 return { busy = false }
6379 }
6480
6581 /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under
......@@ -76,6 +92,7 @@ enum Arranging {
7692
7793 /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit.
7894 static func arrange(_ section: Section, _ order: [Placed], moving id: String) {
95 guard available() else { return }
7996 let before = section.rows.map { Placed(id: $0.id, level: $0.level) }
8097 guard order != before, section.arrange(order, moving: id) else { return }
8198 let (notebook, path) = (section.notebook, section.tab.path)
......@@ -87,6 +104,7 @@ enum Arranging {
87104 /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote
88105 /// Subpage do.
89106 static func indent(_ section: Section, _ id: String, to level: Int) {
107 guard available() else { return }
90108 var order = section.rows.map { Placed(id: $0.id, level: $0.level) }
91109 guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return }
92110 let was = order[at].level
......@@ -110,7 +128,7 @@ enum Arranging {
110128 page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1,
111129 discard: String? = nil
112130 ) {
113 guard let library = notebook.handle else { return }
131 guard let library = notebook.handle, let release = reserve() else { return }
114132 let pointer = Int(bitPattern: library)
115133 let author = Author.name ?? ""
116134 let (date, time) = titleDate()
......@@ -130,6 +148,7 @@ enum Arranging {
130148 time))
131149 }) { moved in
132150 reported = moved
151 release()
133152 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
134153 section.listed()
135154 }
......@@ -137,10 +156,104 @@ enum Arranging {
137156 }
138157 }
139158
159 /// Moves only the selected pages, retaining their ancestry among the selection.
160 static func move(pages ids: [String], from source: Section, to tab: Tab) {
161 guard let release = reserve() else { return }
162 Section.open(tab, of: source.notebook) { target, _ in
163 guard let target else {
164 release()
165 return failed("Can’t Move Pages")
166 }
167 var ancestors: [Int] = []
168 var selected: [Placed] = []
169 for row in source.rows {
170 ancestors.removeAll { $0 >= row.level }
171 guard ids.contains(row.id) else { continue }
172 selected.append(Placed(id: row.id, level: ancestors.count + 1))
173 ancestors.append(row.level)
174 }
175 let order = target.rows.map { Placed(id: $0.id, level: $0.level) } + selected
176 // Hand off the reservation without yielding the main thread.
177 release()
178 transfer(selected.map { (page: $0.id, discard: nil) },
179 from: source, to: target, order: order)
180 }
181 }
182
183 private static func transfer(
184 _ pages: [(page: String, discard: String?)], from source: Section, to target: Section, order: [Placed],
185 repairs: [(id: String, expected: Int, restore: Int)] = []
186 ) {
187 guard !pages.isEmpty, let library = source.notebook.handle, let release = reserve() else { return }
188 let before = source.rows.map { Placed(id: $0.id, level: $0.level) }
189 let pointer = Int(bitPattern: library)
190 let sourcePointer = Int(bitPattern: source.handle)
191 let targetPointer = Int(bitPattern: target.handle)
192 let author = Author.name ?? ""
193 let (date, time) = titleDate()
194 var undo: () -> Void = {}
195 let undoing = history.isUndoing
196 let reciprocal = undoing || history.isRedoing
197 if reciprocal { done("Move Pages") { undo() } }
198 later({ () -> ([MovedPage], [Placed], [(id: String, expected: Int, restore: Int)], Bool) in
199 var reported: [MovedPage] = []
200 var arrived: [String: String] = [:]
201 var promotions: [(id: String, expected: Int, restore: Int)] = []
202 // Removing a parent can promote its first remaining child.
203 for page in pages.reversed() {
204 guard let listed = decode([Row].self, sb_section_pages(OpaquePointer(bitPattern: sourcePointer))) else { break }
205 let next = listed.first?.id == page.page ? listed.dropFirst().first : nil
206 guard let moved = decode(MovedPage.self, sb_library_move_page(
207 OpaquePointer(bitPattern: pointer), source.tab.path, page.page, target.tab.path,
208 nil, 1, page.discard, author, date, time)) else { break }
209 reported.append(moved)
210 arrived[page.page] = moved.page
211 if let next, next.level > 1, moved.before == next.id {
212 promotions.removeAll { $0.id == next.id }
213 promotions.append((id: next.id, expected: 1, restore: next.level))
214 }
215 }
216 promotions.removeAll { arrived[$0.id] != nil }
217 let restore = before.map { Placed(id: arrived[$0.id] ?? $0.id, level: $0.level) }
218 guard !reported.isEmpty else { return (reported, restore, promotions, false) }
219 let placed = order.map { Placed(id: arrived[$0.id] ?? $0.id, level: $0.level) }
220 guard sb_section_arrange(OpaquePointer(bitPattern: targetPointer), json(placed), json(Array(arrived.values))) else {
221 return (reported, restore, promotions, false)
222 }
223 if !repairs.isEmpty {
224 guard let present = decode([Row].self, sb_section_pages(OpaquePointer(bitPattern: targetPointer))) else {
225 return (reported, restore, promotions, false)
226 }
227 let applicable = repairs.filter { repair in present.contains { $0.id == repair.id && $0.level == repair.expected } }
228 let order = present.map { row in Placed(id: row.id, level: applicable.first { $0.id == row.id }?.restore ?? row.level) }
229 if !applicable.isEmpty && !sb_section_arrange(OpaquePointer(bitPattern: targetPointer), json(order), json(applicable.map(\.id))) {
230 return (reported, restore, promotions, false)
231 }
232 }
233 return (reported, restore, promotions, reported.count == pages.count)
234 }) { reported, restore, promotions, succeeded in
235 undo = { transfer(reported.map { (page: $0.page, discard: $0.added) }, from: target, to: source, order: restore, repairs: promotions) }
236 if reciprocal && reported.isEmpty {
237 // A rejected Undo or Redo must leave its original action retryable.
238 undo = { done("Move Pages") { transfer(pages, from: source, to: target, order: order, repairs: repairs) } }
239 if undoing { history.redo() } else { history.undo() }
240 }
241 if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } }
242 release()
243 source.listed()
244 target.listed()
245 if !succeeded {
246 failed("Can’t Move All Pages", reported.isEmpty
247 ? "Check both sections, then try moving the remaining pages again."
248 : "Check both sections. Use \(undoing ? "Redo" : "Undo") to reverse completed moves, or try moving the remaining pages again.")
249 }
250 }
251 }
252
140253 /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order`
141254 /// names its entries once moved; a section shown opens again where it went.
142 static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = []) {
143 guard let library = notebook.handle else { return }
255 static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = [], then: @escaping () -> Void = {}) {
256 guard let library = notebook.handle, let release = reserve() else { return }
144257 let home = parentFolder(of: path)
145258 let name = path.split(separator: "/").last.map(String.init) ?? path
146259 let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name
......@@ -152,6 +265,7 @@ enum Arranging {
152265 let paths = json(order)
153266 later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in
154267 notebook.reload {
268 release()
155269 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
156270 for (scene, shown) in released {
157271 let section = moved ? follow(shown.section, from: path, to: placed) : shown.section
......@@ -159,7 +273,7 @@ enum Arranging {
159273 scene.open(tab, of: notebook, page: shown.page)
160274 }
161275 }
162 if !moved { failed("Can’t Move Section") }
276 if moved { then() } else { failed("Can’t Move Section") }
163277 }
164278 }
165279 }
......@@ -171,6 +285,7 @@ enum Arranging {
171285
172286 /// Puts `notebook` at `index` among the notebooks of its place in the list.
173287 static func move(_ notebook: Notebook, to index: Int) {
288 guard available() else { return }
174289 guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else {
175290 return
176291 }
......@@ -181,8 +296,8 @@ enum Arranging {
181296}
182297
183298extension UIResponder {
184 @objc func undoArranging() { Arranging.history.undo() }
185 @objc func redoArranging() { Arranging.history.redo() }
299 @objc func undoArranging() { if !Arranging.busy { Arranging.history.undo() } }
300 @objc func redoArranging() { if !Arranging.busy { Arranging.history.redo() } }
186301}
187302
188303extension Section {
......@@ -246,7 +361,9 @@ final class PageDrag {
246361private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) {
247362 let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
248363 for action in actions {
249 sheet.addAction(UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) })
364 let item = UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) }
365 item.isEnabled = !action.attributes.contains(.disabled)
366 sheet.addAction(item)
250367 }
251368 sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
252369 sheet.popoverPresentationController?.sourceView = view
......@@ -266,7 +383,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
266383 }
267384
268385 override var canBecomeFirstResponder: Bool { true }
269 override var undoManager: UndoManager? { Arranging.history }
386 override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil }
270387 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
271388
272389 /// Undo in the list takes back its moves.
......@@ -300,7 +417,8 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
300417 return section.notebook.sections(besides: section.tab.path).map { tab in
301418 UIAction(
302419 title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group,
303 image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal)
420 image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal),
421 attributes: !Arranging.busy ? [] : .disabled
304422 ) { [weak self] _ in
305423 Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path)
306424 self?.becomeFirstResponder()
......@@ -315,11 +433,11 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
315433 var actions: [UIMenuElement] = [
316434 UIAction(
317435 title: "Make Subpage", image: UIImage(systemName: "increase.indent"),
318 attributes: indentable(rows, at) ? [] : .disabled
436 attributes: !Arranging.busy && indentable(rows, at) ? [] : .disabled
319437 ) { [weak self] _ in self?.indent(id, by: 1) },
320438 UIAction(
321439 title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"),
322 attributes: rows[at].level > 1 ? [] : .disabled
440 attributes: !Arranging.busy && rows[at].level > 1 ? [] : .disabled
323441 ) { [weak self] _ in self?.indent(id, by: -1) },
324442 ]
325443 let moves = moveActions(item)
......@@ -331,7 +449,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
331449
332450 /// VoiceOver's ways to move a page without dragging it.
333451 func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? {
334 guard item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id })
452 guard !Arranging.busy, item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id })
335453 else { return nil }
336454 let id = item.row.id
337455 var actions: [UIAccessibilityCustomAction] = []
......@@ -357,7 +475,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
357475 _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath
358476 ) -> UISwipeActionsConfiguration? {
359477 // A swipe across the list changes section instead.
360 guard !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil,
478 guard !Arranging.busy, !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil,
361479 let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id })
362480 else { return nil }
363481 let id = rows[at].id
......@@ -384,7 +502,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
384502 }
385503
386504 override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
387 items[indexPath.row].version == nil
505 !Arranging.busy && items[indexPath.row].version == nil
388506 }
389507
390508 override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) {
......@@ -404,7 +522,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
404522 _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
405523 ) -> [UIDragItem] {
406524 let item = items[indexPath.row]
407 guard let section, item.version == nil else { return [] }
525 guard !Arranging.busy, let section, item.version == nil else { return [] }
408526 let drag = UIDragItem(itemProvider: NSItemProvider())
409527 drag.localObject = PageDrag(section: section, id: item.row.id)
410528 return [drag]
......@@ -431,7 +549,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
431549 _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath?
432550 ) -> UITableViewDropProposal {
433551 let page = session.localDragSession?.items.first?.localObject as? PageDrag
434 guard let page, page.section === section else {
552 guard !Arranging.busy, let page, page.section === section else {
435553 return UITableViewDropProposal(operation: .forbidden)
436554 }
437555 return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
......@@ -457,7 +575,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
457575 }
458576 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.
459577 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in
460 self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2
578 !Arranging.busy && self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2
461579 }
462580 // The data source is still applying the move when this runs.
463581 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
......@@ -466,7 +584,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
466584 }
467585
468586 override var canBecomeFirstResponder: Bool { true }
469 override var undoManager: UndoManager? { Arranging.history }
587 override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil }
470588 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
471589
472590 /// Undo in the list takes back its moves.
......@@ -512,7 +630,8 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
512630 notebook.destinations(of: path).map { folder in
513631 UIAction(
514632 title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "),
515 image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder")
633 image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder"),
634 attributes: !Arranging.busy ? [] : .disabled
516635 ) { [weak self] _ in
517636 Arranging.place(path, of: notebook, in: folder)
518637 self?.becomeFirstResponder()
......@@ -528,12 +647,12 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
528647 guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] }
529648 return [
530649 UIMenu(options: .displayInline, children: [
531 UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: at > 0 ? [] : .disabled) {
650 UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: !Arranging.busy && at > 0 ? [] : .disabled) {
532651 _ in Arranging.move(notebook, to: at - 1)
533652 },
534653 UIAction(
535654 title: "Move Down", image: UIImage(systemName: "arrow.down"),
536 attributes: at < siblings.count - 1 ? [] : .disabled
655 attributes: !Arranging.busy && at < siblings.count - 1 ? [] : .disabled
537656 ) { _ in Arranging.move(notebook, to: at + 1) },
538657 ])
539658 ]
......@@ -603,7 +722,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
603722 func collectionView(
604723 _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
605724 ) -> [UIDragItem] {
606 guard let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] }
725 guard !Arranging.busy, let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] }
607726 let drag = UIDragItem(itemProvider: NSItemProvider())
608727 drag.localObject = item
609728 return [drag]
......@@ -640,6 +759,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
640759 _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession,
641760 withDestinationIndexPath destinationIndexPath: IndexPath?
642761 ) -> UICollectionViewDropProposal {
762 guard !Arranging.busy else { return UICollectionViewDropProposal(operation: .forbidden) }
643763 let dragged = session.localDragSession?.items.first?.localObject
644764 if let page = dragged as? PageDrag {
645765 guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) }
apps/ios/Snowbound/Lists.swift+23-13
......@@ -376,7 +376,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
376376 actions.append(UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: destinations.map { folder in
377377 UIAction(title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › ")) { [weak self] _ in
378378 self?.setEditing(false, animated: true)
379 for path in paths { Arranging.place(path, of: notebook, in: folder) }
379 func move(_ remaining: ArraySlice<String>) {
380 guard let path = remaining.first else { return }
381 Arranging.place(path, of: notebook, in: folder) { move(remaining.dropFirst()) }
382 }
383 move(paths[...])
380384 }
381385 }))
382386 }
......@@ -389,11 +393,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
389393
390394 private func delete(_ notebooks: [Notebook]) {
391395 let alert = UIAlertController(
392 title: "Delete \(notebooks.count) Notebooks?",
396 title: notebooks.count == 1 ? "Delete Notebook?" : "Delete \(notebooks.count) Notebooks?",
393397 message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert)
394398 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
395399 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
396 guard let self else { return }
400 guard let self, let release = Arranging.reserve() else { return }
397401 setEditing(false, animated: true)
398402 collectionView.isUserInteractionEnabled = false
399403 for notebook in notebooks {
......@@ -426,6 +430,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
426430 }
427431 return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed)
428432 }) { [weak self] problem, failed in
433 release()
429434 self?.collectionView.isUserInteractionEnabled = true
430435 for notebook in notebooks { notebook.pause() }
431436 for notebook in failed { notebook.open { self?.reload() } }
......@@ -597,10 +602,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
597602 /// then runs `act`; refuses with why while an edit can't be published, as moving the
598603 /// folder then would strand it.
599604 private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {
605 guard let release = Arranging.reserve() else { return }
600606 for scene in UIApplication.shared.connectedScenes {
601607 (scene.delegate as? SceneDelegate)?.close(notebook)
602608 }
603 guard let handle = notebook.handle else { return act() }
609 guard let handle = notebook.handle else {
610 defer { release() }
611 return act()
612 }
604613 let pointer = Int(bitPattern: handle)
605614 background({ () -> String? in
606615 var error: UnsafeMutablePointer<CChar>?
......@@ -608,6 +617,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
608617 let problem = take(error)
609618 return closed ? nil : problem ?? ""
610619 }) { [weak self] problem in
620 defer { release() }
611621 if let problem {
612622 self?.alert(title, problem)
613623 return
......@@ -716,10 +726,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
716726 notebook.open { self?.reload() }
717727 })
718728 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
719 do { try FileManager.default.removeItem(at: folder) } catch {
720 self?.alert("Can’t Delete", error.localizedDescription)
729 self?.close(notebook, refusing: "Can’t Delete") {
730 do { try FileManager.default.removeItem(at: folder) } catch {
731 self?.alert("Can’t Delete", error.localizedDescription)
732 }
733 self?.rescan()
721734 }
722 self?.rescan()
723735 })
724736 present(alert, animated: true)
725737 }
......@@ -1115,27 +1127,25 @@ final class PagesViewController: UITableViewController {
11151127 section.delete(ids)
11161128 }
11171129 if selection.contains(where: { $0.version != nil }) {
1118 let alert = UIAlertController(title: "Delete \(selection.count) Pages?", message: "Conflict pages will be deleted permanently.", preferredStyle: .alert)
1130 let alert = UIAlertController(title: selection.count == 1 ? "Delete Page?" : "Delete \(selection.count) Pages?", message: "Conflict pages will be deleted permanently.", preferredStyle: .alert)
11191131 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
11201132 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() })
11211133 present(alert, animated: true)
11221134 } else { remove() }
11231135 })
11241136 delete.tintColor = .systemRed
1125 delete.isEnabled = !selection.isEmpty
1137 delete.isEnabled = !Arranging.busy && !selection.isEmpty
11261138 var destinations: [UIAction] = []
11271139 if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) {
11281140 destinations = section.notebook.sections(besides: section.tab.path).map { tab in
11291141 UIAction(title: tab.name) { [weak self] _ in
11301142 self?.setEditing(false, animated: true)
1131 for item in selection {
1132 Arranging.move(page: item.id, of: section.notebook, from: section.tab.path, to: tab.path)
1133 }
1143 Arranging.move(pages: selection.map(\.id), from: section, to: tab)
11341144 }
11351145 }
11361146 }
11371147 let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations))
1138 move.isEnabled = !destinations.isEmpty
1148 move.isEnabled = !Arranging.busy && !destinations.isEmpty
11391149 toolbarItems = [move, .flexibleSpace(), delete]
11401150 navigationController?.setToolbarHidden(false, animated: false)
11411151 }
apps/ios/Snowbound/Notebooks.swift+2
......@@ -724,6 +724,7 @@ final class Section {
724724 }
725725
726726 func delete(_ ids: [String], done: @escaping () -> Void = {}) {
727 guard let release = Arranging.reserve() else { return }
727728 let (date, time) = titleDate()
728729 let pointer = Int(bitPattern: handle)
729730 background({
......@@ -732,6 +733,7 @@ final class Section {
732733 }
733734 return true
734735 }) { [self] deleted in
736 release()
735737 reloadRows()
736738 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
737739 if deleted { done() }