From 29f57faa8ae1cf3e08f8d96efb90db424fc73337 Mon Sep 17 00:00:00 2001 From: Nikolai Berezovskii Date: Fri, 4 Sep 2026 14:45:43 +0300 Subject: [PATCH] feat: reorder pinboards by dragging tabs --- CHANGELOG.md | 4 + Copy/Info.plist | 10 +++ Copy/Shelf/PinboardDropCatcher.swift | 76 +++++++++++++++---- Copy/Shelf/ShelfRootView.swift | 45 ++++++++++- Copy/Shelf/ShelfViewModel.swift | 22 ++++++ .../CopyCore/Storage/PinboardStore.swift | 41 +++++++++- .../CopyCoreTests/PinboardStoreTests.swift | 28 +++++++ project.yml | 9 ++- 8 files changed, 211 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8ec9f..f31bc5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Pinboard tabs can be dragged into a new order; History stays fixed first. + ## [0.3.0] - 2026-09-03 A cleaner, more native shelf focused on fast clipboard history and pinboards. diff --git a/Copy/Info.plist b/Copy/Info.plist index 0461ea0..92ee19a 100644 --- a/Copy/Info.plist +++ b/Copy/Info.plist @@ -48,6 +48,16 @@ UTTypeIdentifier sk.brzv.copy.item + + UTTypeConformsTo + + public.data + + UTTypeDescription + Copy pinboard + UTTypeIdentifier + sk.brzv.copy.pinboard + diff --git a/Copy/Shelf/PinboardDropCatcher.swift b/Copy/Shelf/PinboardDropCatcher.swift index 0a4301b..8121c49 100644 --- a/Copy/Shelf/PinboardDropCatcher.swift +++ b/Copy/Shelf/PinboardDropCatcher.swift @@ -5,7 +5,7 @@ import UniformTypeIdentifiers /// coordinate space. Each `TabPill` publishes its own frame; `ShelfRootView` collects them /// so the shelf-level `PinboardDropDelegate` can tell which tab a drop landed on. /// -/// Card-onto-pinboard drops can't be caught by a per-tab `.onDrop`: inside the shelf's +/// Pinboard-related drops can't be caught reliably by a per-tab `.onDrop`: inside the shelf's /// borderless, non-activating Liquid Glass panel, a small nested pill never establishes a /// working SwiftUI drop region (a `.onDrop` on the whole shelf, by contrast, fires /// reliably). So the shelf handles the drop once at its root and maps the drop location to @@ -17,16 +17,20 @@ struct PinboardTabFramesKey: PreferenceKey { } } -/// Shelf-level drop target for filing dragged cards into pinboards. Resolves the tab under -/// the drop point from `tabFrames` (published via `PinboardTabFramesKey`), drives the -/// hovered tab's highlight, and files the card uuid(s) on drop. +/// Shelf-level drop target for both card filing and pinboard reordering. Small per-tab +/// drop targets don't work reliably in the shelf panel, so both paths resolve the tab +/// under the pointer from the frames published by `PinboardTabFramesKey`. struct PinboardDropDelegate: DropDelegate { /// Pinboard tab frames in the same coordinate space this delegate's `.onDrop` uses. var tabFrames: () -> [Int64: CGRect] - /// Reports the pinboard id under the current drag point (nil when over none) for highlight. - var onTargetChange: (Int64?) -> Void + /// Reports the card-filing target for the existing full-tab highlight. + var onFileTargetChange: (Int64?) -> Void + /// Reports the reorder target and whether the insertion point is after its midpoint. + var onReorderTargetChange: (Int64?, Bool) -> Void /// Files the dragged card uuid(s) into the given pinboard. var onFile: (Int64, [String]) -> Void + /// Moves one dragged pinboard before or after the target pinboard. + var onMove: (Int64, Int64, Bool) -> Void private func pinboard(at point: CGPoint) -> Int64? { let frames = tabFrames() @@ -48,27 +52,48 @@ struct PinboardDropDelegate: DropDelegate { } func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [UTType.copyItem]) && pinboard(at: info.location) != nil + let hasSupportedPayload = info.hasItemsConforming(to: [UTType.copyPinboard]) + || info.hasItemsConforming(to: [UTType.copyItem]) + return hasSupportedPayload && pinboard(at: info.location) != nil } func dropEntered(info: DropInfo) { - onTargetChange(pinboard(at: info.location)) + updateTarget(for: info) } func dropUpdated(info: DropInfo) -> DropProposal? { let target = pinboard(at: info.location) - onTargetChange(target) - return DropProposal(operation: target != nil ? .copy : .cancel) + updateTarget(for: info) + let operation: DropOperation = info.hasItemsConforming(to: [UTType.copyPinboard]) + ? .move + : .copy + return DropProposal(operation: target != nil ? operation : .cancel) } func dropExited(info: DropInfo) { - onTargetChange(nil) + clearTargets() } func performDrop(info: DropInfo) -> Bool { - onTargetChange(nil) - guard let id = pinboard(at: info.location), - let provider = info.itemProviders(for: [UTType.copyItem]).first else { return false } + guard let id = pinboard(at: info.location) else { return false } + + if let provider = info.itemProviders(for: [UTType.copyPinboard]).first { + let placeAfterTarget = placesAfterTarget(id, at: info.location) + clearTargets() + provider.loadDataRepresentation(forTypeIdentifier: UTType.copyPinboard.identifier) { data, _ in + guard let data, + let rawID = String(data: data, encoding: .utf8), + let sourceID = Int64(rawID) else { return } + DispatchQueue.main.async { + onMove(sourceID, id, placeAfterTarget) + clearTargets() + } + } + return true + } + + clearTargets() + guard let provider = info.itemProviders(for: [UTType.copyItem]).first else { return false } provider.loadDataRepresentation(forTypeIdentifier: UTType.copyItem.identifier) { data, _ in guard let data, let payload = String(data: data, encoding: .utf8) else { return } let uuids = payload.split(separator: "\n").map(String.init) @@ -78,9 +103,30 @@ struct PinboardDropDelegate: DropDelegate { // Clear the highlight again after any trailing dropUpdated: SwiftUI doesn't // call dropExited after a successful drop, so without this the filed-into // tab keeps its drop border. - onTargetChange(nil) + clearTargets() } } return true } + + private func updateTarget(for info: DropInfo) { + let target = pinboard(at: info.location) + if info.hasItemsConforming(to: [UTType.copyPinboard]) { + onFileTargetChange(nil) + onReorderTargetChange(target, target.map { placesAfterTarget($0, at: info.location) } ?? false) + } else { + onReorderTargetChange(nil, false) + onFileTargetChange(target) + } + } + + private func placesAfterTarget(_ id: Int64, at point: CGPoint) -> Bool { + guard let frame = tabFrames()[id] else { return false } + return point.x >= frame.midX + } + + private func clearTargets() { + onFileTargetChange(nil) + onReorderTargetChange(nil, false) + } } diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 8ab9847..0339c4c 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -58,21 +58,32 @@ struct ShelfRootView: View { // shader's outer pixels visible in the rectangular NSPanel corners; giving the // shader an inset gutter instead produces a second outline around the shelf. .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - // Card → pinboard filing is handled here, at the shelf root, because a per-tab - // `.onDrop` never establishes a working drop region on the small pills inside this + // Card filing and pinboard reordering are handled here, at the shelf root, because + // a per-tab `.onDrop` never establishes a working drop region on the small pills inside this // borderless non-activating glass panel (a shelf-level drop, by contrast, fires // reliably). The delegate maps the drop location to the tab under it using each // tab's frame, collected via PinboardTabFramesKey below. .coordinateSpace(name: "shelfRoot") .onPreferenceChange(PinboardTabFramesKey.self) { pinboardTabFrames = $0 } - .onDrop(of: [UTType.copyItem], delegate: PinboardDropDelegate( + .onDrop(of: [UTType.copyItem, UTType.copyPinboard], delegate: PinboardDropDelegate( tabFrames: { pinboardTabFrames }, - onTargetChange: { viewModel.dropTargetedPinboardID = $0 }, + onFileTargetChange: { viewModel.dropTargetedPinboardID = $0 }, + onReorderTargetChange: { id, placeAfterTarget in + viewModel.reorderTargetedPinboardID = id + viewModel.reorderPlacesAfterTarget = placeAfterTarget + }, onFile: { id, uuids in guard let pinboard = viewModel.pinboards.first(where: { $0.id == id }) else { return } viewModel.dropItems(uuids: uuids, toPinboard: pinboard) // Open the pinboard we just filed into, so the drop's result shows at once. viewModel.tab = .pinboard(id) + }, + onMove: { sourceID, targetID, placeAfterTarget in + viewModel.movePinboard( + id: sourceID, + relativeTo: targetID, + placeAfterTarget: placeAfterTarget + ) } )) // Dark keeps the marketing electric-blue accent. The forced light/dark window @@ -263,6 +274,9 @@ private struct ShelfTabs: View { showsSymbol: false, isSelected: pinboard.id.map { viewModel.tab == .pinboard($0) } ?? false, isDropTargeted: pinboard.id != nil && viewModel.dropTargetedPinboardID == pinboard.id, + reorderIndicatorEdge: viewModel.reorderTargetedPinboardID == pinboard.id + ? (viewModel.reorderPlacesAfterTarget ? .trailing : .leading) + : nil, // ⌘1 is History, so pinboards start at ⌘2; only the first eight get a // hint (⌘9 is the ceiling of the number-key routing). shortcutHint: (viewModel.commandHeld && offset + 2 <= 9) ? "\(offset + 2)" : nil, @@ -295,6 +309,19 @@ private struct ShelfTabs: View { renamingPinboard = nil } } + .onDrag { + guard let id = pinboard.id else { return NSItemProvider() } + let data = Data(String(id).utf8) + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: UTType.copyPinboard.identifier, + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + return provider + } // Publish this tab's frame (in the shelf's "shelfRoot" space) so the // shelf-level PinboardDropDelegate can map a drop location back to this // pinboard. Drops are handled at the shelf root, not per-tab — see @@ -361,6 +388,8 @@ private struct TabPill: View { var showsSymbol: Bool = true let isSelected: Bool var isDropTargeted: Bool = false + /// A slim insertion marker for pinboard-tab reordering. History never receives one. + var reorderIndicatorEdge: Edge? = nil /// The ⌘-number that jumps to this tab (e.g. "1"), shown as a badge while ⌘ is held. var shortcutHint: String? = nil let action: () -> Void @@ -396,6 +425,14 @@ private struct TabPill: View { RoundedRectangle(cornerRadius: 6) .stroke(isDropTargeted ? Color.accentColor : .clear, lineWidth: 2) ) + .overlay(alignment: reorderIndicatorEdge == .trailing ? .trailing : .leading) { + if reorderIndicatorEdge != nil { + Capsule() + .fill(Color.accentColor) + .frame(width: 3, height: 20) + .offset(x: reorderIndicatorEdge == .trailing ? 3 : -3) + } + } // A drop-targeted tab visibly pops so it's unmistakable which pinboard a dragged // card will land in, even when the cursor's drag chip sits near it. .scaleEffect(isDropTargeted ? 1.08 : 1) diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 7bb7454..d31eafe 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -10,6 +10,9 @@ extension UTType { /// (`UTExportedTypeDeclarations`) so the system recognizes it during real /// drag-and-drop sessions, not just as an in-process string constant. static let copyItem = UTType(exportedAs: "sk.brzv.copy.item") + /// Internal payload used only to reorder pinboard tabs. Keeping it distinct from + /// `copyItem` prevents a tab drag from being interpreted as filing a card. + static let copyPinboard = UTType(exportedAs: "sk.brzv.copy.pinboard") } @MainActor @@ -81,6 +84,10 @@ final class ShelfViewModel { /// Driven by the shelf-level drop delegate (see `PinboardDropDelegate`) rather than a /// per-tab `.onDrop`, which never established a working drop region on the small pills. var dropTargetedPinboardID: Int64? + /// Shelf-level reorder target and insertion side for a dragged pinboard tab. These + /// stay separate from card filing so the two drag payloads cannot affect each other. + var reorderTargetedPinboardID: Int64? + var reorderPlacesAfterTarget = false /// Set by a force-click (which fires before the click's own mouse-up resolves) so the /// release doesn't then paste the card. Consumed by the next `handleCardClick`. @ObservationIgnored var suppressNextCardPaste = false @@ -576,6 +583,21 @@ final class ShelfViewModel { } } + func movePinboard(id: Int64, relativeTo targetID: Int64, placeAfterTarget: Bool) { + do { + // Apply the returned order immediately; the observation then keeps every + // other consumer in sync with the same persisted database order. + pinboards = try pinboardStore.move( + id: id, + relativeTo: targetID, + placeAfterTarget: placeAfterTarget + ) + } catch { + NSLog("Copy: failed to reorder pinboards: \(error)") + HUD.show("Couldn't complete that") + } + } + func addItem(_ item: ClipItem, toPinboard id: Int64) { guard let itemID = item.id else { return } do { diff --git a/CopyCore/Sources/CopyCore/Storage/PinboardStore.swift b/CopyCore/Sources/CopyCore/Storage/PinboardStore.swift index bb75da3..75dfb40 100644 --- a/CopyCore/Sources/CopyCore/Storage/PinboardStore.swift +++ b/CopyCore/Sources/CopyCore/Storage/PinboardStore.swift @@ -50,7 +50,38 @@ public struct PinboardStore { public func all() throws -> [Pinboard] { try writer.read { db in - try Pinboard.order(Column("sortIndex").asc).fetchAll(db) + try Self.fetchAll(db) + } + } + + /// Moves one pinboard before or after another and persists the complete tab order. + /// History is not a database pinboard, so it can never enter this operation and + /// remains fixed at the front of the shelf. + @discardableResult + public func move(id: Int64, relativeTo targetID: Int64, placeAfterTarget: Bool) throws -> [Pinboard] { + try writer.write { db in + var ids = try Int64.fetchAll(db, sql: """ + SELECT id FROM pinboard ORDER BY sortIndex ASC, id ASC + """) + guard id != targetID, + let sourceIndex = ids.firstIndex(of: id), + let targetIndex = ids.firstIndex(of: targetID) else { + return try Self.fetchAll(db) + } + + ids.remove(at: sourceIndex) + var insertionIndex = targetIndex + (placeAfterTarget ? 1 : 0) + if sourceIndex < insertionIndex { insertionIndex -= 1 } + insertionIndex = min(max(0, insertionIndex), ids.count) + ids.insert(id, at: insertionIndex) + + for (sortIndex, pinboardID) in ids.enumerated() { + try db.execute( + sql: "UPDATE pinboard SET sortIndex = ? WHERE id = ?", + arguments: [sortIndex, pinboardID] + ) + } + return try Self.fetchAll(db) } } @@ -93,7 +124,7 @@ public struct PinboardStore { public func observeAll(onError: @escaping (Error) -> Void, onChange: @escaping ([Pinboard]) -> Void) -> ObservationToken { let observation = ValueObservation.tracking { db in - try Pinboard.order(Column("sortIndex").asc).fetchAll(db) + try Self.fetchAll(db) } return ObservationToken(observation.start(in: writer, scheduling: .async(onQueue: .main), @@ -120,4 +151,10 @@ public struct PinboardStore { LIMIT ? """, arguments: [pinboardID, limit]) } + + private static func fetchAll(_ db: Database) throws -> [Pinboard] { + try Pinboard + .order(Column("sortIndex").asc, Column("id").asc) + .fetchAll(db) + } } diff --git a/CopyCore/Tests/CopyCoreTests/PinboardStoreTests.swift b/CopyCore/Tests/CopyCoreTests/PinboardStoreTests.swift index 009537e..c3b8429 100644 --- a/CopyCore/Tests/CopyCoreTests/PinboardStoreTests.swift +++ b/CopyCore/Tests/CopyCoreTests/PinboardStoreTests.swift @@ -47,6 +47,34 @@ final class PinboardStoreTests: XCTestCase { XCTAssertEqual(try store.recentItems(limit: 10).count, 1) } + func testMovePinboardsBeforeAndAfterEachOther() throws { + let (_, pinboards) = try makeTempStores() + let a = try pinboards.create(name: "A", symbol: "pin") + let b = try pinboards.create(name: "B", symbol: "pin") + let c = try pinboards.create(name: "C", symbol: "pin") + + try pinboards.move(id: a.id!, relativeTo: c.id!, placeAfterTarget: true) + XCTAssertEqual(try pinboards.all().map(\.name), ["B", "C", "A"]) + + try pinboards.move(id: c.id!, relativeTo: b.id!, placeAfterTarget: false) + XCTAssertEqual(try pinboards.all().map(\.name), ["C", "B", "A"]) + + try pinboards.move(id: b.id!, relativeTo: a.id!, placeAfterTarget: false) + XCTAssertEqual(try pinboards.all().map(\.name), ["C", "B", "A"], + "dropping into the board's existing gap should be a no-op") + } + + func testMoveIgnoresUnknownOrSamePinboardWithoutChangingOrder() throws { + let (_, pinboards) = try makeTempStores() + let a = try pinboards.create(name: "A", symbol: "pin") + _ = try pinboards.create(name: "B", symbol: "pin") + + try pinboards.move(id: a.id!, relativeTo: a.id!, placeAfterTarget: true) + try pinboards.move(id: 9_999, relativeTo: a.id!, placeAfterTarget: false) + + XCTAssertEqual(try pinboards.all().map(\.name), ["A", "B"]) + } + func testClearHistorySparesPinboardMembers() throws { let (store, pinboards) = try makeTempStores() let board = try pinboards.create(name: "Keep", symbol: "pin") diff --git a/project.yml b/project.yml index 6d4b63f..56ccaed 100644 --- a/project.yml +++ b/project.yml @@ -43,14 +43,17 @@ targets: SUPublicEDKey: "ic1GeI8aVeYsA3RsX9sxrbaAkWOFyLOhL8K94U7TrNA=" SUEnableAutomaticChecks: true SUScheduledCheckInterval: 86400 - # Declares the internal card-drag payload type (see ShelfViewModel.UTType.copyItem) - # so the system recognizes it during real drag-and-drop, not just as an in-process - # string constant. Required for card → pinboard tab filing to work. + # Declares the app's internal drag payloads so the system recognizes them during + # real drag-and-drop, not just as in-process string constants. UTExportedTypeDeclarations: - UTTypeIdentifier: sk.brzv.copy.item UTTypeDescription: Copy clipboard item UTTypeConformsTo: - public.data + - UTTypeIdentifier: sk.brzv.copy.pinboard + UTTypeDescription: Copy pinboard + UTTypeConformsTo: + - public.data settings: base: PRODUCT_BUNDLE_IDENTIFIER: sk.brzv.Copy