--- phase: 18-batch-edit plan: 02 type: execute wave: 2 depends_on: ["18-01"] files_modified: - frontend/src/components/track-details/track-details.ts - frontend/src/components/track-list/track-list.ts - frontend/src/components/cover-grid/cover-grid.ts - frontend/src/components/queue-panel/queue-panel.ts - frontend/src/components/playlist-details/playlist-details.ts autonomous: false requirements: [BATCH-01, BATCH-02, BATCH-03, BATCH-04] must_haves: truths: - "Selecting 2+ tracks and clicking Track Details opens a batch summary view showing 'N tracks selected' header" - "Each field shows shared value (if identical across tracks) or 'Multiple values' placeholder (if different)" - "In edit mode, typing into a field marks it dirty; only dirty fields are sent as TagChanges on save" - "Clearing a field (empty string) after interaction is a distinct state from 'untouched' — it sends the clear to all tracks" - "A confirmation dialog appears before save showing which fields will be set/cleared and the track count" - "During batch save, a progress bar and 'N of M tracks' counter are visible inside the dialog" - "The cancel button stops the batch after the current track; already-written tracks keep changes" - "Partial failures show a summary with success count and per-failure details" - "Cover art can be set or cleared for all selected tracks at once" - "After batch save completes, dialog returns to read-only summary with refreshed data" artifacts: - path: "frontend/src/components/track-details/track-details.ts" provides: "Batch mode: multi-track show(), summary view, three-state editing, confirmation, progress, cover art" contains: "showBatch" - path: "frontend/src/components/track-list/track-list.ts" provides: "Updated context menu handler passing all selected filePaths to track-details" contains: "showBatch" - path: "frontend/src/components/cover-grid/cover-grid.ts" provides: "Updated context menu handler passing all selected filePaths to track-details" contains: "showBatch" - path: "frontend/src/components/queue-panel/queue-panel.ts" provides: "Updated context menu handler passing all selected filePaths to track-details" contains: "showBatch" - path: "frontend/src/components/playlist-details/playlist-details.ts" provides: "Updated context menu handler passing all selected filePaths to track-details" contains: "showBatch" key_links: - from: "frontend/src/components/track-details/track-details.ts" to: "frontend/wailsjs/go/tagwriter/TagWriter.js" via: "import { BatchWriteTrackTags, CancelBatchWrite }" pattern: "BatchWriteTrackTags" - from: "frontend/src/components/track-list/track-list.ts" to: "frontend/src/components/track-details/track-details.ts" via: "trackDetailsDialog.showBatch(tracks, coverArt)" pattern: "showBatch" - from: "frontend/src/components/track-details/track-details.ts" to: "frontend/src/events.ts" via: "EventsOn(Events.BatchWriteProgress, ...)" pattern: "BatchWriteProgress" --- Adapt the track-details component for multi-track batch editing with three-state field model, progress UI, confirmation dialog, and batch cover art. Update all 4 view components to call the new batch API when multiple tracks are selected. Purpose: Users need to efficiently edit shared metadata across multiple tracks — the dialog must show merged field values, support implicit three-state editing (keep/set/clear), provide a confirmation guard, show live progress during writes, handle partial failures gracefully, and support batch cover art operations. Output: Fully functional batch edit mode in track-details component, all 4 views wired to use it. @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/18-batch-edit/18-CONTEXT.md @.planning/phases/18-batch-edit/18-01-SUMMARY.md @.planning/phases/17-single-track-edit/17-02-SUMMARY.md @frontend/src/components/track-details/track-details.ts @frontend/src/components/track-list/track-list.ts @frontend/src/components/cover-grid/cover-grid.ts @frontend/src/components/queue-panel/queue-panel.ts @frontend/src/components/playlist-details/playlist-details.ts @frontend/src/events.ts @frontend/wailsjs/go/tagwriter/TagWriter.js @frontend/wailsjs/go/tagwriter/TagWriter.d.ts @frontend/src/store/library-store.ts From backend/tagwriter/pipeline.go (created by Plan 01): ```go type BatchFailure struct { FilePath string `json:"filePath"` Error string `json:"error"` } type BatchResult struct { Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Cancelled bool `json:"cancelled"` Failures []BatchFailure `json:"failures"` } func (tw *TagWriter) BatchWriteTrackTags(filePaths []string, changes TagChanges) BatchResult func (tw *TagWriter) CancelBatchWrite() ``` Wails bindings (created by Plan 01): ```typescript // TagWriter.d.ts export function BatchWriteTrackTags(arg1:Array,arg2:tagwriter.TagChanges):Promise; export function CancelBatchWrite():Promise; ``` BatchWriteProgress event payload shape: ```typescript { current: number, total: number, filePath: string, succeeded: number, failed: number } ``` From frontend/src/components/track-details/track-details.ts (existing): ```typescript export interface CoverArtUrls { coverArtPath: string; coverArtSmall: string; coverArtMedium: string; coverArtLarge: string; } interface MetadataField { key: string; label: string; value: string; editable: boolean; type: 'text' | 'number'; } export class TrackDetails extends LitElement { show(track: library.Track, coverArt?: CoverArtUrls): void; close(): void; // State: editing, editValues, saving, errorMessage, pendingCoverArt, clearCoverArt // Methods: saveEdit, buildChanges, selectCoverArt, removeCoverArt, getEditValue, onEditInput } ``` From library.Track (Wails model): ```typescript class Track { TrackName: string; ArtistName: string; TrackLength: string; FilePath: string; TrackNumber: number; DiscNumber: number; Album: string; Genre: string[]; Year: number; Composer: string; FileType: string; // ... more fields } ``` From each view's context menu handler (identical pattern in all 4): ```typescript case 'track-details': this.openTrackDetails(filePaths[0]!); break; ``` Each view has: `this.selection.getSelectedKeysOrdered()` returning `string[]` of file paths. Each view has: `resolveCoverArt(albumName: string): CoverArtUrls | null` Each view has: `@query('track-details') private trackDetailsDialog: TrackDetails` Each view has access to the tracks array for resolving file paths to Track objects. From frontend/src/events.ts + Wails runtime: ```typescript import { Events } from '../events'; import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime'; // Usage: EventsOn(Events.BatchWriteProgress, (data) => { ... }) ``` Task 1: Add batch mode to track-details component frontend/src/components/track-details/track-details.ts This is the core task. Extend the existing `track-details` component to handle multi-track batch editing. The component already has full single-track edit infrastructure — batch mode adapts it. **New state properties** (add to the existing @state() declarations): ```typescript @state() private batchMode = false; @state() private batchTracks: library.Track[] = []; @state() private batchFilePaths: string[] = []; @state() private batchCoverArt: CoverArtUrls | null = null; // shared cover art, or null if mixed @state() private batchCoverArtMixed = false; // true if tracks have different cover art @state() private batchProgress: { current: number; total: number } | null = null; @state() private batchResult: { succeeded: number; failed: number; cancelled: boolean; failures: Array<{ filePath: string; error: string }> } | null = null; @state() private showConfirmation = false; ``` **New imports:** ```typescript import { BatchWriteTrackTags, CancelBatchWrite } from '@go/tagwriter/TagWriter'; import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime'; import { Events } from '../../events'; ``` **1. New public API — `showBatch()`:** ```typescript showBatch( tracks: library.Track[], coverArt: CoverArtUrls | null, coverArtMixed: boolean, ): void { ``` - Sets `this.batchMode = true`, `this.batchTracks = tracks`, `this.batchFilePaths = tracks.map(t => t.FilePath)`. - Sets `this.batchCoverArt = coverArt`, `this.batchCoverArtMixed = coverArtMixed`. - Clears single-track state: `this.track = null`. - Resets edit state: `editing = false`, `editValues = {}`, `errorMessage = ''`, `batchProgress = null`, `batchResult = null`, `showConfirmation = false`. - Cleans up pending cover art. - Opens dialog same as `show()`. **2. Merged field values for summary/edit:** Add a private method `getMergedFields()` that returns `MetadataField[]` with merged values: ```typescript private getMergedFields(): MetadataField[] { ``` For each of the 8 editable fields (title, artist, album, genre, year, trackNumber, discNumber, composer), extract the value from every track in `batchTracks`. If all values are identical → the field value is that shared value. If values differ → the field value is `''` (empty string) with a flag indicating mixed. Return MetadataField objects with the same structure as the single-track version. Add an optional `mixed` boolean to the MetadataField interface: ```typescript interface MetadataField { key: string; label: string; value: string; editable: boolean; type: 'text' | 'number'; mixed?: boolean; // true if values differ across batch tracks } ``` For the field value extraction, use the same mapping as in `renderDetailFields`: - title → `t.TrackName` - artist → `t.ArtistName` - album → `t.Album` - genre → `(t.Genre ?? []).join(', ')` - year → `t.Year ? String(t.Year) : ''` - composer → `t.Composer ?? ''` - trackNumber → `t.TrackNumber ? String(t.TrackNumber) : ''` - discNumber → `t.DiscNumber ? String(t.DiscNumber) : ''` **3. Render: summary/read-only view for batch mode:** Modify the `override render()` method. When `batchMode && !editing && !batchProgress && !batchResult`: - Header: `${this.batchTracks.length} tracks selected` (instead of track title). - Cover art section: if `batchCoverArtMixed` show a placeholder with text like "Multiple cover arts" and a count. If shared, show the actual cover art (same as single-track). - For each merged field: if `mixed` show "N different values" in gray italic. If shared, show the actual value. - Buttons: "Edit" and "Close" (same as single-track read-only). - Do NOT show non-editable fields like file path, file type, bitrate, etc. (not meaningful for batch). **4. Render: edit mode for batch:** When `batchMode && editing && !batchProgress`: - Header: `Editing ${this.batchTracks.length} tracks`. - Cover art section with edit controls (same as single-track: click to pick, X to remove). If mixed, show placeholder; if shared, show art. pendingCoverArt and clearCoverArt work the same. - For each merged field: render an input. Pre-populate with the shared value (if not mixed). If mixed, show empty input with `placeholder="Multiple values"` in gray italic style. - Three-state field model is implicit via the existing `editValues` + `getEditValue` pattern: - **Keep original:** user doesn't touch the field → key NOT in `editValues` → not sent in TagChanges. - **Set value:** user types → key IN `editValues` with the typed value → sent in TagChanges. - **Clear field:** user types then deletes everything → key IN `editValues` with `""` → sent in TagChanges (the value is empty string, which the backend writes as clearing the field). - The existing `onEditInput` handler already adds the key to `editValues` on any input event, which is exactly the dirty-tracking mechanism needed. - Buttons: "Cancel" and "Save" (same as single-track edit mode). **5. Confirmation dialog:** When user clicks "Save" in batch edit mode, set `this.showConfirmation = true` instead of saving immediately. Render a confirmation overlay within the dialog: ```html

