Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions Copy/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@
<key>UTTypeIdentifier</key>
<string>sk.brzv.copy.item</string>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Copy pinboard</string>
<key>UTTypeIdentifier</key>
<string>sk.brzv.copy.pinboard</string>
</dict>
</array>
</dict>
</plist>
76 changes: 61 additions & 15 deletions Copy/Shelf/PinboardDropCatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
}
}
45 changes: 41 additions & 4 deletions Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions Copy/Shelf/ShelfViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 39 additions & 2 deletions CopyCore/Sources/CopyCore/Storage/PinboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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),
Expand All @@ -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)
}
}
28 changes: 28 additions & 0 deletions CopyCore/Tests/CopyCoreTests/PinboardStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading