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 {...@@ -29,6 +29,7 @@ private func json(_ value: some Encodable) -> String {
29/// either list.29/// either list.
30enum Arranging {30enum Arranging {
31 static let history = UndoManager()31 static let history = UndoManager()
32 private(set) static var busy = false
32 private static let owner = NSObject()33 private static let owner = NSObject()
3334
34 /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own.35 /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own.
...@@ -55,11 +56,26 @@ enum Arranging {...@@ -55,11 +56,26 @@ enum Arranging {
55 }56 }
56 private static let queue = DispatchQueue(label: "Arranging")57 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.") {
59 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first60 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
60 var top = window?.rootViewController61 var top = window?.rootViewController
61 while let presented = top?.presentedViewController { top = presented }62 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 }
63 }79 }
6480
65 /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under81 /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under
...@@ -76,6 +92,7 @@ enum Arranging {...@@ -76,6 +92,7 @@ enum Arranging {
7692
77 /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit.93 /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit.
78 static func arrange(_ section: Section, _ order: [Placed], moving id: String) {94 static func arrange(_ section: Section, _ order: [Placed], moving id: String) {
95 guard available() else { return }
79 let before = section.rows.map { Placed(id: $0.id, level: $0.level) }96 let before = section.rows.map { Placed(id: $0.id, level: $0.level) }
80 guard order != before, section.arrange(order, moving: id) else { return }97 guard order != before, section.arrange(order, moving: id) else { return }
81 let (notebook, path) = (section.notebook, section.tab.path)98 let (notebook, path) = (section.notebook, section.tab.path)
...@@ -87,6 +104,7 @@ enum Arranging {...@@ -87,6 +104,7 @@ enum Arranging {
87 /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote104 /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote
88 /// Subpage do.105 /// Subpage do.
89 static func indent(_ section: Section, _ id: String, to level: Int) {106 static func indent(_ section: Section, _ id: String, to level: Int) {
107 guard available() else { return }
90 var order = section.rows.map { Placed(id: $0.id, level: $0.level) }108 var order = section.rows.map { Placed(id: $0.id, level: $0.level) }
91 guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return }109 guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return }
92 let was = order[at].level110 let was = order[at].level
...@@ -110,7 +128,7 @@ enum Arranging {...@@ -110,7 +128,7 @@ enum Arranging {
110 page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1,128 page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1,
111 discard: String? = nil129 discard: String? = nil
112 ) {130 ) {
113 guard let library = notebook.handle else { return }131 guard let library = notebook.handle, let release = reserve() else { return }
114 let pointer = Int(bitPattern: library)132 let pointer = Int(bitPattern: library)
115 let author = Author.name ?? ""133 let author = Author.name ?? ""
116 let (date, time) = titleDate()134 let (date, time) = titleDate()
...@@ -130,6 +148,7 @@ enum Arranging {...@@ -130,6 +148,7 @@ enum Arranging {
130 time))148 time))
131 }) { moved in149 }) { moved in
132 reported = moved150 reported = moved
151 release()
133 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {152 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
134 section.listed()153 section.listed()
135 }154 }
...@@ -137,10 +156,104 @@ enum Arranging {...@@ -137,10 +156,104 @@ enum Arranging {
137 }156 }
138 }157 }
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
140 /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order`253 /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order`
141 /// names its entries once moved; a section shown opens again where it went.254 /// 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] = []) {255 static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = [], then: @escaping () -> Void = {}) {
143 guard let library = notebook.handle else { return }256 guard let library = notebook.handle, let release = reserve() else { return }
144 let home = parentFolder(of: path)257 let home = parentFolder(of: path)
145 let name = path.split(separator: "/").last.map(String.init) ?? path258 let name = path.split(separator: "/").last.map(String.init) ?? path
146 let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name259 let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name
...@@ -152,6 +265,7 @@ enum Arranging {...@@ -152,6 +265,7 @@ enum Arranging {
152 let paths = json(order)265 let paths = json(order)
153 later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in266 later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in
154 notebook.reload {267 notebook.reload {
268 release()
155 NotificationCenter.default.post(name: Notebook.listed, object: notebook)269 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
156 for (scene, shown) in released {270 for (scene, shown) in released {
157 let section = moved ? follow(shown.section, from: path, to: placed) : shown.section271 let section = moved ? follow(shown.section, from: path, to: placed) : shown.section
...@@ -159,7 +273,7 @@ enum Arranging {...@@ -159,7 +273,7 @@ enum Arranging {
159 scene.open(tab, of: notebook, page: shown.page)273 scene.open(tab, of: notebook, page: shown.page)
160 }274 }
161 }275 }
162 if !moved { failed("Can’t Move Section") }276 if moved { then() } else { failed("Can’t Move Section") }
163 }277 }
164 }278 }
165 }279 }
...@@ -171,6 +285,7 @@ enum Arranging {...@@ -171,6 +285,7 @@ enum Arranging {
171285
172 /// Puts `notebook` at `index` among the notebooks of its place in the list.286 /// Puts `notebook` at `index` among the notebooks of its place in the list.
173 static func move(_ notebook: Notebook, to index: Int) {287 static func move(_ notebook: Notebook, to index: Int) {
288 guard available() else { return }
174 guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else {289 guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else {
175 return290 return
176 }291 }
...@@ -181,8 +296,8 @@ enum Arranging {...@@ -181,8 +296,8 @@ enum Arranging {
181}296}
182297
183extension UIResponder {298extension UIResponder {
184 @objc func undoArranging() { Arranging.history.undo() }299 @objc func undoArranging() { if !Arranging.busy { Arranging.history.undo() } }
185 @objc func redoArranging() { Arranging.history.redo() }300 @objc func redoArranging() { if !Arranging.busy { Arranging.history.redo() } }
186}301}
187302
188extension Section {303extension Section {
...@@ -246,7 +361,9 @@ final class PageDrag {...@@ -246,7 +361,9 @@ final class PageDrag {
246private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) {361private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) {
247 let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)362 let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
248 for action in actions {363 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)
250 }367 }
251 sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))368 sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
252 sheet.popoverPresentationController?.sourceView = view369 sheet.popoverPresentationController?.sourceView = view
...@@ -266,7 +383,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -266,7 +383,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
266 }383 }
267384
268 override var canBecomeFirstResponder: Bool { true }385 override var canBecomeFirstResponder: Bool { true }
269 override var undoManager: UndoManager? { Arranging.history }386 override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil }
270 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }387 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
271388
272 /// Undo in the list takes back its moves.389 /// Undo in the list takes back its moves.
...@@ -300,7 +417,8 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -300,7 +417,8 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
300 return section.notebook.sections(besides: section.tab.path).map { tab in417 return section.notebook.sections(besides: section.tab.path).map { tab in
301 UIAction(418 UIAction(
302 title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group,419 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
304 ) { [weak self] _ in422 ) { [weak self] _ in
305 Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path)423 Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path)
306 self?.becomeFirstResponder()424 self?.becomeFirstResponder()
...@@ -315,11 +433,11 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -315,11 +433,11 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
315 var actions: [UIMenuElement] = [433 var actions: [UIMenuElement] = [
316 UIAction(434 UIAction(
317 title: "Make Subpage", image: UIImage(systemName: "increase.indent"),435 title: "Make Subpage", image: UIImage(systemName: "increase.indent"),
318 attributes: indentable(rows, at) ? [] : .disabled436 attributes: !Arranging.busy && indentable(rows, at) ? [] : .disabled
319 ) { [weak self] _ in self?.indent(id, by: 1) },437 ) { [weak self] _ in self?.indent(id, by: 1) },
320 UIAction(438 UIAction(
321 title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"),439 title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"),
322 attributes: rows[at].level > 1 ? [] : .disabled440 attributes: !Arranging.busy && rows[at].level > 1 ? [] : .disabled
323 ) { [weak self] _ in self?.indent(id, by: -1) },441 ) { [weak self] _ in self?.indent(id, by: -1) },
324 ]442 ]
325 let moves = moveActions(item)443 let moves = moveActions(item)
...@@ -331,7 +449,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -331,7 +449,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
331449
332 /// VoiceOver's ways to move a page without dragging it.450 /// VoiceOver's ways to move a page without dragging it.
333 func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? {451 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 })
335 else { return nil }453 else { return nil }
336 let id = item.row.id454 let id = item.row.id
337 var actions: [UIAccessibilityCustomAction] = []455 var actions: [UIAccessibilityCustomAction] = []
...@@ -357,7 +475,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -357,7 +475,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
357 _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath475 _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath
358 ) -> UISwipeActionsConfiguration? {476 ) -> UISwipeActionsConfiguration? {
359 // A swipe across the list changes section instead.477 // 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,
361 let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id })479 let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id })
362 else { return nil }480 else { return nil }
363 let id = rows[at].id481 let id = rows[at].id
...@@ -384,7 +502,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -384,7 +502,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
384 }502 }
385503
386 override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {504 override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
387 items[indexPath.row].version == nil505 !Arranging.busy && items[indexPath.row].version == nil
388 }506 }
389507
390 override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) {508 override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) {
...@@ -404,7 +522,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -404,7 +522,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
404 _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath522 _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
405 ) -> [UIDragItem] {523 ) -> [UIDragItem] {
406 let item = items[indexPath.row]524 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 [] }
408 let drag = UIDragItem(itemProvider: NSItemProvider())526 let drag = UIDragItem(itemProvider: NSItemProvider())
409 drag.localObject = PageDrag(section: section, id: item.row.id)527 drag.localObject = PageDrag(section: section, id: item.row.id)
410 return [drag]528 return [drag]
...@@ -431,7 +549,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -431,7 +549,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
431 _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath?549 _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath?
432 ) -> UITableViewDropProposal {550 ) -> UITableViewDropProposal {
433 let page = session.localDragSession?.items.first?.localObject as? PageDrag551 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 {
435 return UITableViewDropProposal(operation: .forbidden)553 return UITableViewDropProposal(operation: .forbidden)
436 }554 }
437 return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)555 return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
...@@ -457,7 +575,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -457,7 +575,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
457 }575 }
458 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.576 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.
459 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in577 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in
460 self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2578 !Arranging.busy && self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2
461 }579 }
462 // The data source is still applying the move when this runs.580 // The data source is still applying the move when this runs.
463 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in581 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
...@@ -466,7 +584,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -466,7 +584,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
466 }584 }
467585
468 override var canBecomeFirstResponder: Bool { true }586 override var canBecomeFirstResponder: Bool { true }
469 override var undoManager: UndoManager? { Arranging.history }587 override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil }
470 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }588 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
471589
472 /// Undo in the list takes back its moves.590 /// Undo in the list takes back its moves.
...@@ -512,7 +630,8 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -512,7 +630,8 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
512 notebook.destinations(of: path).map { folder in630 notebook.destinations(of: path).map { folder in
513 UIAction(631 UIAction(
514 title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "),632 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
516 ) { [weak self] _ in635 ) { [weak self] _ in
517 Arranging.place(path, of: notebook, in: folder)636 Arranging.place(path, of: notebook, in: folder)
518 self?.becomeFirstResponder()637 self?.becomeFirstResponder()
...@@ -528,12 +647,12 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -528,12 +647,12 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
528 guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] }647 guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] }
529 return [648 return [
530 UIMenu(options: .displayInline, children: [649 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) {
532 _ in Arranging.move(notebook, to: at - 1)651 _ in Arranging.move(notebook, to: at - 1)
533 },652 },
534 UIAction(653 UIAction(
535 title: "Move Down", image: UIImage(systemName: "arrow.down"),654 title: "Move Down", image: UIImage(systemName: "arrow.down"),
536 attributes: at < siblings.count - 1 ? [] : .disabled655 attributes: !Arranging.busy && at < siblings.count - 1 ? [] : .disabled
537 ) { _ in Arranging.move(notebook, to: at + 1) },656 ) { _ in Arranging.move(notebook, to: at + 1) },
538 ])657 ])
539 ]658 ]
...@@ -603,7 +722,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -603,7 +722,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
603 func collectionView(722 func collectionView(
604 _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath723 _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
605 ) -> [UIDragItem] {724 ) -> [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 [] }
607 let drag = UIDragItem(itemProvider: NSItemProvider())726 let drag = UIDragItem(itemProvider: NSItemProvider())
608 drag.localObject = item727 drag.localObject = item
609 return [drag]728 return [drag]
...@@ -640,6 +759,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -640,6 +759,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
640 _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession,759 _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession,
641 withDestinationIndexPath destinationIndexPath: IndexPath?760 withDestinationIndexPath destinationIndexPath: IndexPath?
642 ) -> UICollectionViewDropProposal {761 ) -> UICollectionViewDropProposal {
762 guard !Arranging.busy else { return UICollectionViewDropProposal(operation: .forbidden) }
643 let dragged = session.localDragSession?.items.first?.localObject763 let dragged = session.localDragSession?.items.first?.localObject
644 if let page = dragged as? PageDrag {764 if let page = dragged as? PageDrag {
645 guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) }765 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...@@ -376,7 +376,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
376 actions.append(UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: destinations.map { folder in376 actions.append(UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: destinations.map { folder in
377 UIAction(title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › ")) { [weak self] _ in377 UIAction(title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › ")) { [weak self] _ in
378 self?.setEditing(false, animated: true)378 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[...])
380 }384 }
381 }))385 }))
382 }386 }
...@@ -389,11 +393,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -389,11 +393,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
389393
390 private func delete(_ notebooks: [Notebook]) {394 private func delete(_ notebooks: [Notebook]) {
391 let alert = UIAlertController(395 let alert = UIAlertController(
392 title: "Delete \(notebooks.count) Notebooks?",396 title: notebooks.count == 1 ? "Delete Notebook?" : "Delete \(notebooks.count) Notebooks?",
393 message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert)397 message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert)
394 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))398 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
395 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in399 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 }
397 setEditing(false, animated: true)401 setEditing(false, animated: true)
398 collectionView.isUserInteractionEnabled = false402 collectionView.isUserInteractionEnabled = false
399 for notebook in notebooks {403 for notebook in notebooks {
...@@ -426,6 +430,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -426,6 +430,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
426 }430 }
427 return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed)431 return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed)
428 }) { [weak self] problem, failed in432 }) { [weak self] problem, failed in
433 release()
429 self?.collectionView.isUserInteractionEnabled = true434 self?.collectionView.isUserInteractionEnabled = true
430 for notebook in notebooks { notebook.pause() }435 for notebook in notebooks { notebook.pause() }
431 for notebook in failed { notebook.open { self?.reload() } }436 for notebook in failed { notebook.open { self?.reload() } }
...@@ -597,10 +602,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -597,10 +602,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
597 /// then runs `act`; refuses with why while an edit can't be published, as moving the602 /// then runs `act`; refuses with why while an edit can't be published, as moving the
598 /// folder then would strand it.603 /// folder then would strand it.
599 private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {604 private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {
605 guard let release = Arranging.reserve() else { return }
600 for scene in UIApplication.shared.connectedScenes {606 for scene in UIApplication.shared.connectedScenes {
601 (scene.delegate as? SceneDelegate)?.close(notebook)607 (scene.delegate as? SceneDelegate)?.close(notebook)
602 }608 }
603 guard let handle = notebook.handle else { return act() }609 guard let handle = notebook.handle else {
610 defer { release() }
611 return act()
612 }
604 let pointer = Int(bitPattern: handle)613 let pointer = Int(bitPattern: handle)
605 background({ () -> String? in614 background({ () -> String? in
606 var error: UnsafeMutablePointer<CChar>?615 var error: UnsafeMutablePointer<CChar>?
...@@ -608,6 +617,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -608,6 +617,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
608 let problem = take(error)617 let problem = take(error)
609 return closed ? nil : problem ?? ""618 return closed ? nil : problem ?? ""
610 }) { [weak self] problem in619 }) { [weak self] problem in
620 defer { release() }
611 if let problem {621 if let problem {
612 self?.alert(title, problem)622 self?.alert(title, problem)
613 return623 return
...@@ -716,10 +726,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -716,10 +726,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
716 notebook.open { self?.reload() }726 notebook.open { self?.reload() }
717 })727 })
718 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in728 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
719 do { try FileManager.default.removeItem(at: folder) } catch {729 self?.close(notebook, refusing: "Can’t Delete") {
720 self?.alert("Can’t Delete", error.localizedDescription)730 do { try FileManager.default.removeItem(at: folder) } catch {
731 self?.alert("Can’t Delete", error.localizedDescription)
732 }
733 self?.rescan()
721 }734 }
722 self?.rescan()
723 })735 })
724 present(alert, animated: true)736 present(alert, animated: true)
725 }737 }
...@@ -1115,27 +1127,25 @@ final class PagesViewController: UITableViewController {...@@ -1115,27 +1127,25 @@ final class PagesViewController: UITableViewController {
1115 section.delete(ids)1127 section.delete(ids)
1116 }1128 }
1117 if selection.contains(where: { $0.version != nil }) {1129 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)
1119 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))1131 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1120 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() })1132 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() })
1121 present(alert, animated: true)1133 present(alert, animated: true)
1122 } else { remove() }1134 } else { remove() }
1123 })1135 })
1124 delete.tintColor = .systemRed1136 delete.tintColor = .systemRed
1125 delete.isEnabled = !selection.isEmpty1137 delete.isEnabled = !Arranging.busy && !selection.isEmpty
1126 var destinations: [UIAction] = []1138 var destinations: [UIAction] = []
1127 if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) {1139 if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) {
1128 destinations = section.notebook.sections(besides: section.tab.path).map { tab in1140 destinations = section.notebook.sections(besides: section.tab.path).map { tab in
1129 UIAction(title: tab.name) { [weak self] _ in1141 UIAction(title: tab.name) { [weak self] _ in
1130 self?.setEditing(false, animated: true)1142 self?.setEditing(false, animated: true)
1131 for item in selection {1143 Arranging.move(pages: selection.map(\.id), from: section, to: tab)
1132 Arranging.move(page: item.id, of: section.notebook, from: section.tab.path, to: tab.path)
1133 }
1134 }1144 }
1135 }1145 }
1136 }1146 }
1137 let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations))1147 let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations))
1138 move.isEnabled = !destinations.isEmpty1148 move.isEnabled = !Arranging.busy && !destinations.isEmpty
1139 toolbarItems = [move, .flexibleSpace(), delete]1149 toolbarItems = [move, .flexibleSpace(), delete]
1140 navigationController?.setToolbarHidden(false, animated: false)1150 navigationController?.setToolbarHidden(false, animated: false)
1141 }1151 }
apps/ios/Snowbound/Notebooks.swift+2
...@@ -724,6 +724,7 @@ final class Section {...@@ -724,6 +724,7 @@ final class Section {
724 }724 }
725725
726 func delete(_ ids: [String], done: @escaping () -> Void = {}) {726 func delete(_ ids: [String], done: @escaping () -> Void = {}) {
727 guard let release = Arranging.reserve() else { return }
727 let (date, time) = titleDate()728 let (date, time) = titleDate()
728 let pointer = Int(bitPattern: handle)729 let pointer = Int(bitPattern: handle)
729 background({730 background({
...@@ -732,6 +733,7 @@ final class Section {...@@ -732,6 +733,7 @@ final class Section {
732 }733 }
733 return true734 return true
734 }) { [self] deleted in735 }) { [self] deleted in
736 release()
735 reloadRows()737 reloadRows()
736 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])738 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
737 if deleted { done() }739 if deleted { done() }