diff --git a/apps/ios/Snowbound/Arranging.swift b/apps/ios/Snowbound/Arranging.swift index 9737172fa2b803f04206b6593c95da85721f3a4a..a55f601d9f1b29ea976a53d4ec7deb2843e6966b 100644 --- a/apps/ios/Snowbound/Arranging.swift +++ b/apps/ios/Snowbound/Arranging.swift @@ -29,6 +29,7 @@ private func json(_ value: some Encodable) -> String { /// either list. enum Arranging { static let history = UndoManager() + private(set) static var busy = false private static let owner = NSObject() /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own. @@ -55,11 +56,26 @@ enum Arranging { } private static let queue = DispatchQueue(label: "Arranging") - private static func failed(_ title: String) { + private static func failed(_ title: String, _ message: String = "Check that the notebook can be reached, then try again.") { let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first var top = window?.rootViewController while let presented = top?.presentedViewController { top = presented } - top?.alert(title, "Check that the notebook can be reached, then try again.") + top?.alert(title, message) + } + + private static func available() -> Bool { + guard !busy else { + failed("Notebook Is Busy", "Wait for the current notebook change to finish, then try again.") + return false + } + return true + } + + /// Excludes queued moves and filesystem mutations through their asynchronous outcomes. + static func reserve() -> (() -> Void)? { + guard available() else { return nil } + busy = true + return { busy = false } } /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under @@ -76,6 +92,7 @@ enum Arranging { /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit. static func arrange(_ section: Section, _ order: [Placed], moving id: String) { + guard available() else { return } let before = section.rows.map { Placed(id: $0.id, level: $0.level) } guard order != before, section.arrange(order, moving: id) else { return } let (notebook, path) = (section.notebook, section.tab.path) @@ -87,6 +104,7 @@ enum Arranging { /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote /// Subpage do. static func indent(_ section: Section, _ id: String, to level: Int) { + guard available() else { return } var order = section.rows.map { Placed(id: $0.id, level: $0.level) } guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return } let was = order[at].level @@ -110,7 +128,7 @@ enum Arranging { page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1, discard: String? = nil ) { - guard let library = notebook.handle else { return } + guard let library = notebook.handle, let release = reserve() else { return } let pointer = Int(bitPattern: library) let author = Author.name ?? "" let (date, time) = titleDate() @@ -130,6 +148,7 @@ enum Arranging { time)) }) { moved in reported = moved + release() for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) { section.listed() } @@ -137,10 +156,104 @@ enum Arranging { } } + /// Moves only the selected pages, retaining their ancestry among the selection. + static func move(pages ids: [String], from source: Section, to tab: Tab) { + guard let release = reserve() else { return } + Section.open(tab, of: source.notebook) { target, _ in + guard let target else { + release() + return failed("Can’t Move Pages") + } + var ancestors: [Int] = [] + var selected: [Placed] = [] + for row in source.rows { + ancestors.removeAll { $0 >= row.level } + guard ids.contains(row.id) else { continue } + selected.append(Placed(id: row.id, level: ancestors.count + 1)) + ancestors.append(row.level) + } + let order = target.rows.map { Placed(id: $0.id, level: $0.level) } + selected + // Hand off the reservation without yielding the main thread. + release() + transfer(selected.map { (page: $0.id, discard: nil) }, + from: source, to: target, order: order) + } + } + + private static func transfer( + _ pages: [(page: String, discard: String?)], from source: Section, to target: Section, order: [Placed], + repairs: [(id: String, expected: Int, restore: Int)] = [] + ) { + guard !pages.isEmpty, let library = source.notebook.handle, let release = reserve() else { return } + let before = source.rows.map { Placed(id: $0.id, level: $0.level) } + let pointer = Int(bitPattern: library) + let sourcePointer = Int(bitPattern: source.handle) + let targetPointer = Int(bitPattern: target.handle) + let author = Author.name ?? "" + let (date, time) = titleDate() + var undo: () -> Void = {} + let undoing = history.isUndoing + let reciprocal = undoing || history.isRedoing + if reciprocal { done("Move Pages") { undo() } } + later({ () -> ([MovedPage], [Placed], [(id: String, expected: Int, restore: Int)], Bool) in + var reported: [MovedPage] = [] + var arrived: [String: String] = [:] + var promotions: [(id: String, expected: Int, restore: Int)] = [] + // Removing a parent can promote its first remaining child. + for page in pages.reversed() { + guard let listed = decode([Row].self, sb_section_pages(OpaquePointer(bitPattern: sourcePointer))) else { break } + let next = listed.first?.id == page.page ? listed.dropFirst().first : nil + guard let moved = decode(MovedPage.self, sb_library_move_page( + OpaquePointer(bitPattern: pointer), source.tab.path, page.page, target.tab.path, + nil, 1, page.discard, author, date, time)) else { break } + reported.append(moved) + arrived[page.page] = moved.page + if let next, next.level > 1, moved.before == next.id { + promotions.removeAll { $0.id == next.id } + promotions.append((id: next.id, expected: 1, restore: next.level)) + } + } + promotions.removeAll { arrived[$0.id] != nil } + let restore = before.map { Placed(id: arrived[$0.id] ?? $0.id, level: $0.level) } + guard !reported.isEmpty else { return (reported, restore, promotions, false) } + let placed = order.map { Placed(id: arrived[$0.id] ?? $0.id, level: $0.level) } + guard sb_section_arrange(OpaquePointer(bitPattern: targetPointer), json(placed), json(Array(arrived.values))) else { + return (reported, restore, promotions, false) + } + if !repairs.isEmpty { + guard let present = decode([Row].self, sb_section_pages(OpaquePointer(bitPattern: targetPointer))) else { + return (reported, restore, promotions, false) + } + let applicable = repairs.filter { repair in present.contains { $0.id == repair.id && $0.level == repair.expected } } + let order = present.map { row in Placed(id: row.id, level: applicable.first { $0.id == row.id }?.restore ?? row.level) } + if !applicable.isEmpty && !sb_section_arrange(OpaquePointer(bitPattern: targetPointer), json(order), json(applicable.map(\.id))) { + return (reported, restore, promotions, false) + } + } + return (reported, restore, promotions, reported.count == pages.count) + }) { reported, restore, promotions, succeeded in + undo = { transfer(reported.map { (page: $0.page, discard: $0.added) }, from: target, to: source, order: restore, repairs: promotions) } + if reciprocal && reported.isEmpty { + // A rejected Undo or Redo must leave its original action retryable. + undo = { done("Move Pages") { transfer(pages, from: source, to: target, order: order, repairs: repairs) } } + if undoing { history.redo() } else { history.undo() } + } + if !reciprocal && !reported.isEmpty { done("Move Pages") { undo() } } + release() + source.listed() + target.listed() + if !succeeded { + failed("Can’t Move All Pages", reported.isEmpty + ? "Check both sections, then try moving the remaining pages again." + : "Check both sections. Use \(undoing ? "Redo" : "Undo") to reverse completed moves, or try moving the remaining pages again.") + } + } + } + /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order` /// names its entries once moved; a section shown opens again where it went. - static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = []) { - guard let library = notebook.handle else { return } + static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = [], then: @escaping () -> Void = {}) { + guard let library = notebook.handle, let release = reserve() else { return } let home = parentFolder(of: path) let name = path.split(separator: "/").last.map(String.init) ?? path let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name @@ -152,6 +265,7 @@ enum Arranging { let paths = json(order) later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in notebook.reload { + release() NotificationCenter.default.post(name: Notebook.listed, object: notebook) for (scene, shown) in released { let section = moved ? follow(shown.section, from: path, to: placed) : shown.section @@ -159,7 +273,7 @@ enum Arranging { scene.open(tab, of: notebook, page: shown.page) } } - if !moved { failed("Can’t Move Section") } + if moved { then() } else { failed("Can’t Move Section") } } } } @@ -171,6 +285,7 @@ enum Arranging { /// Puts `notebook` at `index` among the notebooks of its place in the list. static func move(_ notebook: Notebook, to index: Int) { + guard available() else { return } guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else { return } @@ -181,8 +296,8 @@ enum Arranging { } extension UIResponder { - @objc func undoArranging() { Arranging.history.undo() } - @objc func redoArranging() { Arranging.history.redo() } + @objc func undoArranging() { if !Arranging.busy { Arranging.history.undo() } } + @objc func redoArranging() { if !Arranging.busy { Arranging.history.redo() } } } extension Section { @@ -246,7 +361,9 @@ final class PageDrag { private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) { let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) for action in actions { - sheet.addAction(UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) }) + let item = UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) } + item.isEnabled = !action.attributes.contains(.disabled) + sheet.addAction(item) } sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) sheet.popoverPresentationController?.sourceView = view @@ -266,7 +383,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate } override var canBecomeFirstResponder: Bool { true } - override var undoManager: UndoManager? { Arranging.history } + override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil } override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands } /// Undo in the list takes back its moves. @@ -300,7 +417,8 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate return section.notebook.sections(besides: section.tab.path).map { tab in UIAction( title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group, - image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal) + image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal), + attributes: !Arranging.busy ? [] : .disabled ) { [weak self] _ in Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path) self?.becomeFirstResponder() @@ -315,11 +433,11 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate var actions: [UIMenuElement] = [ UIAction( title: "Make Subpage", image: UIImage(systemName: "increase.indent"), - attributes: indentable(rows, at) ? [] : .disabled + attributes: !Arranging.busy && indentable(rows, at) ? [] : .disabled ) { [weak self] _ in self?.indent(id, by: 1) }, UIAction( title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"), - attributes: rows[at].level > 1 ? [] : .disabled + attributes: !Arranging.busy && rows[at].level > 1 ? [] : .disabled ) { [weak self] _ in self?.indent(id, by: -1) }, ] let moves = moveActions(item) @@ -331,7 +449,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate /// VoiceOver's ways to move a page without dragging it. func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? { - guard item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) + guard !Arranging.busy, item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) else { return nil } let id = item.row.id var actions: [UIAccessibilityCustomAction] = [] @@ -357,7 +475,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { // A swipe across the list changes section instead. - guard !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil, + guard !Arranging.busy, !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil, let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id }) else { return nil } let id = rows[at].id @@ -384,7 +502,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate } override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - items[indexPath.row].version == nil + !Arranging.busy && items[indexPath.row].version == nil } override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) { @@ -404,7 +522,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath ) -> [UIDragItem] { let item = items[indexPath.row] - guard let section, item.version == nil else { return [] } + guard !Arranging.busy, let section, item.version == nil else { return [] } let drag = UIDragItem(itemProvider: NSItemProvider()) drag.localObject = PageDrag(section: section, id: item.row.id) return [drag] @@ -431,7 +549,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath? ) -> UITableViewDropProposal { let page = session.localDragSession?.items.first?.localObject as? PageDrag - guard let page, page.section === section else { + guard !Arranging.busy, let page, page.section === section else { return UITableViewDropProposal(operation: .forbidden) } return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) @@ -457,7 +575,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie } // Rows dragged between rows, and by Edit's reorder handles, move through the data source. dataSource.reorderingHandlers.canReorderItem = { [weak self] item in - self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2 + !Arranging.busy && self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2 } // The data source is still applying the move when this runs. dataSource.reorderingHandlers.didReorder = { [weak self] transaction in @@ -466,7 +584,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie } override var canBecomeFirstResponder: Bool { true } - override var undoManager: UndoManager? { Arranging.history } + override var undoManager: UndoManager? { !Arranging.busy ? Arranging.history : nil } override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands } /// Undo in the list takes back its moves. @@ -512,7 +630,8 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie notebook.destinations(of: path).map { folder in UIAction( title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "), - image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder") + image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder"), + attributes: !Arranging.busy ? [] : .disabled ) { [weak self] _ in Arranging.place(path, of: notebook, in: folder) self?.becomeFirstResponder() @@ -528,12 +647,12 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] } return [ UIMenu(options: .displayInline, children: [ - UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: at > 0 ? [] : .disabled) { + UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: !Arranging.busy && at > 0 ? [] : .disabled) { _ in Arranging.move(notebook, to: at - 1) }, UIAction( title: "Move Down", image: UIImage(systemName: "arrow.down"), - attributes: at < siblings.count - 1 ? [] : .disabled + attributes: !Arranging.busy && at < siblings.count - 1 ? [] : .disabled ) { _ in Arranging.move(notebook, to: at + 1) }, ]) ] @@ -603,7 +722,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie func collectionView( _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath ) -> [UIDragItem] { - guard let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] } + guard !Arranging.busy, let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] } let drag = UIDragItem(itemProvider: NSItemProvider()) drag.localObject = item return [drag] @@ -640,6 +759,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath? ) -> UICollectionViewDropProposal { + guard !Arranging.busy else { return UICollectionViewDropProposal(operation: .forbidden) } let dragged = session.localDragSession?.items.first?.localObject if let page = dragged as? PageDrag { guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) } diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 8fbc3d7569139fbe76ba2452e1f1bcf285bc00f6..0a1e7d1a44d0864bb1b153a5ae549034d5fa24c3 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -376,7 +376,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke actions.append(UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: destinations.map { folder in UIAction(title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › ")) { [weak self] _ in self?.setEditing(false, animated: true) - for path in paths { Arranging.place(path, of: notebook, in: folder) } + func move(_ remaining: ArraySlice) { + guard let path = remaining.first else { return } + Arranging.place(path, of: notebook, in: folder) { move(remaining.dropFirst()) } + } + move(paths[...]) } })) } @@ -389,11 +393,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke private func delete(_ notebooks: [Notebook]) { let alert = UIAlertController( - title: "Delete \(notebooks.count) Notebooks?", + title: notebooks.count == 1 ? "Delete Notebook?" : "Delete \(notebooks.count) Notebooks?", message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in - guard let self else { return } + guard let self, let release = Arranging.reserve() else { return } setEditing(false, animated: true) collectionView.isUserInteractionEnabled = false for notebook in notebooks { @@ -426,6 +430,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed) }) { [weak self] problem, failed in + release() self?.collectionView.isUserInteractionEnabled = true for notebook in notebooks { notebook.pause() } for notebook in failed { notebook.open { self?.reload() } } @@ -597,10 +602,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke /// then runs `act`; refuses with why while an edit can't be published, as moving the /// folder then would strand it. private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) { + guard let release = Arranging.reserve() else { return } for scene in UIApplication.shared.connectedScenes { (scene.delegate as? SceneDelegate)?.close(notebook) } - guard let handle = notebook.handle else { return act() } + guard let handle = notebook.handle else { + defer { release() } + return act() + } let pointer = Int(bitPattern: handle) background({ () -> String? in var error: UnsafeMutablePointer? @@ -608,6 +617,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke let problem = take(error) return closed ? nil : problem ?? "" }) { [weak self] problem in + defer { release() } if let problem { self?.alert(title, problem) return @@ -716,10 +726,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke notebook.open { self?.reload() } }) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in - do { try FileManager.default.removeItem(at: folder) } catch { - self?.alert("Can’t Delete", error.localizedDescription) + self?.close(notebook, refusing: "Can’t Delete") { + do { try FileManager.default.removeItem(at: folder) } catch { + self?.alert("Can’t Delete", error.localizedDescription) + } + self?.rescan() } - self?.rescan() }) present(alert, animated: true) } @@ -1115,27 +1127,25 @@ final class PagesViewController: UITableViewController { section.delete(ids) } if selection.contains(where: { $0.version != nil }) { - let alert = UIAlertController(title: "Delete \(selection.count) Pages?", message: "Conflict pages will be deleted permanently.", preferredStyle: .alert) + let alert = UIAlertController(title: selection.count == 1 ? "Delete Page?" : "Delete \(selection.count) Pages?", message: "Conflict pages will be deleted permanently.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() }) present(alert, animated: true) } else { remove() } }) delete.tintColor = .systemRed - delete.isEnabled = !selection.isEmpty + delete.isEnabled = !Arranging.busy && !selection.isEmpty var destinations: [UIAction] = [] if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) { destinations = section.notebook.sections(besides: section.tab.path).map { tab in UIAction(title: tab.name) { [weak self] _ in self?.setEditing(false, animated: true) - for item in selection { - Arranging.move(page: item.id, of: section.notebook, from: section.tab.path, to: tab.path) - } + Arranging.move(pages: selection.map(\.id), from: section, to: tab) } } } let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations)) - move.isEnabled = !destinations.isEmpty + move.isEnabled = !Arranging.busy && !destinations.isEmpty toolbarItems = [move, .flexibleSpace(), delete] navigationController?.setToolbarHidden(false, animated: false) } diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index e143d7bb860ef5111177138ad16cef89c8f68006..b3241164c40a78ca6e1a5d1b6f4663c2ff350503 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -724,6 +724,7 @@ final class Section { } func delete(_ ids: [String], done: @escaping () -> Void = {}) { + guard let release = Arranging.reserve() else { return } let (date, time) = titleDate() let pointer = Int(bitPattern: handle) background({ @@ -732,6 +733,7 @@ final class Section { } return true }) { [self] deleted in + release() reloadRows() NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) if deleted { done() }