# Track Context Menu on Bottom Controls — 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:** Right-clicking the now-playing area at bottom-left shows the same Add/Remove playlist context menu as right-clicking a track row in the track table. **Architecture:** Introduce a `TrackContextMenuConfig` value type that bundles all menu data + callbacks. A new `TrackContextMenuModifier` SwiftUI view modifier applies `.contextMenu` using that config. `TrackTableView` is refactored to accept a single `contextMenuConfig` parameter (replacing six individual playlist params), and `PlayerControlsView` gains the same optional parameter with the modifier applied to `nowPlayingSection`. `ContentView` constructs one config and passes it to both views. **Tech Stack:** Swift 5.9+, SwiftUI `.contextMenu`, AppKit `NSMenu` (table view keeps existing AppKit path), Swift Testing framework. --- ## File Map | File | Action | |------|--------| | `Music/Models/TrackContextMenuConfig.swift` | **Create** — value type holding playlists + callbacks | | `Music/Views/TrackContextMenuModifier.swift` | **Create** — SwiftUI ViewModifier + View extension | | `MusicTests/TrackContextMenuConfigTests.swift` | **Create** — unit tests for config struct | | `Music/Views/TrackTableView.swift` | **Modify** — replace 6 playlist params with `contextMenuConfig`, update `menuNeedsUpdate` | | `Music/Views/PlayerControlsView.swift` | **Modify** — add `contextMenuConfig` param, apply modifier to `nowPlayingSection` | | `Music/ContentView.swift` | **Modify** — construct and pass `TrackContextMenuConfig` to both views | --- ### Task 1: Create `TrackContextMenuConfig` **Files:** - Create: `Music/Models/TrackContextMenuConfig.swift` - Test: `MusicTests/TrackContextMenuConfigTests.swift` - [ ] **Step 1: Write the failing test** Create `MusicTests/TrackContextMenuConfigTests.swift`: ```swift import Testing @testable import Music struct TrackContextMenuConfigTests { // Builds a config with all fields set and verifies: // - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs // - onAddToPlaylist callback fires with the correct track and playlist // - onAddToLastPlaylist callback fires with the correct track // - onRemoveFromPlaylist callback fires with the correct track // - when optional callbacks are nil, optionally calling them is safe @Test func storesPropertiesAndFiresCallbacks() { // 1. Create fixture data let pl1 = Playlist.fixture(id: 1, name: "Favorites") let pl2 = Playlist.fixture(id: 2, name: "Chill") let track = Track.fixture(id: 42, title: "Test") var addedTrack: Track? = nil var addedPlaylist: Playlist? = nil var lastTrack: Track? = nil var removedTrack: Track? = nil // 2. Build config with all callbacks let config = TrackContextMenuConfig( playlists: [pl1, pl2], lastUsedPlaylistName: "Favorites", selectedPlaylist: pl1, onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p }, onAddToLastPlaylist: { t in lastTrack = t }, onRemoveFromPlaylist: { t in removedTrack = t } ) // 3. Verify stored properties #expect(config.playlists.count == 2) #expect(config.playlists[0].name == "Favorites") #expect(config.lastUsedPlaylistName == "Favorites") #expect(config.selectedPlaylist == pl1) // 4. Invoke callbacks and verify they fire correctly config.onAddToPlaylist(track, pl2) config.onAddToLastPlaylist?(track) config.onRemoveFromPlaylist?(track) #expect(addedTrack?.id == track.id) #expect(addedPlaylist?.id == pl2.id) #expect(lastTrack?.id == track.id) #expect(removedTrack?.id == track.id) } @Test func nilOptionalCallbacksAreSafe() { // Verifies that a config with nil optional callbacks does not crash // when you call them via optional chaining (the normal usage pattern) let pl = Playlist.fixture(id: 1, name: "Rock") let track = Track.fixture() let config = TrackContextMenuConfig( playlists: [pl], lastUsedPlaylistName: nil, selectedPlaylist: nil, onAddToPlaylist: { _, _ in }, onAddToLastPlaylist: nil, onRemoveFromPlaylist: nil ) // These must not crash config.onAddToLastPlaylist?(track) config.onRemoveFromPlaylist?(track) #expect(config.lastUsedPlaylistName == nil) #expect(config.selectedPlaylist == nil) } } ``` - [ ] **Step 2: Run tests to confirm they fail (type not found)** ``` xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "error:|FAIL|PASS|warning: cannot" ``` Expected: compile error — `TrackContextMenuConfig` not found. - [ ] **Step 3: Create `Music/Models/TrackContextMenuConfig.swift`** ```swift import Foundation struct TrackContextMenuConfig { let playlists: [Playlist] let lastUsedPlaylistName: String? let selectedPlaylist: Playlist? let onAddToPlaylist: (Track, Playlist) -> Void let onAddToLastPlaylist: ((Track) -> Void)? let onRemoveFromPlaylist: ((Track) -> Void)? } ``` - [ ] **Step 4: Add the new file to the Xcode project** In Xcode, right-click the `Models` group in the project navigator → **Add Files to "Music"** → select `TrackContextMenuConfig.swift`. Make sure "Add to targets: Music" is checked. - [ ] **Step 5: Run tests to confirm they pass** ``` xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "Test.*passed|Test.*failed|error:" ``` Expected: both tests pass. - [ ] **Step 6: Commit** ```bash git add Music/Models/TrackContextMenuConfig.swift MusicTests/TrackContextMenuConfigTests.swift git commit -m "feat: add TrackContextMenuConfig value type" ``` --- ### Task 2: Create `TrackContextMenuModifier` **Files:** - Create: `Music/Views/TrackContextMenuModifier.swift` > No unit test for this task — SwiftUI view modifier behaviour requires UI/snapshot testing not set up in this project. Manual verification is in Task 5. - [ ] **Step 1: Create `Music/Views/TrackContextMenuModifier.swift`** ```swift import SwiftUI // Attaches a context menu matching the track table's right-click menu. // No-ops silently when track or config is nil so callers can pass optionals freely. struct TrackContextMenuModifier: ViewModifier { let track: Track? let config: TrackContextMenuConfig? func body(content: Content) -> some View { if let track, let config { content.contextMenu { if let lastPlaylistName = config.lastUsedPlaylistName, let onAddToLastPlaylist = config.onAddToLastPlaylist { Button("Add to \(lastPlaylistName)") { onAddToLastPlaylist(track) } Divider() } if !config.playlists.isEmpty { Menu("Add to Playlist") { ForEach(config.playlists) { playlist in Button(playlist.name) { config.onAddToPlaylist(track, playlist) } } } } if config.selectedPlaylist != nil, let onRemoveFromPlaylist = config.onRemoveFromPlaylist { Divider() Button("Remove from Playlist") { onRemoveFromPlaylist(track) } } } } else { content } } } extension View { func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View { modifier(TrackContextMenuModifier(track: track, config: config)) } } ``` - [ ] **Step 2: Add the new file to the Xcode project** In Xcode, right-click the `Views` group → **Add Files to "Music"** → select `TrackContextMenuModifier.swift`. Make sure "Add to targets: Music" is checked. - [ ] **Step 3: Build to confirm it compiles** ``` xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED" ``` Expected: `BUILD SUCCEEDED`. - [ ] **Step 4: Commit** ```bash git add Music/Views/TrackContextMenuModifier.swift git commit -m "feat: add TrackContextMenuModifier SwiftUI view modifier" ``` --- ### Task 3: Refactor `TrackTableView` to use `contextMenuConfig` **Files:** - Modify: `Music/Views/TrackTableView.swift:45-50` (replace 6 playlist params) - Modify: `Music/Views/TrackTableView.swift:333-394` (update `menuNeedsUpdate` + action handlers) - [ ] **Step 1: Replace the 6 individual playlist properties with `contextMenuConfig`** In `Music/Views/TrackTableView.swift`, find lines 45–51: ```swift var playlists: [Playlist] var lastUsedPlaylistName: String? var selectedPlaylist: Playlist? var onAddToPlaylist: ((Track, Playlist) -> Void)? var onAddToLastPlaylist: ((Track) -> Void)? var onRemoveFromPlaylist: ((Track) -> Void)? var onReorder: ((Int, Int) -> Void)? ``` Replace with: ```swift var contextMenuConfig: TrackContextMenuConfig? var onReorder: ((Int, Int) -> Void)? ``` - [ ] **Step 2: Update `menuNeedsUpdate` to read from `contextMenuConfig`** Find the entire `menuNeedsUpdate` method (lines ~333–375) and replace it: ```swift func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } guard let config = parent.contextMenuConfig else { return } if let lastPlaylistName = config.lastUsedPlaylistName, config.onAddToLastPlaylist != nil { let lastItem = NSMenuItem( title: "Add to \(lastPlaylistName)", action: #selector(addToLastPlaylist(_:)), keyEquivalent: "" ) lastItem.target = self menu.addItem(lastItem) menu.addItem(.separator()) } if !config.playlists.isEmpty { let submenu = NSMenu() for (index, playlist) in config.playlists.enumerated() { let item = NSMenuItem( title: playlist.name, action: #selector(addToPlaylist(_:)), keyEquivalent: "" ) item.target = self item.tag = index submenu.addItem(item) } let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "") submenuItem.submenu = submenu menu.addItem(submenuItem) } if config.selectedPlaylist != nil, config.onRemoveFromPlaylist != nil { menu.addItem(.separator()) let removeItem = NSMenuItem( title: "Remove from Playlist", action: #selector(removeFromPlaylist(_:)), keyEquivalent: "" ) removeItem.target = self menu.addItem(removeItem) } } ``` - [ ] **Step 3: Update the three `@objc` menu action methods to use `contextMenuConfig`** Find `addToPlaylist`, `addToLastPlaylist`, and `removeFromPlaylist` (lines ~377–394) and replace all three: ```swift @objc func addToPlaylist(_ sender: NSMenuItem) { guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } guard let config = parent.contextMenuConfig else { return } let track = tracks[tableView.clickedRow] let playlist = config.playlists[sender.tag] config.onAddToPlaylist(track, playlist) } @objc func addToLastPlaylist(_ sender: NSMenuItem) { guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } guard let config = parent.contextMenuConfig else { return } let track = tracks[tableView.clickedRow] config.onAddToLastPlaylist?(track) } @objc func removeFromPlaylist(_ sender: NSMenuItem) { guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } guard let config = parent.contextMenuConfig else { return } let track = tracks[tableView.clickedRow] config.onRemoveFromPlaylist?(track) } ``` - [ ] **Step 4: Build — expect ContentView compile errors (call site not yet updated)** ``` xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:" ``` Expected: errors in `ContentView.swift` about removed parameters. `TrackTableView.swift` itself should be clean. - [ ] **Step 5: Commit** ```bash git add Music/Views/TrackTableView.swift git commit -m "refactor: replace TrackTableView playlist params with TrackContextMenuConfig" ``` --- ### Task 4: Add `contextMenuConfig` to `PlayerControlsView` **Files:** - Modify: `Music/Views/PlayerControlsView.swift:22` (add param after `onNowPlayingTap`) - Modify: `Music/Views/PlayerControlsView.swift:112-118` (apply modifier to `nowPlayingSection`) - [ ] **Step 1: Add the new parameter to `PlayerControlsView`** In `Music/Views/PlayerControlsView.swift`, find line 22: ```swift let onNowPlayingTap: () -> Void ``` Add after it: ```swift let onNowPlayingTap: () -> Void var contextMenuConfig: TrackContextMenuConfig? = nil ``` - [ ] **Step 2: Apply the modifier to `nowPlayingSection`** In `PlayerControlsView.swift`, find the closing of `nowPlayingSection` (lines ~112–118): ```swift .contentShape(Rectangle()) .onTapGesture { if currentTrack != nil { onNowPlayingTap() } } } ``` Replace with: ```swift .contentShape(Rectangle()) .onTapGesture { if currentTrack != nil { onNowPlayingTap() } } .trackContextMenu(track: currentTrack, config: contextMenuConfig) } ``` - [ ] **Step 3: Build — expect ContentView errors only** ``` xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:" ``` Expected: only `ContentView.swift` errors remain (old params still passed there). - [ ] **Step 4: Commit** ```bash git add Music/Views/PlayerControlsView.swift git commit -m "feat: add contextMenuConfig param to PlayerControlsView" ``` --- ### Task 5: Update `ContentView` — wire both call sites **Files:** - Modify: `Music/ContentView.swift:181-194` (TrackTableView call site) - Modify: `Music/ContentView.swift:333-362` (PlayerControlsView call site) - [ ] **Step 1: Replace the TrackTableView playlist params with `contextMenuConfig`** In `Music/ContentView.swift`, find lines 181–200 (inside `TrackTableView(...)`): ```swift playlists: playlist.playlists, lastUsedPlaylistName: playlist.lastUsedPlaylistName, selectedPlaylist: playlist.selectedPlaylist, onAddToPlaylist: { track, targetPlaylist in try? playlist.addTrack(track, to: targetPlaylist) }, onAddToLastPlaylist: { track in try? playlist.addTrackToLastUsedPlaylist(track) }, onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in if let selected = playlist.selectedPlaylist { try? playlist.removeTrack(track, from: selected) } } : nil, ``` Replace with: ```swift contextMenuConfig: TrackContextMenuConfig( playlists: playlist.playlists, lastUsedPlaylistName: playlist.lastUsedPlaylistName, selectedPlaylist: playlist.selectedPlaylist, onAddToPlaylist: { track, targetPlaylist in try? playlist.addTrack(track, to: targetPlaylist) }, onAddToLastPlaylist: { track in try? playlist.addTrackToLastUsedPlaylist(track) }, onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in if let selected = playlist.selectedPlaylist { try? playlist.removeTrack(track, from: selected) } } : nil ), ``` - [ ] **Step 2: Pass `contextMenuConfig` to `PlayerControlsView`** In `Music/ContentView.swift`, find the `PlayerControlsView(...)` block (lines ~333–362). Add `contextMenuConfig` after `onNowPlayingTap`: ```swift onNowPlayingTap: { scrollToPlayingTrigger = UUID() }, contextMenuConfig: TrackContextMenuConfig( playlists: playlist.playlists, lastUsedPlaylistName: playlist.lastUsedPlaylistName, selectedPlaylist: playlist.selectedPlaylist, onAddToPlaylist: { track, targetPlaylist in try? playlist.addTrack(track, to: targetPlaylist) }, onAddToLastPlaylist: { track in try? playlist.addTrackToLastUsedPlaylist(track) }, onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in if let selected = playlist.selectedPlaylist { try? playlist.removeTrack(track, from: selected) } } : nil ) ``` - [ ] **Step 3: Build cleanly** ``` xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED" ``` Expected: `BUILD SUCCEEDED` with no errors. - [ ] **Step 4: Run the full test suite** ``` xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "Test.*passed|Test.*failed|error:|BUILD" ``` Expected: all tests pass, including `TrackContextMenuConfigTests`. - [ ] **Step 5: Manual verification** Run the app. With at least one playlist created: 1. **Track table:** right-click any track row → confirm the menu shows "Add to [last]" / "Add to Playlist" submenu / "Remove from Playlist" (when in a playlist view). Behaviour must be unchanged. 2. **Bottom controls:** play a track, then right-click anywhere on the now-playing area (album art + title + artist) → confirm the same menu appears. 3. **No track playing:** right-click the empty now-playing area → confirm no menu appears (the modifier is a no-op when `currentTrack` is nil). - [ ] **Step 6: Commit** ```bash git add Music/ContentView.swift git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table" ```