You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-31-track-drag-to-pl...

11 KiB

Track Drag-to-Playlist Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Drag a single track from the track table onto a regular playlist chip to add it.

Architecture: The NSTableView drag source writes the track's Int64 ID to the pasteboard under a custom type. Each regular playlist chip in PlaylistBarView becomes a SwiftUI drop target that reads the ID and calls the existing PlaylistViewModel.addTrack method. A highlight shows when a valid drag hovers over a chip.

Tech Stack: SwiftUI, AppKit (NSTableView/NSPasteboard), GRDB


Task 1: Add custom pasteboard type and update drag source

Files:

  • Modify: Music/Views/TrackTableView.swift:4 (add constant)

  • Modify: Music/Views/TrackTableView.swift:133-140 (register drag types)

  • Modify: Music/Views/TrackTableView.swift:372-375 (pasteboardWriterForRow)

  • Step 1: Add the custom pasteboard type constant

At the top of TrackTableView.swift, after line 6 (private let defaultVisibleColumnIds), add:

private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
  • Step 2: Update pasteboardWriterForRow to always write track ID

Replace the current implementation (lines 372-375):

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
    guard parent.onReorder != nil else { return nil }
    return "\(row)" as NSString
}

With:

func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
    let item = NSPasteboardItem()
    if let trackId = parent.tracks[row].id {
        item.setString(String(trackId), forType: trackIdPasteboardType)
    }
    if parent.onReorder != nil {
        item.setString(String(row), forType: .string)
    }
    return item
}
  • Step 3: Update validateDrop and acceptDrop to read .string type explicitly

The current acceptDrop reads from the first pasteboard item's generic string. Now that we write two types, it must read .string specifically.

Replace acceptDrop (lines 382-393):

func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
    guard let onReorder = parent.onReorder else { return false }
    guard let item = info.draggingPasteboard.pasteboardItems?.first,
          let rowString = item.string(forType: .string),
          let sourceRow = Int(rowString) else { return false }

    let destination = sourceRow < row ? row - 1 : row
    guard sourceRow != destination else { return false }

    onReorder(sourceRow, destination)
    return true
}
  • Step 4: Update drag type registration in updateNSView

Replace the drag registration block (lines 133-140):

if context.coordinator.parent.onReorder != nil {
    if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) {
        tableView.registerForDraggedTypes([.string])
        tableView.draggingDestinationFeedbackStyle = .gap
    }
} else {
    tableView.unregisterDraggedTypes()
}

With:

let needsReorder = context.coordinator.parent.onReorder != nil
let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder
    ? [trackIdPasteboardType, .string]
    : [trackIdPasteboardType]
if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) {
    tableView.registerForDraggedTypes(wantedTypes)
    tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none
}
  • Step 5: Build and verify

Run: cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 6: Commit
git add Music/Views/TrackTableView.swift
git commit -m "feat: write track ID to pasteboard on drag start"

Task 2: Add drop target to PlaylistBarView

Files:

  • Modify: Music/Views/PlaylistBarView.swift:3-14 (add callback prop)

  • Modify: Music/Views/PlaylistBarView.swift:26-51 (add .onDrop to playlist chips)

  • Modify: Music/Views/PlaylistBarView.swift:59-96 (add isDropTarget param to PlaylistButton)

  • Step 1: Add onDropTrack callback and UniformTypeIdentifiers import

Add at the top of the file:

import UniformTypeIdentifiers

Add a new property to PlaylistBarView after the existing callbacks (after line 14):

var onDropTrack: ((Int64, Playlist) -> Void)?

Also add a private constant for the UTType (inside the file, outside the struct — near the import):

private let trackIdUTType = UTType("com.music.trackID")!
  • Step 2: Add .onDrop modifier to regular playlist chips

Replace the ForEach block (lines 26-52) with:

ForEach(playlists, id: \.listIdentity) { item in
    let isRegular = item is Playlist
    PlaylistChip(
        item: item,
        isSelected: selectedItem?.listIdentity == item.listIdentity,
        isRemoteMode: isRemoteMode,
        acceptsDrop: isRegular,
        trackIdUTType: trackIdUTType,
        onTap: {
            if selectedItem?.listIdentity == item.listIdentity {
                onDeselect()
            } else {
                onSelect(item)
            }
        },
        onDropTrack: isRegular ? { trackId in
            onDropTrack?(trackId, item as! Playlist)
        } : nil,
        onRename: { onRename(item) },
        onDelete: { onDelete(item) },
        onEditQuery: (item as? SmartPlaylist).map { smart in { onEditQuery(smart) } },
        onEditConditions: (item as? SmartPlaylist).map { smart in { onEditConditions(smart) } }
    )
}
  • Step 3: Create a PlaylistChip wrapper view to manage drop state

