Files
yellowjacket/.opencode/plans/split-cover-grid.md
T
yonlu 1221a403cf cover grid refactor
-split component into several files
2026-02-24 09:49:34 -05:00

412 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Split `cover-grid.ts` + Extract Shared Context Menu
Addresses refactoring catalog #4 (split `cover-grid.ts`, 3774 lines) and partially addresses #8 (49× `as any` casts on popups).
## Current State
`frontend/src/components/cover-grid/cover-grid.ts` is the largest frontend file at 3774 lines. It contains a single `CoverGrid` LitElement that handles:
- Virtualized album grid rendering (single + split mode with inline dropdown)
- Album/track selection (custom inline logic with Ctrl/Shift/range)
- Context menus (album + track, with playlist submenu) — **duplicated across 6 components**
- Drag-and-drop source (albums + tracks)
- Sort controls (toolbar + dropdown)
- Ctrl+scroll zoom
- Scroll position save/restore (index-based + pixel-based resize-aware)
- Transition overlays (DOM snapshots during layout transitions)
- Album filtering/sorting (memoized)
- 319 lines of CSS
The context menu logic is copy-pasted into 6 components: `cover-grid.ts`, `track-list.ts`, `playlist-view.ts`, `queue-panel.ts`, `genres-view.ts`, `artists-view.ts`. Each duplicates ~200 lines of state, open/close methods, submenu timers, document event listeners, and render templates.
---
## Guiding Principles
1. **Extract logic modules, not sub-components.** The grid is one visual component. Splitting it into multiple custom elements would create artificial boundaries and state-forwarding complexity. Instead, extract plain TS files (classes/functions) that the component imports.
2. **Follow existing patterns.** The codebase has `SelectionController` in `utils/`, `drag-controller.ts`, `drag-image.ts`. New extractions follow these conventions.
3. **Shared context menu is the highest-value extraction.** Duplicated across 6 components, it benefits the whole codebase.
4. **Don't over-split.** Lifecycle methods, render methods, and data loading are inherently tied to component state and stay in the main file. Some code density is fine for orchestration.
---
## Part 1: Types and Constants → `cover-grid-types.ts`
**New file:** `frontend/src/components/cover-grid/cover-grid-types.ts` (~85 lines)
**Move from `cover-grid.ts` lines 49-132:**
- `ContextMenuTarget` discriminated union type
- `GridEntry` interface
- `SCROLL_DEBOUNCE_MS`, `ZOOM_STEP` constants
- `SORT_FIELD_KEY`, `SORT_DIR_KEY` localStorage key constants
- `AlbumSortField` type, `SortDirection` type
- `AlbumSortOption` interface
- `ALBUM_SORT_OPTIONS` array (3 sort options with comparator functions)
**Rationale:** Pure data definitions with zero component dependency. Multiple files in the directory will import these (scroll-manager needs `SCROLL_DEBOUNCE_MS`, main file needs sort options, etc.).
---
## Part 2: CSS Styles → `cover-grid-styles.ts`
**New file:** `frontend/src/components/cover-grid/cover-grid-styles.ts` (~270 lines)
**Move from `cover-grid.ts` lines 343-661**, minus the context-menu styles (~47 lines at 615-661) which move to the shared context menu utility in Part 4.
Export as a tagged template:
```typescript
import { css } from 'lit';
export const coverGridStyles = css`...`;
```
Main file uses:
```typescript
import { coverGridStyles } from './cover-grid-styles.js';
import { contextMenuStyles } from '@utils/context-menu-controller.js';
// ...
static override styles = [coverGridStyles, contextMenuStyles];
```
**Rationale:** Standard Lit pattern for large style blocks. Reduces visual noise. The style array composition pattern is idiomatic Lit.
---
## Part 3: Scroll Manager → `scroll-manager.ts`
**New file:** `frontend/src/components/cover-grid/scroll-manager.ts` (~450 lines)
**Move from `cover-grid.ts`:**
- Scroll position persistence: `restoreScrollPosition()` (line 1580), `onVisibilityChanged` (line 1607)
- Resize-aware scroll preservation: `setupResizeObserver()` (line 1668), `captureFocusPoint()` (line 1803)
- Layout helpers: `getColumnCount()` (line 1865), `getContainerWidth()` (line 1887), `getGridRowWidth()` (line 1901), `getCaratOffset()` (line 1916), `computeSplitIndex()` (line 1949)
- Transition overlay: `captureOverlay()` (line 2039), `removeOverlay()` (line 2090)
- Scroll positioning: `awaitBeforeLayout()` (line 2116), `computeAdjustedScrollTop()` (line 2135), `restoreScrollTop()` (line 2197), `scrollToShowDropdown()` (line 2265)
- Associated fields: `resizeObserver`, `resizeDebounceTimer`, `pendingFocus`, `currentColumnCount`, `isResizing`, `savedScrollTop`, `needsScrollRestore`, `showDropdownAfterRestore`, `scrollRestoreGeneration`, `scrollRestoreResolved`, `savedAlbumViewportOffset`, `transitionOverlay`, `scrollDebounceTimer`
**Shape:** Plain class with a host interface (not a ReactiveController — scroll management is imperative/async, not reactive).
```typescript
export interface ScrollManagerHost {
readonly libraryCtrl: LibraryController;
readonly cachedFilteredAlbums: library.Album[];
readonly expandedAlbumId: number | null;
readonly expandedTracks: library.Track[];
readonly splitMode: boolean;
readonly splitIndex: number;
readonly cardWidth: number;
readonly cardHeight: number;
readonly cardTextHeight: number;
shadowRoot: ShadowRoot | null;
updateComplete: Promise<boolean>;
requestUpdate(): void;
}
export class ScrollManager {
constructor(host: ScrollManagerHost, gridConstants: GridConstants);
// Called from component lifecycle
setup(): void; // from connectedCallback
teardown(): void; // from disconnectedCallback
// Scroll save/restore
onVisibilityChanged(e: VisibilityChangedEvent): void;
restoreScrollPosition(): void;
// Resize handling
setupResizeObserver(): void;
// Split/single mode transitions
captureOverlay(): void;
removeOverlay(): void;
computeAdjustedScrollTop(): number;
async restoreScrollTop(target: number): Promise<void>;
async scrollToShowDropdown(): Promise<void>;
awaitBeforeLayout(): Promise<void>;
// Layout geometry
getColumnCount(): number;
getContainerWidth(): number;
getGridRowWidth(): number;
getCaratOffset(): number;
computeSplitIndex(): number;
// State exposed to component
needsScrollRestore: boolean;
showDropdownAfterRestore: boolean;
savedScrollTop: number;
savedAlbumViewportOffset: number | null;
isResizing: boolean;
splitIndex: number;
}
```
**Rationale:** Scroll management is the largest concern (~800 raw lines, consolidated to ~450 without the grid constants that stay on the component). It's completely self-contained — reads component state but doesn't modify selection, context menus, or rendering. The host interface decouples it from the concrete class. A plain class (not ReactiveController) is honest about the imperative nature of scroll management.
---
## Part 4: Shared Context Menu Controller → `utils/context-menu-controller.ts`
**New file:** `frontend/src/utils/context-menu-controller.ts` (~200 lines)
This is the highest cross-cutting value extraction. The same context menu pattern is duplicated in 6 components.
**Extract the common pattern from all 6 components:**
```typescript
import type { ReactiveController, ReactiveControllerHost } from 'lit';
export interface ContextMenuHost extends ReactiveControllerHost {
// Query accessors — each component provides its own popup element refs
getContextMenuPopup(): HTMLElement | undefined;
getPlaylistSubmenuPopup(): HTMLElement | undefined;
updateComplete: Promise<boolean>;
shadowRoot: ShadowRoot | null;
}
export class ContextMenuController implements ReactiveController {
// Reactive state (component reads these for rendering)
contextMenuOpen = false;
playlistSubmenuOpen = false;
playlistFilePaths: string[] = [];
constructor(host: ContextMenuHost);
// Lifecycle — registers/removes document-level listeners
hostConnected(): void;
hostDisconnected(): void;
// Actions
openAt(clientX: number, clientY: number): void;
close(): void;
showPlaylistSubmenu(filePaths: string[]): Promise<void>;
closePlaylistSubmenu(): void;
onPlaylistActionComplete(): void;
}
```
**Also extract** shared context menu CSS styles as:
```typescript
export const contextMenuStyles = css`
#context-menu { ... }
.context-menu-panel { ... }
wa-dropdown-item { ... }
.submenu-item { ... }
.submenu-arrow { ... }
#playlist-submenu { ... }
`;
```
**What stays in each component:**
- The `renderContextMenu()` method — menu items differ per component (cover-grid has conditional "Track Details", queue-panel has "Remove" instead of "Add to Queue", etc.)
- The `onContextMenuAction(action)` handler — file path resolution differs per component
- The `@query` decorators for popup elements (passed to controller via host interface)
**Components to update (6):**
1. `cover-grid.ts` — Remove ~200 lines of inline context menu code
2. `track-list.ts` — Remove ~200 lines
3. `playlist-view.ts` — Remove ~200 lines (keep the second playlist-level context menu as-is or also migrate)
4. `queue-panel.ts` — Remove ~200 lines
5. `genres-view.ts` — Remove ~200 lines
6. `artists-view.ts` — Remove ~200 lines
**Bonus:** All 49× `(popup as any).anchor = ...` and `(popup as any).active = ...` casts are now centralized in one file. This partially addresses catalog item #8 — adding proper typing to the controller's internals eliminates the `as any` from all 6 components.
**Rationale:** ReactiveController is the right shape here (unlike ScrollManager) because it manages document-level event listeners tied to the component lifecycle via `hostConnected`/`hostDisconnected`. This matches the existing `SelectionController` pattern in `utils/`.
---
## Part 5: Album Selection Manager → `album-selection.ts`
**New file:** `frontend/src/components/cover-grid/album-selection.ts` (~250 lines)
**Move from `cover-grid.ts`:**
- Album selection: `selectAlbumRange()` (line 2378), `getSelectedAlbumFilePaths()` (line 2398), `getContextMenuAlbumFilePaths()` (line 2422), `getAlbumFilePaths()` (line 2449)
- Drag cache: `warmAlbumFilePathCache()` (line 2471), `getCachedSelectedAlbumFilePaths()` (line 2505), `albumFilePathCache` Map
- Track selection: `selectTrackRange()` (line 2528), `getSelectedTrackFilePaths()` (line 2547)
- Dropdown coupling: `closeDropdown()` (line 2561), `openDropdown()` (line 2575), `syncDropdownToSelection()` (line 2607)
**Shape:**
```typescript
export class AlbumSelectionManager {
selectedAlbums = new Set<number>();
selectedTracks = new Set<string>();
expandedAlbumId: number | null = null;
expandedTracks: library.Track[] = [];
lastSelectedAlbumIndex: number | null = null;
lastSelectedTrackIndex: number | null = null;
private albumFilePathCache = new Map<number, string[]>();
// Album selection
selectAlbumRange(from: number, to: number, filteredAlbums: library.Album[]): Set<number>;
async getSelectedAlbumFilePaths(albums: library.Album[]): Promise<string[]>;
async getContextMenuAlbumFilePaths(contextMenuAlbumId: number | null, albums: library.Album[]): Promise<string[]>;
// Drag cache
async warmCache(albums: library.Album[]): Promise<void>;
getCachedSelectedPaths(albums: library.Album[]): string[];
// Track selection
selectTrackRange(from: number, to: number): Set<string>;
getSelectedTrackFilePaths(): string[];
// Dropdown
async openDropdown(album: library.Album): Promise<void>;
closeDropdown(): void;
syncDropdownToSelection(filteredAlbums: library.Album[]): void;
// Reset
clear(): void;
}
```
**Why not use the existing `SelectionController`?** The existing controller:
- Uses string keys only; album selection uses numeric IDs
- Manages a single selection set; cover-grid has separate album and track selections
- Has no concept of dropdown coupling (selecting 1 album → opens dropdown)
- Has no file path caching for drag
Retrofitting `SelectionController` to handle all of this would make it overly complex for its other consumers (`track-list.ts`, `playlist-view.ts`, `queue-panel.ts`). A dedicated manager for cover-grid's dual album/track model is cleaner.
**Rationale:** Selection state + file path resolution is a coherent concern (~250 lines) that doesn't need access to the DOM, making it easy to extract. The main component's event handlers become thin wrappers that call into this manager.
---
## What Stays in `cover-grid.ts`
After all extractions and improvements, the main file will be approximately **~1700 lines** (down from 3774):
| Section | ~Lines | Why it stays |
|---------|--------|-------------|
| Imports and class declaration | 60 | Structural |
| Properties, state, queries, controllers | 100 | Component-specific reactive state (fewer `@state` props) |
| Grid layout creation + memoization | 80 | Tightly coupled to virtualizer |
| Lifecycle (connectedCallback, disconnectedCallback, willUpdate, updated) | 350 | Orchestration — wires managers together (debug logs removed) |
| Dynamic size properties + zoom | 70 | Simple, component-specific |
| Data loading | 30 | Simple async fetch |
| Virtualizer item builders | 40 | Depends on component state (memoized) |
| Event handlers (album + track + drag) | 340 | Thin delegation to managers |
| Render methods | 430 | Templates reference component state |
| Sort toolbar logic | 120 | Small, self-contained |
~1700 lines is still substantial, but the *complexity* is dramatically reduced because the three hardest subsystems (scroll management, context menus, selection/file-path resolution) are encapsulated in dedicated modules. The remaining code is pure orchestration and rendering.
---
## What This Does NOT Do
- **Does not split into multiple custom elements** — Artificial component boundaries would add event-forwarding complexity for no UX benefit.
- **Does not refactor the split/single virtualizer architecture** — That's the core rendering strategy; changing it is a separate effort.
- **Does not retrofit `SelectionController` for albums** — The existing controller serves different consumers with simpler needs. See Part 5 rationale.
- **Does not touch `album-dropdown.ts`** — Already a well-scoped 410-line sub-component.
- **Does not extract drag handlers** — ~165 lines of glue code that delegates to existing `drag-controller.ts`/`drag-image.ts`. Diminishing returns.
---
## Part 6: Code Quality and Performance Improvements
These improvements are applied during the extraction steps that touch the relevant code. They don't change behavior — they make the same behavior more efficient and clean.
### 6a. Remove 13 `console.log` debug statements
**Lines:** 1133, 1151, 1192, 1226, 1303, 1320, 1328, 1390, 1432, 1437, 2158, 2176, 2345
The scroll restoration and transition overlay code contains 13 `console.log` calls that are clearly development debugging artifacts (e.g., `[willUpdate] exit split (tracks empty)`, `[updated] scroll restore start`, `[adjustScroll]`, `[restoreScrollTop] attempt ${i}`).
**Action:** Remove all 13 `console.log` calls. Keep the 3 `console.error` (actual failures) and 1 `console.warn` (retry exhaustion).
**Applied during:** Part 3 (scroll-manager extraction) and Part 5 lifecycle cleanup.
### 6b. Memoize `buildGridEntries()` — eliminates 3-5 redundant array allocations per render
**Problem:** `buildGridEntries()` allocates a new `GridEntry[]` array on every call. In split-mode rendering, it's called up to 5 times per render cycle:
- `getBeforeEntries()``buildGridEntries().slice(0, splitIndex)` (line 2003)
- `getAfterEntries()` called **twice** in `renderSplitGrid()` — once for `.length > 0` check (line 3612), once for `.items` (line 3616) — each rebuilding the full array
- `onVisibilityChanged` scroll handler also rebuilds it (line 1637)
There's even a placeholder comment on line 340: `// buildGridEntries() memoization cache.` — but no cache was ever implemented.
**Action:**
1. Cache the `GridEntry[]` result, keyed on `cachedFilteredAlbums` reference identity. Invalidate in `recomputeAlbumCache()`.
2. In `renderSplitGrid()`, compute `const afterEntries = this.getAfterEntries()` once and reuse for both the length check and the `.items` binding.
**Applied during:** Part 1 (types — `GridEntry` moves) and main file cleanup.
### 6c. Cache expanded album index — eliminates 6 redundant O(n) scans
**Problem:** `cachedFilteredAlbums.findIndex((a) => a.ID === this.expandedAlbumId)` appears at 6 call sites (lines 1077, 1364, 1813, 1919, 1959, 2276). Each is a linear scan of the album array for the same ID.
**Action:** Compute `expandedAlbumIndex` in `recomputeAlbumCache()` (or in `willUpdate` when `expandedAlbumId` changes). All 6 call sites become a direct property read. Invalidate when either `expandedAlbumId` or `cachedFilteredAlbums` changes.
**Applied during:** Part 3 (scroll-manager extraction — 4 of the 6 sites are in scroll code) and main file cleanup.
### 6d. Build `albumById` Map for O(1) selection lookups
**Problem:** `getSelectedAlbumFilePaths()` (line 2401) and `warmAlbumFilePathCache()` (line 2472) both call `this.albums.filter(a => selectedAlbums.has(a.ID))` to find selected albums — an O(n) scan of the full album list. `resolveTrackCoverArt()` (line 3181) does `this.albums.find(a => a.Name === albumName)` — an O(n) name-based scan that could also match the wrong album if names collide.
**Action:** Build a `Map<number, library.Album>` (keyed by album ID) when `albums` changes. Selection lookups iterate `selectedAlbums` and do O(1) map lookups. `resolveTrackCoverArt()` uses the map with `expandedAlbumId` instead of name-based search.
**Applied during:** Part 5 (album-selection extraction).
### 6e. Remove unnecessary `@state()` from 2 properties
**Problem:** 15 properties have `@state()`. Two don't need it:
- `playlistFilePaths` (line 695) — only rendered inside the playlist submenu, which is conditionally shown when `playlistSubmenuOpen` is true. Since `showPlaylistSubmenu()` sets `playlistFilePaths` before setting `playlistSubmenuOpen`, the reactive update from `playlistSubmenuOpen` will render with the correct paths. `playlistFilePaths` itself doesn't need to trigger a re-render.
- `splitIndex` (line 737) — only used to compute `getBeforeEntries()`/`getAfterEntries()`. It's always set before `splitMode` changes (which triggers the render), so it doesn't need independent reactivity.
**Action:** Remove `@state()` decorator from both. Make them plain private fields.
**Applied during:** Main file cleanup after extractions.
### 6f. Single-pass `onGridClick` path traversal
**Problem:** `onGridClick` (line 3071) calls `composedPath()` once, then iterates it twice with `.some()` — once for `.album-card` and once for `.album-dropdown`.
**Action:** Single loop checking both classes:
```typescript
for (const el of e.composedPath()) {
if (!(el instanceof HTMLElement)) continue;
if (el.classList.contains('album-card') ||
el.classList.contains('album-dropdown')) return;
}
```
**Applied during:** Main file cleanup.
### 6g. Use expanded album directly for cover art resolution
**Problem:** `resolveTrackCoverArt(albumName)` (line 3176) does an O(n) `.find()` on `this.albums` by `Name` to get cover art URLs. But we already know which album is expanded (`expandedAlbumId`), and all tracks in the dropdown belong to that album. Name-based lookup has a theoretical collision risk if two albums share the same name.
**Action:** Replace the name-based search with a direct lookup using `expandedAlbumId` and the `albumById` map from improvement 6d. Falls back gracefully if the album isn't found.
**Applied during:** Part 5 (album-selection extraction) or main file cleanup.
### 6h. Prune `albumFilePathCache` to prevent unbounded growth
**Problem:** The `albumFilePathCache` (Map<number, string[]>) is warmed when albums are selected and read during dragstart, but entries are never removed. Over a session, it grows without bound.
**Action:**
1. Clear the entire cache when `albums` changes (library rescan).
2. After `warmAlbumFilePathCache()` completes, remove entries whose album ID is no longer in `selectedAlbums`.
**Applied during:** Part 5 (album-selection extraction — the cache moves to `AlbumSelectionManager`).
---
## Execution Order
| Step | File(s) | Risk | Notes |
|------|---------|------|-------|
| 1 | `cover-grid-types.ts` | Minimal | Pure move, no logic changes |
| 2 | `cover-grid-styles.ts` | Minimal | Pure move, verify `static styles` array works |
| 3 | `utils/context-menu-controller.ts` | Medium | Widest blast radius — update 6 components |
| 4 | `album-selection.ts` + improvements 6d, 6g, 6h | Low | Contained to cover-grid |
| 5 | `scroll-manager.ts` + improvements 6a, 6c | Medium | Largest extraction, deep state interaction |
| 6 | Main file cleanup: improvements 6b, 6e, 6f | Low | After extractions, clean up remaining code |
| 7 | Verify: `pnpm build` + `pnpm exec tsc --noEmit` | — | Ensure no type errors or build failures |
Steps 1-2 are safe warmups. Step 3 has the highest cross-cutting value. Steps 4-5 are the structural wins for cover-grid itself. Step 6 is polish. Each step should be independently verifiable with `tsc --noEmit`.