Apply changes to ${this.batchTracks.length} tracks?

``` Build the summary from `editValues`: for each key in editValues, show `"Set {label} to '{value}'"` or `"Clear {label}"` if value is empty. If cover art is pending: `"Set cover art"`. If clearCoverArt: `"Remove cover art"`. Style the overlay: position absolute, full dialog coverage, semi-transparent backdrop, centered card. **6. Batch save flow (`confirmSave`):** When user confirms: - Set `showConfirmation = false`. - Set `batchProgress = { current: 0, total: batchFilePaths.length }`. - Build TagChanges from `buildBatchChanges()` (new method, similar to `buildChanges` but for batch — only includes dirty fields, no diff against original since batch doesn't have a single original). - Register a Wails event listener for `BatchWriteProgress`: ```typescript const cleanup = EventsOn(Events.BatchWriteProgress, (data: any) => { this.batchProgress = { current: data.current, total: data.total }; }); ``` - Call `await BatchWriteTrackTags(this.batchFilePaths, changes)`. - After completion, call `EventsOff(Events.BatchWriteProgress)` (or use the cleanup function). - Store result in `this.batchResult`. - Set `batchProgress = null`. **New method `buildBatchChanges()`:** ```typescript private buildBatchChanges(): Record { const changes: Record = {}; // Same fieldMap as buildChanges but WITHOUT diff against original — // every key in editValues is a change. const fieldMap = [ { editKey: 'title', backendKey: 'title' }, { editKey: 'artist', backendKey: 'artist' }, { editKey: 'album', backendKey: 'album' }, { editKey: 'genre', backendKey: 'genre' }, { editKey: 'year', backendKey: 'year', transform: (v: string) => v ? parseInt(v, 10) : 0 }, { editKey: 'composer', backendKey: 'composer' }, { editKey: 'trackNumber', backendKey: 'track_number', transform: (v: string) => v ? parseInt(v, 10) : 0 }, { editKey: 'discNumber', backendKey: 'disc_number', transform: (v: string) => v ? parseInt(v, 10) : 0 }, ]; for (const { editKey, backendKey, transform } of fieldMap) { if (editKey in this.editValues) { const val = this.editValues[editKey]!; changes[backendKey] = transform ? transform(val) : val; } } // Cover art if (this.pendingCoverArt) { changes['cover_art'] = Array.from(new Uint8Array(this.pendingCoverArt.data)); } else if (this.clearCoverArt) { changes['cover_art'] = null; } return changes; } ``` **7. Progress UI:** When `batchProgress` is not null, render: ```html
${batchProgress.current} of ${batchProgress.total} tracks
``` The `cancelBatchWrite` handler calls `CancelBatchWrite()` (the Wails binding). Style the progress bar: full-width track with rounded corners, fill uses the app's accent color (`var(--yj-accent, #4a9eff)`), smooth width transition. **8. Results view:** When `batchResult` is not null, render: - If `batchResult.cancelled`: "Batch cancelled — {succeeded} of {total} tracks updated" - Else if `batchResult.failed === 0`: "All {succeeded} tracks updated successfully" - Else: "{succeeded} tracks updated, {failed} failed" - If failures exist, show an expandable list of failures (file name + error). - "Close" button that resets to summary view with refreshed data. After displaying results and user clicks "Close": - Reset `batchResult = null`, `batchProgress = null`. - Re-fetch tracks from `libraryStore.getTracks()` and albums from `libraryStore.getAlbums()`. - Re-resolve `batchTracks` from refreshed data (filter tracks by batchFilePaths). - Re-resolve cover art (check if all tracks now share the same album art). - Return to read-only summary view with updated data. **9. CSS additions:** Add styles for: - `.batch-header` — larger text showing track count - `.mixed-value` — gray italic placeholder text for "Multiple values" and "N different values" - `.confirmation-overlay` — absolute positioned overlay with backdrop - `.confirmation-content` — centered card with padding - `.confirmation-summary` — list of changes - `.batch-progress` — progress section layout - `.progress-bar-track` — progress bar track (gray background, rounded) - `.progress-bar-fill` — progress bar fill (accent color, transition: width 0.3s) - `.batch-result` — results section - `.failure-list` — expandable failure details **10. Close/cleanup behavior:** Override `close()` to also call `CancelBatchWrite()` if `batchProgress` is not null (closing during progress cancels the batch). Reset all batch state. **CRITICAL IMPLEMENTATION NOTES:** - The single-track `show()` method remains unchanged — it sets `batchMode = false`. - All existing single-track rendering and behavior continues to work when `batchMode === false`. - The render method should branch on `batchMode` early to avoid complex conditional nesting. Consider helper methods like `renderBatchSummary()`, `renderBatchEdit()`, `renderBatchProgress()`, `renderBatchResult()`. - Follow existing code style: arrow function handlers, @state() decorators, html tagged template literals, `override` keyword. - Import type for type-only imports.
cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "showBatch\|batchMode\|batchProgress\|buildBatchChanges\|confirmSave\|cancelBatchWrite\|renderBatchSummary\|BatchWriteTrackTags" src/components/track-details/track-details.ts Track-details component supports batch mode with: - showBatch() public API for multi-track entry - Read-only summary showing merged field values with "Multiple values" for mixed fields - Edit mode with implicit three-state field model (keep/set/clear via dirty tracking) - Confirmation dialog listing all pending changes before save - Progress bar with "N of M" counter during batch write, wired to BatchWriteProgress events - Cancel button calling CancelBatchWrite - Results summary showing success/failure counts with expandable failure details - Batch cover art: pick, preview, or clear for all tracks - Post-save data refresh returning to updated summary view
Task 2: Update all view context menu handlers for batch mode frontend/src/components/track-list/track-list.ts, frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/queue-panel/queue-panel.ts, frontend/src/components/playlist-details/playlist-details.ts Update the `'track-details'` case in `onContextMenuAction` for each of the 4 view components. Currently each does: ```typescript case 'track-details': this.openTrackDetails(filePaths[0]!); break; ``` Change to: ```typescript case 'track-details': if (filePaths.length === 1) { this.openTrackDetails(filePaths[0]!); } else { this.openBatchTrackDetails(filePaths); } break; ``` Add a new private method `openBatchTrackDetails(filePaths: string[])` to each view: **For track-list.ts:** ```typescript private openBatchTrackDetails(filePaths: string[]) { const tracks = filePaths .map((fp) => this.tracks.find((t) => t.FilePath === fp)) .filter((t): t is library.Track => t != null); if (tracks.length === 0) return; // Resolve cover art: check if all tracks share the same album. const albumNames = new Set(tracks.map((t) => t.Album)); let coverArt: CoverArtUrls | null = null; let coverArtMixed = false; if (albumNames.size === 1) { const albumName = [...albumNames][0]!; coverArt = this.resolveCoverArt(albumName); } else { coverArtMixed = true; } this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed); } ``` **For cover-grid.ts:** Same pattern but tracks come from `this.currentTracks` or `this.albumTracks` depending on current view state. Check how cover-grid stores its track list — it may use a different property name. The cover-grid has tracks available via album detail tracks. Look for where tracks are stored and use the same source. **For queue-panel.ts:** Queue panel uses indices, not file paths. The existing `openTrackDetails(index: number)` resolves queue tracks by index. For batch, the context menu handler has `indices = this.selection.getSelectedKeysOrdered()` (which are indices for queue). Map indices to queue tracks: ```typescript private openBatchTrackDetails(indices: number[]) { const queueTracks = queueStore.tracks; const tracks = indices .map((i) => queueTracks[i]) .filter((t): t is QueueTrack => t != null); // QueueTrack has different shape than library.Track — need to resolve // from library store. QueueTrack has filePath. // ... resolve tracks from library store or adapt... } ``` **IMPORTANT for queue-panel:** The queue panel's selection uses numeric indices, not file paths. The `onContextMenuAction` handler may already convert to file paths or indices. Check the actual code carefully. The queue-panel context menu handler likely already has access to `filePaths` or can derive them from queue tracks. Each QueueTrack has a `filePath` field. Resolve the library.Track objects from `libraryStore.getTracks()` (await) or use the queue tracks' metadata directly. The key insight: `showBatch` needs `library.Track[]` objects — queue panel must resolve them. **For playlist-details.ts:** Similar to track-list. Has its own tracks array. Use the same pattern. **Each view's `openBatchTrackDetails` method must:** 1. Resolve file paths to `library.Track[]` objects from the view's available track data 2. Determine cover art state: if all tracks share same album → resolve shared art. If different albums → coverArtMixed = true. 3. Call `this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed)` The `resolveCoverArt(albumName)` method already exists on each view and can be reused. cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "openBatchTrackDetails\|showBatch" src/components/track-list/track-list.ts src/components/cover-grid/cover-grid.ts src/components/queue-panel/queue-panel.ts src/components/playlist-details/playlist-details.ts All 4 view components (track-list, cover-grid, queue-panel, playlist-details) branch on selection count in the track-details context menu action: 1 track → existing openTrackDetails, 2+ tracks → new openBatchTrackDetails that resolves tracks, determines cover art state, and calls showBatch(). Task 3: Verify complete batch edit flow frontend/src/components/track-details/track-details.ts Human verification of the complete batch edit flow. Run `wails dev` and test: 1. **Batch summary view:** In the track list, select 3+ tracks with different metadata. Right-click → Track Details. Verify header shows "N tracks selected", shared fields show value, mixed fields show "Multiple values" placeholder. 2. **Three-state editing:** Click "Edit". Verify shared fields pre-populated, mixed fields have placeholder, typing marks fields dirty, untouched fields are not sent on save, clearing a field sends empty. 3. **Confirmation dialog:** Click "Save". Verify confirmation overlay shows field changes and track count. 4. **Progress:** Click "Apply" on 5+ tracks. Verify progress bar advances and counter updates. 5. **Results:** After batch completes, verify success/failure summary. Close returns to updated summary. 6. **Cover art:** Pick/remove in batch mode applies to all tracks. 7. **Single-track unchanged:** Select 1 track → Track Details works as before. cd frontend && npx tsc --noEmit && echo "TYPECHECK OK" All batch edit user flows verified: summary view, three-state editing, confirmation, progress, results, cover art, and single-track regression check passes.
1. `cd frontend && npx tsc --noEmit` — TypeScript compiles with no errors 2. `cd backend && go build ./...` — backend still compiles (no regressions) 3. Select 2+ tracks → Track Details → shows batch summary (not single track) 4. Select 1 track → Track Details → shows single track (existing behavior unchanged) 5. Batch edit → save → all tracks updated with correct field values 6. Progress bar visible during batch save of 5+ tracks 7. Cover art batch set/clear works across all selected tracks - Batch mode activates when 2+ tracks are selected from any of the 4 views - Summary view correctly shows shared vs mixed field values - Three-state field model works: untouched fields preserved, typed fields set, cleared fields clear - Confirmation dialog appears before batch save with change summary - Progress bar and track counter visible during batch write - Partial failures collected and displayed - Cancel stops remaining tracks - Cover art pick/clear applies to all selected tracks - Single-track mode unchanged (no regression) - All 4 views correctly dispatch to showBatch for multi-select After completion, create `.planning/phases/18-batch-edit/18-02-SUMMARY.md`