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