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...@@ -168,7 +168,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
168 private let split = RootViewController(style: .tripleColumn)168 private let split = RootViewController(style: .tripleColumn)
169 private let notebooks = NotebooksViewController()169 private let notebooks = NotebooksViewController()
170 private let pages = PagesViewController()170 private let pages = PagesViewController()
171 private var showingPage = false
172 /// A first launch opened a page by itself.171 /// A first launch opened a page by itself.
173 private var landed = false172 private var landed = false
174 private var background = UIBackgroundTaskIdentifier.invalid173 private var background = UIBackgroundTaskIdentifier.invalid
...@@ -288,12 +287,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -288,12 +287,14 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
288 controller.present(navigation, animated: true)287 controller.present(navigation, animated: true)
289 }288 }
290289
291 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false) {290 func show(section: Section, page: String, reveal: Reveal? = nil, titleFocus: Bool = false, revealPage: Bool = true) {
292 showingPage = true
293 let controller = PageViewController(section: section, page: page, reveal: reveal, titleFocus: titleFocus)291 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 }
295 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)296 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
296 split.show(.secondary)297 if revealPage { split.show(.secondary) }
297 pages.select(page)298 pages.select(page)
298 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)299 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
299 }300 }
...@@ -302,7 +303,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -302,7 +303,6 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
302 func close(_ notebook: Notebook) {303 func close(_ notebook: Notebook) {
303 guard pages.section?.notebook === notebook else { return }304 guard pages.section?.notebook === notebook else { return }
304 pages.clear()305 pages.clear()
305 showingPage = false
306 split.setViewController(UIViewController(), for: .secondary)306 split.setViewController(UIViewController(), for: .secondary)
307 split.show(.primary)307 split.show(.primary)
308 }308 }
...@@ -335,7 +335,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -335,7 +335,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
335 func splitViewController(335 func splitViewController(
336 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column336 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
337 ) -> UISplitViewController.Column {337 ) -> UISplitViewController.Column {
338 showingPage ? proposedTopColumn : .primary338 let detail = (svc.viewController(for: .secondary) as? UINavigationController)?.topViewController
339 return detail is PageViewController ? proposedTopColumn : .primary
339 }340 }
340341
341 /// Composition ends and every edit is stored and published while the system allows.342 /// 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...@@ -261,6 +261,7 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
261 tableView.dragDelegate = self261 tableView.dragDelegate = self
262 tableView.dropDelegate = self262 tableView.dropDelegate = self
263 tableView.dragInteractionEnabled = true263 tableView.dragInteractionEnabled = true
264 tableView.allowsMultipleSelectionDuringEditing = true
264 navigationItem.rightBarButtonItem = editButtonItem265 navigationItem.rightBarButtonItem = editButtonItem
265 }266 }
266267
...@@ -395,12 +396,6 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate...@@ -395,12 +396,6 @@ extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate
395 DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) }396 DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) }
396 }397 }
397398
398 override func tableView(
399 _ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath
400 ) -> UITableViewCell.EditingStyle {
401 tableView.isEditing ? .none : .delete
402 }
403
404 override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {399 override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
405 false400 false
406 }401 }
...@@ -453,6 +448,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -453,6 +448,7 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
453 collectionView.dragDelegate = self448 collectionView.dragDelegate = self
454 collectionView.dropDelegate = self449 collectionView.dropDelegate = self
455 collectionView.dragInteractionEnabled = true450 collectionView.dragInteractionEnabled = true
451 collectionView.allowsMultipleSelectionDuringEditing = true
456 // A sidebar's trailing edge has no room left for it.452 // A sidebar's trailing edge has no room left for it.
457 if UIDevice.current.userInterfaceIdiom == .pad {453 if UIDevice.current.userInterfaceIdiom == .pad {
458 navigationItem.leftBarButtonItem = editButtonItem454 navigationItem.leftBarButtonItem = editButtonItem
...@@ -460,7 +456,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -460,7 +456,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
460 navigationItem.rightBarButtonItems?.append(editButtonItem)456 navigationItem.rightBarButtonItems?.append(editButtonItem)
461 }457 }
462 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.458 // 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 }
464 // The data source is still applying the move when this runs.462 // The data source is still applying the move when this runs.
465 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in463 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
466 DispatchQueue.main.async { self?.reordered(transaction) }464 DispatchQueue.main.async { self?.reordered(transaction) }
...@@ -477,12 +475,6 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -477,12 +475,6 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
477 becomeFirstResponder()475 becomeFirstResponder()
478 }476 }
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
486 private func movable(_ item: Item) -> Bool {478 private func movable(_ item: Item) -> Bool {
487 switch item {479 switch item {
488 case .notebook, .group, .section: true480 case .notebook, .group, .section: true
...@@ -553,7 +545,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie...@@ -553,7 +545,9 @@ extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionVie
553545
554 /// Adds Edit's reorder handle and VoiceOver's ways to move to a row.546 /// Adds Edit's reorder handle and VoiceOver's ways to move to a row.
555 func arranging(_ cell: UICollectionViewListCell, _ item: Item) {547 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 }
557 var actions: [UIAccessibilityCustomAction] = []551 var actions: [UIAccessibilityCustomAction] = []
558 let add = { (name: String, act: @escaping () -> Void) in552 let add = { (name: String, act: @escaping () -> Void) in
559 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })553 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...@@ -47,7 +47,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
47 var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)47 var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)
48 list.headerMode = .firstItemInSection48 list.headerMode = .firstItemInSection
49 list.trailingSwipeActionsConfigurationProvider = { indexPath in49 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 }
51 if case .notebook(let id) = item, let notebook = self.notebook(id) {51 if case .notebook(let id) = item, let notebook = self.notebook(id) {
52 return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))52 return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))
53 }53 }
...@@ -141,14 +141,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -141,14 +141,11 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
141 ]141 ]
142 navigationItem.searchController = SearchViewController.controller()142 navigationItem.searchController = SearchViewController.controller()
143 setUpArranging()143 setUpArranging()
144 let corner = Prototype.quickNote ? [QuickNote.item()] : []
145 if #available(iOS 26, *) {144 if #available(iOS 26, *) {
146 // Search sits in the bottom toolbar, as Notes and Files put it.145 // Search sits in the bottom toolbar, as Notes and Files put it.
147 navigationItem.preferredSearchBarPlacement = .integrated146 navigationItem.preferredSearchBarPlacement = .integrated
148 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner)
149 } else {
150 toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner
151 }147 }
148 updateSelectionActions()
152 for name in [Sync.changed, Section.changed] {149 for name in [Sync.changed, Section.changed] {
153 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil)150 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil)
154 }151 }
...@@ -172,6 +169,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -172,6 +169,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
172169
173 func reload(animated: Bool = false) {170 func reload(animated: Bool = false) {
174 guard isViewLoaded else { return }171 guard isViewLoaded else { return }
172 let selection = collectionView.indexPathsForSelectedItems?.compactMap { dataSource.itemIdentifier(for: $0) } ?? []
175 if Prototype.welcome {173 if Prototype.welcome {
176 let welcome = Prototype.welcome(174 let welcome = Prototype.welcome(
177 new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) },175 new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) },
...@@ -230,6 +228,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -230,6 +228,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
230 dataSource.apply(list, to: location, animatingDifferences: animated)228 dataSource.apply(list, to: location, animatingDifferences: animated)
231 }229 }
232 showSelection()230 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 }
233 refreshSync()239 refreshSync()
234 }240 }
235241
...@@ -294,10 +300,142 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -294,10 +300,142 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
294300
295 /// Marks the open section, as the sidebar of a wide window keeps it.301 /// Marks the open section, as the sidebar of a wide window keeps it.
296 private func showSelection() {302 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 }
298 collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])304 collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
299 }305 }
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
301 private func configure(_ cell: UICollectionViewListCell, _ item: Item) {439 private func configure(_ cell: UICollectionViewListCell, _ item: Item) {
302 var content = cell.defaultContentConfiguration()440 var content = cell.defaultContentConfiguration()
303 var accessories: [UICellAccessory] = []441 var accessories: [UICellAccessory] = []
...@@ -589,7 +727,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -589,7 +727,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
589 override func collectionView(727 override func collectionView(
590 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint728 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
591 ) -> UIContextMenuConfiguration? {729 ) -> 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 }
593 guard case .notebook(let id) = item, let notebook = notebook(id) else {731 guard case .notebook(let id) = item, let notebook = notebook(id) else {
594 let moves = arrangeMenu(item)732 let moves = arrangeMenu(item)
595 return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) })733 return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) })
...@@ -601,6 +739,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -601,6 +739,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
601739
602 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {740 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
603 guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }741 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 }
604 switch item {748 switch item {
605 case .section(let id, let path):749 case .section(let id, let path):
606 let tab = notebook(id)?.tabs.first(where: { $0.path == path })750 let tab = notebook(id)?.tabs.first(where: { $0.path == path })
...@@ -631,6 +775,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -631,6 +775,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
631 }775 }
632776
633 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {777 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
778 if isEditing { return updateSelectionActions() }
634 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),779 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
635 let tab = notebook.tabs.first(where: { $0.path == path })780 let tab = notebook.tabs.first(where: { $0.path == path })
636 else { return }781 else { return }
...@@ -638,6 +783,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -638,6 +783,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
638 onOpen?(tab, notebook)783 onOpen?(tab, notebook)
639 }784 }
640785
786 override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
787 if isEditing { updateSelectionActions() }
788 }
789
641 /// Asks for a protected section's password, as OneNote's Protected Section dialog does,790 /// Asks for a protected section's password, as OneNote's Protected Section dialog does,
642 /// and opens it once unlocked.791 /// and opens it once unlocked.
643 private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) {792 private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) {
...@@ -846,15 +995,10 @@ final class PagesViewController: UITableViewController {...@@ -846,15 +995,10 @@ final class PagesViewController: UITableViewController {
846 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")995 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
847 navigationItem.searchController = search996 navigationItem.searchController = search
848 navigationItem.hidesSearchBarWhenScrolling = false997 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) })
852 if #available(iOS 26, *) {998 if #available(iOS 26, *) {
853 navigationItem.preferredSearchBarPlacement = .integrated999 navigationItem.preferredSearchBarPlacement = .integrated
854 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add]
855 } else {
856 toolbarItems = [.flexibleSpace(), add]
857 }1000 }
1001 updateSelectionActions()
858 NotificationCenter.default.addObserver(1002 NotificationCenter.default.addObserver(
859 self, selector: #selector(changed), name: Section.changed, object: nil)1003 self, selector: #selector(changed), name: Section.changed, object: nil)
860 setUpArranging()1004 setUpArranging()
...@@ -885,6 +1029,7 @@ final class PagesViewController: UITableViewController {...@@ -885,6 +1029,7 @@ final class PagesViewController: UITableViewController {
885 }1029 }
8861030
887 func loading(_ tab: Tab) {1031 func loading(_ tab: Tab) {
1032 if isEditing { setEditing(false, animated: false) }
888 section = nil1033 section = nil
889 items = []1034 items = []
890 title = tab.name1035 title = tab.name
...@@ -901,6 +1046,7 @@ final class PagesViewController: UITableViewController {...@@ -901,6 +1046,7 @@ final class PagesViewController: UITableViewController {
9011046
902 /// Lets go of the section shown, as when its notebook goes.1047 /// Lets go of the section shown, as when its notebook goes.
903 func clear() {1048 func clear() {
1049 if isEditing { setEditing(false, animated: false) }
904 section = nil1050 section = nil
905 items = []1051 items = []
906 title = nil1052 title = nil
...@@ -929,6 +1075,7 @@ final class PagesViewController: UITableViewController {...@@ -929,6 +1075,7 @@ final class PagesViewController: UITableViewController {
9291075
930 func select(_ id: String) {1076 func select(_ id: String) {
931 selected = id1077 selected = id
1078 guard !isEditing else { return }
932 if let index = items.firstIndex(where: { $0.id == id }) {1079 if let index = items.firstIndex(where: { $0.id == id }) {
933 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)1080 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
934 }1081 }
...@@ -939,12 +1086,72 @@ final class PagesViewController: UITableViewController {...@@ -939,12 +1086,72 @@ final class PagesViewController: UITableViewController {
939 DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() }1086 DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() }
940 }1087 }
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
942 private func reload() {1143 private func reload() {
1144 let selection = tableView.indexPathsForSelectedRows?.map { items[$0.row].id } ?? []
943 items = (section?.rows ?? []).flatMap { row in1145 items = (section?.rows ?? []).flatMap { row in
944 [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) }1146 [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) }
945 }1147 }
946 tableView.reloadData()1148 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) }
948 var empty = UIContentUnavailableConfiguration.empty()1155 var empty = UIContentUnavailableConfiguration.empty()
949 empty.text = "No Pages"1156 empty.text = "No Pages"
950 contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil1157 contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil
...@@ -961,7 +1168,7 @@ final class PagesViewController: UITableViewController {...@@ -961,7 +1168,7 @@ final class PagesViewController: UITableViewController {
961 }1168 }
9621169
963 private func delete(_ item: Item) {1170 private func delete(_ item: Item) {
964 section?.delete(item.id)1171 section?.delete([item.id])
965 }1172 }
9661173
967 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }1174 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
...@@ -997,16 +1204,21 @@ final class PagesViewController: UITableViewController {...@@ -997,16 +1204,21 @@ final class PagesViewController: UITableViewController {
997 }1204 }
9981205
999 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {1206 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1207 if isEditing { return updateSelectionActions() }
1000 guard let section else { return }1208 guard let section else { return }
1001 selected = items[indexPath.row].id1209 selected = items[indexPath.row].id
1002 onOpen?(section, items[indexPath.row].id)1210 onOpen?(section, items[indexPath.row].id)
1003 }1211 }
10041212
1213 override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
1214 if isEditing { updateSelectionActions() }
1215 }
1216
1005 override func tableView(1217 override func tableView(
1006 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath1218 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
1007 ) -> UISwipeActionsConfiguration? {1219 ) -> UISwipeActionsConfiguration? {
1008 // A swipe across the list changes section instead; the context menu deletes.1220 // 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 }
1010 let item = items[indexPath.row]1222 let item = items[indexPath.row]
1011 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in1223 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
1012 self?.delete(item)1224 self?.delete(item)
...@@ -1019,6 +1231,7 @@ final class PagesViewController: UITableViewController {...@@ -1019,6 +1231,7 @@ final class PagesViewController: UITableViewController {
1019 override func tableView(1231 override func tableView(
1020 _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint1232 _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint
1021 ) -> UIContextMenuConfiguration? {1233 ) -> UIContextMenuConfiguration? {
1234 guard !isEditing else { return nil }
1022 let item = items[indexPath.row]1235 let item = items[indexPath.row]
1023 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in1236 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
1024 var actions: [UIMenuElement] = []1237 var actions: [UIMenuElement] = []
apps/ios/Snowbound/Notebooks.swift+12-3
...@@ -723,13 +723,22 @@ final class Section {...@@ -723,13 +723,22 @@ final class Section {
723 return id723 return id
724 }724 }
725725
726 func delete(_ id: String, done: @escaping () -> Void = {}) {726 func delete(_ ids: [String], done: @escaping () -> Void = {}) {
727 let (date, time) = titleDate()727 let (date, time) = titleDate()
728 let pointer = Int(bitPattern: handle)728 let pointer = Int(bitPattern: handle)
729 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in729 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
730 reloadRows()735 reloadRows()
731 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])736 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 }
733 }742 }
734 }743 }
735744
apps/ios/Snowbound/PageViewController.swift+2-2
...@@ -225,7 +225,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -225,7 +225,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
225 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {225 UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) {
226 [weak self] _ in226 [weak self] _ in
227 guard let self else { return }227 guard let self else { return }
228 section.delete(page)228 section.delete([page])
229 },229 },
230 ]),230 ]),
231 ]231 ]
...@@ -464,7 +464,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -464,7 +464,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
464 private func conflictAction() {464 private func conflictAction() {
465 guard let (row, version) = section.row(of: page) else { return }465 guard let (row, version) = section.row(of: page) else { return }
466 if version != nil {466 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) }
468 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {468 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {
469 onOpen?(newest.id)469 onOpen?(newest.id)
470 }470 }