authorclover caruso <git@paperclover.net> 2026-10-11 14:11:17-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 16:44:36-07:00
log706bb3a78129719645128ee615252d6e4c879ec3
treefb2fe9cf09651077b6b1f86dc289bd113672f5e9
parentf48a5c1bf572e5731cc18ea300c7ef7611a7733d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(ios): select multiple list items without leaving the page list

Edit selects rows for applicable bulk actions while preserving reorder handles. Pages delete sequentially or move within their notebook; notebook selections share, close, delete, or move compatible entries. Notebook deletion confirms once and publishes every selected notebook before removing any folder, with per-target failure reporting. Replacing a deleted page inherits the retained detail controller visibility, so deletion from the page list does not navigate into its replacement. Refs: #125, #126 Assisted-by: gpt-6.1-sol

5 files changed, 259 insertions(+), 42 deletions(-)

apps/ios/Snowbound/App.swift+8-7
......@@ -168,7 +168,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
168168 private let split = RootViewController(style: .tripleColumn)
169169 private let notebooks = NotebooksViewController()
170170 private let pages = PagesViewController()
171 private var showingPage = false
172171 /// A first launch opened a page by itself.
173172 private var landed = false
174173 private var background = UIBackgroundTaskIdentifier.invalid
......@@ -288,12 +287,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
288287 controller.present(navigation, animated: true)
289288 }
290289
291 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {
292 showingPage = true
290 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false, revealPage: Bool = true) {
293291 let controller = PageViewController(section: section, page: page, reveal: reveal, titleFocus: titleFocus)
294 controller.onOpen = { [weak self] id in self?.show(section: section, page: id) }
292 controller.onOpen = { [weak self, weak controller] id in
293 guard let controller else { return }
294 self?.show(section: section, page: id, revealPage: controller.viewIfLoaded?.window != nil)
295 }
295296 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
296 split.show(.secondary)
297 if revealPage { split.show(.secondary) }
297298 pages.select(page)
298299 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
299300 }
......@@ -302,7 +303,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
302303 func close(_ notebook: Notebook) {
303304 guard pages.section?.notebook === notebook else { return }
304305 pages.clear()
305 showingPage = false
306306 split.setViewController(UIViewController(), for: .secondary)
307307 split.show(.primary)
308308 }
......@@ -335,7 +335,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
335335 func splitViewController(
336336 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
337337 ) -> UISplitViewController.Column {
338 showingPage ? proposedTopColumn : .primary
338 let detail = (svc.viewController(for: .secondary) as? UINavigationController)?.topViewController
339 return detail is PageViewController ? proposedTopColumn : .primary
339340 }
340341
341342 /// Composition ends and every edit is stored and published while the system allows.
apps/ios/Snowbound/Arranging.swift+8-14
......@@ -261,6 +261,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
261261 tableView.dragDelegate = self
262262 tableView.dropDelegate = self
263263 tableView.dragInteractionEnabled = true
264 tableView.allowsMultipleSelectionDuringEditing = true
264265 navigationItem.rightBarButtonItem = editButtonItem
265266 }
266267
......@@ -395,12 +396,6 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
395396 DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) }
396397 }
397398
398 override func tableView(
399 _ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath
400 ) -> UITableViewCell.EditingStyle {
401 tableView.isEditing ? .none : .delete
402 }
403
404399 override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
405400 false
406401 }
......@@ -453,6 +448,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
453448 collectionView.dragDelegate = self
454449 collectionView.dropDelegate = self
455450 collectionView.dragInteractionEnabled = true
451 collectionView.allowsMultipleSelectionDuringEditing = true
456452 // A sidebar's trailing edge has no room left for it.
457453 if UIDevice.current.userInterfaceIdiom == .pad {
458454 navigationItem.leftBarButtonItem = editButtonItem
......@@ -460,7 +456,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
460456 navigationItem.rightBarButtonItems?.append(editButtonItem)
461457 }
462458 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.
463 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in self?.movable(item) == true }
459 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in
460 self?.movable(item) == true && (self?.collectionView.indexPathsForSelectedItems?.count ?? 0) < 2
461 }
464462 // The data source is still applying the move when this runs.
465463 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
466464 DispatchQueue.main.async { self?.reordered(transaction) }
......@@ -477,12 +475,6 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
477475 becomeFirstResponder()
478476 }
479477
480 override func setEditing(_ editing: Bool, animated: Bool) {
481 super.setEditing(editing, animated: animated)
482 collectionView.isEditing = editing
483 collectionView.dragInteractionEnabled = !editing
484 }
485
486478 private func movable(_ item: Item) -> Bool {
487479 switch item {
488480 case .notebook, .group, .section: true
......@@ -553,7 +545,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
553545
554546 /// Adds Edit's reorder handle and VoiceOver's ways to move to a row.
555547 func arranging(_ cell: UICollectionViewListCell, _ item: Item) {
556 if movable(item) { cell.accessories.append(.reorder(displayed: .whenEditing)) }
548 if movable(item) {
549 cell.accessories += [.multiselect(), .reorder(displayed: .whenEditing)]
550 }
557551 var actions: [UIAccessibilityCustomAction] = []
558552 let add = { (name: String, act: @escaping () -> Void) in
559553 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })
apps/ios/Snowbound/Lists.swift+229-16
......@@ -47,7 +47,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
4747 var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)
4848 list.headerMode = .firstItemInSection
4949 list.trailingSwipeActionsConfigurationProvider = { indexPath in
50 guard let self, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
50 guard let self, !self.isEditing, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
5151 if case .notebook(let id) = item, let notebook = self.notebook(id) {
5252 return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))
5353 }
......@@ -141,14 +141,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
141141 ]
142142 navigationItem.searchController = SearchViewController.controller()
143143 setUpArranging()
144 let corner = Prototype.quickNote ? [QuickNote.item()] : []
145144 if #available(iOS 26, *) {
146145 // Search sits in the bottom toolbar, as Notes and Files put it.
147146 navigationItem.preferredSearchBarPlacement = .integrated
148 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner)
149 } else {
150 toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner
151147 }
148 updateSelectionActions()
152149 for name in [Sync.changed, Section.changed] {
153150 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil)
154151 }
......@@ -172,6 +169,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
172169
173170 func reload(animated: Bool = false) {
174171 guard isViewLoaded else { return }
172 let selection = collectionView.indexPathsForSelectedItems?.compactMap { dataSource.itemIdentifier(for: $0) } ?? []
175173 if Prototype.welcome {
176174 let welcome = Prototype.welcome(
177175 new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) },
......@@ -230,6 +228,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
230228 dataSource.apply(list, to: location, animatingDifferences: animated)
231229 }
232230 showSelection()
231 if isEditing {
232 for item in selection {
233 if let index = dataSource.indexPath(for: item) {
234 collectionView.selectItem(at: index, animated: false, scrollPosition: [])
235 }
236 }
237 updateSelectionActions()
238 }
233239 refreshSync()
234240 }
235241
......@@ -294,10 +300,142 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
294300
295301 /// Marks the open section, as the sidebar of a wide window keeps it.
296302 private func showSelection() {
297 guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return }
303 guard !isEditing, let selected, let indexPath = dataSource.indexPath(for: selected) else { return }
298304 collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
299305 }
300306
307 override func setEditing(_ editing: Bool, animated: Bool) {
308 super.setEditing(editing, animated: animated)
309 for index in collectionView.indexPathsForSelectedItems ?? [] {
310 collectionView.deselectItem(at: index, animated: false)
311 }
312 collectionView.isEditing = editing
313 collectionView.dragInteractionEnabled = !editing
314 if !editing { showSelection() }
315 updateSelectionActions()
316 }
317
318 private func updateSelectionActions() {
319 guard isEditing else {
320 let corner = Prototype.quickNote ? [QuickNote.item()] : []
321 if #available(iOS 26, *) {
322 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner)
323 } else {
324 toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner
325 }
326 navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false)
327 return
328 }
329 let selection = (collectionView.indexPathsForSelectedItems ?? []).sorted().compactMap {
330 dataSource.itemIdentifier(for: $0)
331 }
332 var actions: [UIMenuElement] = []
333 let notebooks = selection.compactMap { item -> Notebook? in
334 guard case .notebook(let id) = item else { return nil }
335 return notebook(id)
336 }
337 if !selection.isEmpty, notebooks.count == selection.count {
338 let folders = notebooks.compactMap { folder(of: $0) }
339 if folders.count == notebooks.count {
340 actions.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in
341 guard let self else { return }
342 let sheet = UIActivityViewController(activityItems: folders, applicationActivities: nil)
343 sheet.popoverPresentationController?.barButtonItem = toolbarItems?.last
344 present(sheet, animated: true)
345 })
346 let deletable = notebooks.allSatisfy { $0.handle != nil }
347 actions.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: deletable ? .destructive : [.destructive, .disabled]) { [weak self] _ in
348 self?.delete(notebooks)
349 })
350 } else if notebooks.allSatisfy({ notebook in Notebooks.elsewhere.contains { $0 === notebook } }) {
351 actions.append(UIAction(title: "Close", image: UIImage(systemName: "xmark.circle")) { [weak self] _ in
352 for notebook in notebooks {
353 for scene in UIApplication.shared.connectedScenes {
354 (scene.delegate as? SceneDelegate)?.close(notebook)
355 }
356 Notebooks.remove(notebook)
357 }
358 self?.reload()
359 })
360 }
361 }
362 let entries = selection.compactMap { item -> (Notebook, String)? in
363 switch item {
364 case .section(let id, let path), .group(let id, let path): return notebook(id).map { ($0, path) }
365 default: return nil
366 }
367 }
368 if let notebook = entries.first?.0, entries.count == selection.count,
369 entries.allSatisfy({ $0.0 === notebook })
370 {
371 let paths = entries.map(\.1).filter { path in !entries.contains { path.hasPrefix($0.1 + "/") } }
372 let destinations = notebook.destinations(of: paths[0]).filter { destination in
373 paths.allSatisfy { notebook.destinations(of: $0).contains(destination) }
374 }
375 if !destinations.isEmpty {
376 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] _ in
378 self?.setEditing(false, animated: true)
379 for path in paths { Arranging.place(path, of: notebook, in: folder) }
380 }
381 }))
382 }
383 }
384 let action = UIBarButtonItem(title: "Actions", image: UIImage(systemName: "ellipsis.circle"), menu: UIMenu(children: actions))
385 action.isEnabled = !actions.isEmpty
386 toolbarItems = [.flexibleSpace(), action]
387 navigationController?.setToolbarHidden(false, animated: false)
388 }
389
390 private func delete(_ notebooks: [Notebook]) {
391 let alert = UIAlertController(
392 title: "Delete \(notebooks.count) Notebooks?",
393 message: "Notebooks that can’t be moved to Recently Deleted will be deleted permanently.", preferredStyle: .alert)
394 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
395 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
396 guard let self else { return }
397 setEditing(false, animated: true)
398 collectionView.isUserInteractionEnabled = false
399 for notebook in notebooks {
400 for scene in UIApplication.shared.connectedScenes {
401 (scene.delegate as? SceneDelegate)?.close(notebook)
402 }
403 }
404 background({ () -> (String?, [Notebook]) in
405 // No folder is removed until every notebook has published its edits.
406 for notebook in notebooks {
407 guard let handle = notebook.handle else {
408 return ("\(notebook.name): Open this notebook before deleting it.", notebooks)
409 }
410 var error: UnsafeMutablePointer<CChar>?
411 let closed = sb_library_close(handle, 10, &error)
412 let problem = take(error)
413 if !closed { return ("\(notebook.name): \(problem ?? "Some changes haven’t been saved yet.")", notebooks) }
414 }
415 var failed: [Notebook] = []
416 var problems: [String] = []
417 for notebook in notebooks {
418 guard let folder = self.folder(of: notebook) else { continue }
419 do {
420 do { try FileManager.default.trashItem(at: folder, resultingItemURL: nil) }
421 catch { try FileManager.default.removeItem(at: folder) }
422 } catch {
423 failed.append(notebook)
424 problems.append("\(notebook.name): \(error.localizedDescription)")
425 }
426 }
427 return (problems.isEmpty ? nil : problems.joined(separator: "\n\n"), failed)
428 }) { [weak self] problem, failed in
429 self?.collectionView.isUserInteractionEnabled = true
430 for notebook in notebooks { notebook.pause() }
431 for notebook in failed { notebook.open { self?.reload() } }
432 self?.rescan()
433 if let problem { self?.alert("Can’t Delete", problem) }
434 }
435 })
436 present(alert, animated: true)
437 }
438
301439 private func configure(_ cell: UICollectionViewListCell, _ item: Item) {
302440 var content = cell.defaultContentConfiguration()
303441 var accessories: [UICellAccessory] = []
......@@ -589,7 +727,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
589727 override func collectionView(
590728 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
591729 ) -> UIContextMenuConfiguration? {
592 guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil }
730 guard !isEditing, indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil }
593731 guard case .notebook(let id) = item, let notebook = notebook(id) else {
594732 let moves = arrangeMenu(item)
595733 return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) })
......@@ -601,6 +739,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
601739
602740 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
603741 guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
742 if isEditing {
743 switch item {
744 case .notebook, .group, .section: return true
745 default: return false
746 }
747 }
604748 switch item {
605749 case .section(let id, let path):
606750 let tab = notebook(id)?.tabs.first(where: { $0.path == path })
......@@ -631,6 +775,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
631775 }
632776
633777 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
778 if isEditing { return updateSelectionActions() }
634779 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
635780 let tab = notebook.tabs.first(where: { $0.path == path })
636781 else { return }
......@@ -638,6 +783,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
638783 onOpen?(tab, notebook)
639784 }
640785
786 override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
787 if isEditing { updateSelectionActions() }
788 }
789
641790 /// Asks for a protected section's password, as OneNote's Protected Section dialog does,
642791 /// and opens it once unlocked.
643792 private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) {
......@@ -846,15 +995,10 @@ final class PagesViewController: UITableViewController {
846995 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
847996 navigationItem.searchController = search
848997 navigationItem.hidesSearchBarWhenScrolling = false
849 let add = UIBarButtonItem(
850 title: "New Page", image: UIImage(systemName: "plus"),
851 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
852998 if #available(iOS 26, *) {
853999 navigationItem.preferredSearchBarPlacement = .integrated
854 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add]
855 } else {
856 toolbarItems = [.flexibleSpace(), add]
8571000 }
1001 updateSelectionActions()
8581002 NotificationCenter.default.addObserver(
8591003 self, selector: #selector(changed), name: Section.changed, object: nil)
8601004 setUpArranging()
......@@ -885,6 +1029,7 @@ final class PagesViewController: UITableViewController {
8851029 }
8861030
8871031 func loading(_ tab: Tab) {
1032 if isEditing { setEditing(false, animated: false) }
8881033 section = nil
8891034 items = []
8901035 title = tab.name
......@@ -901,6 +1046,7 @@ final class PagesViewController: UITableViewController {
9011046
9021047 /// Lets go of the section shown, as when its notebook goes.
9031048 func clear() {
1049 if isEditing { setEditing(false, animated: false) }
9041050 section = nil
9051051 items = []
9061052 title = nil
......@@ -929,6 +1075,7 @@ final class PagesViewController: UITableViewController {
9291075
9301076 func select(_ id: String) {
9311077 selected = id
1078 guard !isEditing else { return }
9321079 if let index = items.firstIndex(where: { $0.id == id }) {
9331080 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
9341081 }
......@@ -939,12 +1086,72 @@ final class PagesViewController: UITableViewController {
9391086 DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() }
9401087 }
9411088
1089 override func setEditing(_ editing: Bool, animated: Bool) {
1090 super.setEditing(editing, animated: animated)
1091 for index in tableView.indexPathsForSelectedRows ?? [] { tableView.deselectRow(at: index, animated: false) }
1092 tableView.dragInteractionEnabled = !editing
1093 if !editing, let selected { select(selected) }
1094 updateSelectionActions()
1095 }
1096
1097 private func updateSelectionActions() {
1098 guard isEditing else {
1099 let add = UIBarButtonItem(title: "New Page", image: UIImage(systemName: "plus"), primaryAction: UIAction { [weak self] _ in
1100 self?.newPage(under: nil)
1101 })
1102 if #available(iOS 26, *) {
1103 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add]
1104 } else { toolbarItems = [.flexibleSpace(), add] }
1105 return
1106 }
1107 let selection = (tableView.indexPathsForSelectedRows ?? []).sorted().map { items[$0.row] }
1108 let delete = UIBarButtonItem(title: "Delete", image: UIImage(systemName: "trash"), primaryAction: UIAction { [weak self] _ in
1109 guard let self, let section else { return }
1110 let remove = { [weak self] in
1111 self?.setEditing(false, animated: true)
1112 let ids = selection.filter { item in
1113 item.version == nil || !selection.contains { $0.version == nil && $0.id == item.row.id }
1114 }.map(\.id)
1115 section.delete(ids)
1116 }
1117 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)
1119 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
1120 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { _ in remove() })
1121 present(alert, animated: true)
1122 } else { remove() }
1123 })
1124 delete.tintColor = .systemRed
1125 delete.isEnabled = !selection.isEmpty
1126 var destinations: [UIAction] = []
1127 if let section, !selection.isEmpty, selection.allSatisfy({ $0.version == nil }) {
1128 destinations = section.notebook.sections(besides: section.tab.path).map { tab in
1129 UIAction(title: tab.name) { [weak self] _ in
1130 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 }
1134 }
1135 }
1136 }
1137 let move = UIBarButtonItem(title: "Move to Section", image: UIImage(systemName: "folder"), menu: UIMenu(children: destinations))
1138 move.isEnabled = !destinations.isEmpty
1139 toolbarItems = [move, .flexibleSpace(), delete]
1140 navigationController?.setToolbarHidden(false, animated: false)
1141 }
1142
9421143 private func reload() {
1144 let selection = tableView.indexPathsForSelectedRows?.map { items[$0.row].id } ?? []
9431145 items = (section?.rows ?? []).flatMap { row in
9441146 [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) }
9451147 }
9461148 tableView.reloadData()
947 if let selected { select(selected) }
1149 if isEditing {
1150 for (index, item) in items.enumerated() where selection.contains(item.id) {
1151 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
1152 }
1153 updateSelectionActions()
1154 } else if let selected { select(selected) }
9481155 var empty = UIContentUnavailableConfiguration.empty()
9491156 empty.text = "No Pages"
9501157 contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil
......@@ -961,7 +1168,7 @@ final class PagesViewController: UITableViewController {
9611168 }
9621169
9631170 private func delete(_ item: Item) {
964 section?.delete(item.id)
1171 section?.delete([item.id])
9651172 }
9661173
9671174 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
......@@ -997,16 +1204,21 @@ final class PagesViewController: UITableViewController {
9971204 }
9981205
9991206 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1207 if isEditing { return updateSelectionActions() }
10001208 guard let section else { return }
10011209 selected = items[indexPath.row].id
10021210 onOpen?(section, items[indexPath.row].id)
10031211 }
10041212
1213 override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
1214 if isEditing { updateSelectionActions() }
1215 }
1216
10051217 override func tableView(
10061218 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
10071219 ) -> UISwipeActionsConfiguration? {
10081220 // A swipe across the list changes section instead; the context menu deletes.
1009 if Prototype.sectionStrip { return nil }
1221 if isEditing || Prototype.sectionStrip { return nil }
10101222 let item = items[indexPath.row]
10111223 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
10121224 self?.delete(item)
......@@ -1019,6 +1231,7 @@ final class PagesViewController: UITableViewController {
10191231 override func tableView(
10201232 _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint
10211233 ) -> UIContextMenuConfiguration? {
1234 guard !isEditing else { return nil }
10221235 let item = items[indexPath.row]
10231236 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
10241237 var actions: [UIMenuElement] = []
apps/ios/Snowbound/Notebooks.swift+12-3
......@@ -723,13 +723,22 @@ final class Section {
723723 return id
724724 }
725725
726 func delete(_ id: String, done: @escaping () -> Void = {}) {
726 func delete(_ ids: [String], done: @escaping () -> Void = {}) {
727727 let (date, time) = titleDate()
728728 let pointer = Int(bitPattern: handle)
729 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in
729 background({
730 for id in ids {
731 guard sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) else { return false }
732 }
733 return true
734 }) { [self] deleted in
730735 reloadRows()
731736 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
732 done()
737 if deleted { done() }
738 else {
739 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
740 window?.rootViewController?.alert("Can’t Delete", "Some pages couldn’t be deleted. Try again.")
741 }
733742 }
734743 }
735744
apps/ios/Snowbound/PageViewController.swift+2-2
......@@ -225,7 +225,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
225225 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
226226 [weak self] _ in
227227 guard let self else { return }
228 section.delete(page)
228 section.delete([page])
229229 },
230230 ]),
231231 ]
......@@ -464,7 +464,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
464464 private func conflictAction() {
465465 guard let (row, version) = section.row(of: page) else { return }
466466 if version != nil {
467 section.delete(page) { [weak self] in self?.onOpen?(row.id) }
467 section.delete([page]) { [weak self] in self?.onOpen?(row.id) }
468468 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {
469469 onOpen?(newest.id)
470470 }