# 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: ```swift private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") ``` - [ ] **Step 2: Update `pasteboardWriterForRow` to always write track ID** Replace the current implementation (lines 372-375): ```swift func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { guard parent.onReorder != nil else { return nil } return "\(row)" as NSString } ``` With: ```swift 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): ```swift 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): ```swift if context.coordinator.parent.onReorder != nil { if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { tableView.registerForDraggedTypes([.string]) tableView.draggingDestinationFeedbackStyle = .gap } } else { tableView.unregisterDraggedTypes() } ``` With: ```swift 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** ```bash 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: ```swift import UniformTypeIdentifiers ``` Add a new property to `PlaylistBarView` after the existing callbacks (after line 14): ```swift var onDropTrack: ((Int64, Playlist) -> Void)? ``` Also add a private constant for the UTType (inside the file, outside the struct — near the import): ```swift private let trackIdUTType = UTType("com.music.trackID")! ``` - [ ] **Step 2: Add `.onDrop` modifier to regular playlist chips** Replace the `ForEach` block (lines 26-52) with: ```swift 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`): ```swift 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`: ```swift private extension View { @ViewBuilder func `if`(_ 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: ```swift var isDropTarget: Bool = false ``` Update the `.background` and `.overlay` in the button body to respond to `isDropTarget`: ```swift .background( isDropTarget ? tintColor.opacity(0.3) : isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1) ) ``` ```swift .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** ```bash 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): ```swift 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** ```bash 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**