Add this view between PlaylistBarView and PlaylistButton (it owns the @State for isTargeted):

private struct PlaylistChip: View {
    let item: any PlaylistRepresentable
    let isSelected: Bool
    let isRemoteMode: Bool
    let acceptsDrop: Bool
    let trackIdUTType: UTType
    let onTap: () -> Void
    var onDropTrack: ((Int64) -> Void)?
    let onRename: () -> Void
    let onDelete: () -> Void
    var onEditQuery: (() -> Void)?
    var onEditConditions: (() -> Void)?

    @State private var isDropTargeted = false

    var body: some View {
        PlaylistButton(
            name: item.name,
            isSelected: isSelected,
            isSmart: item.isSmartPlaylist,
            isDropTarget: isDropTargeted,
            action: onTap
        )
        .if(acceptsDrop) { view in
            view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in
                guard let provider = providers.first else { return false }
                provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in
                    guard let data = data as? Data,
                          let str = String(data: data, encoding: .utf8),
                          let trackId = Int64(str) else { return }
                    DispatchQueue.main.async {
                        onDropTrack?(trackId)
                    }
                }
                return true
            }
        }
        .contextMenu {
            if !isRemoteMode {
                Button("Rename...") { onRename() }
                if let onEditConditions {
                    Button("Edit...") { onEditConditions() }
                } else if let onEditQuery {
                    Button("Edit Search Query...") { onEditQuery() }
                }
                Button("Delete") { onDelete() }
            }
        }
    }
}
  • Step 4: Add the .if view extension if it doesn't already exist

Check if View+if already exists in the project. If not, add it at the bottom of PlaylistBarView.swift:

private extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition { transform(self) } else { self }
    }
}
  • Step 5: Add isDropTarget parameter to PlaylistButton

Update PlaylistButton to accept and use the highlight state. Add the parameter:

var isDropTarget: Bool = false

Update the .background and .overlay in the button body to respond to isDropTarget:

.background(
    isDropTarget ? tintColor.opacity(0.3) :
    isSelected ? tintColor.opacity(0.2) :
    Color.secondary.opacity(0.1)
)
.overlay(
    RoundedRectangle(cornerRadius: 4)
        .stroke(
            isDropTarget ? tintColor :
            isSelected ? tintColor :
            Color.secondary.opacity(0.3),
            lineWidth: isDropTarget ? 2 : 1
        )
)
  • Step 6: Build and verify

Run: cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 7: Commit
git add Music/Views/PlaylistBarView.swift
git commit -m "feat: add drop target on playlist chips for track drag"

Task 3: Wire up in ContentView

Files:

  • Modify: Music/ContentView.swift:211-261 (add onDropTrack to PlaylistBarView call)

  • Step 1: Add onDropTrack closure to PlaylistBarView instantiation

In ContentView.swift, add the onDropTrack parameter to the PlaylistBarView(...) call, after the onEditConditions closure (after line 259):

onDropTrack: { trackId, targetPlaylist in
    guard let track = library.tracks.first(where: { $0.id == trackId }) else { return }
    try? playlist.addTrack(track, to: targetPlaylist)
}
  • Step 2: Build and verify

Run: cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5

Expected: BUILD SUCCEEDED

  • Step 3: Commit
git add Music/ContentView.swift
git commit -m "feat: wire track drag-to-playlist in ContentView"

Task 4: Manual test and fix any issues

  • Step 1: Launch the app

Run: cd /Users/laurentmorvillier/code/Music && open -a Xcode Music.xcodeproj and run from Xcode (Cmd+R), or build and run via command line.

  • Step 2: Test the happy path
  1. Ensure at least one regular playlist exists
  2. Drag a track from the track table
  3. Hover over a regular playlist chip — verify it highlights
  4. Drop on the chip — verify the track appears in the playlist (click the playlist to check)
  • Step 3: Test edge cases
  1. Drag a track over a smart playlist chip — verify no highlight, drop rejected
  2. Drag a track that's already in a playlist onto that playlist — verify silent no-op
  3. Drag a track over the Home chip — verify no highlight
  4. After a drag-to-playlist, try reordering tracks within a playlist — verify still works
  5. Drop a file from Finder onto the app — verify library scan still works
  • Step 4: Fix any issues found, commit