docs(09): create phase plan — 5 plans in 3 waves for scan cancellation & keyboard shortcuts

This commit is contained in:
2026-03-06 13:20:35 -05:00
parent 71c200bc5b
commit 08ff00f029
6 changed files with 1793 additions and 2 deletions
+8 -2
View File
@@ -46,7 +46,13 @@
3. Default keyboard shortcuts work immediately after install — play/pause, next/prev, volume up/down, search focus, queue toggle, shuffle, repeat all respond to keys
4. User can open a settings UI, rebind any shortcut to a different key, and the new binding takes effect immediately — conflicts are warned about before saving
5. Keyboard shortcuts are context-aware — typing in a search box doesn't trigger player shortcuts (except Escape to blur)
**Plans:** TBD
**Plans:** 5 plans
Plans:
- [ ] 09-01-PLAN.md — Backend scan control (cancel/pause/resume methods, events, metrics)
- [ ] 09-02-PLAN.md — Backend shortcuts config + frontend keyboard shortcut service
- [ ] 09-03-PLAN.md — Frontend scan control UI (buttons, cancel dialog)
- [ ] 09-04-PLAN.md — Frontend shortcut settings UI (record-style capture, conflict detection)
- [ ] 09-05-PLAN.md — Integration verification checkpoint
### Phase 10: Tag Editing
**Goal:** Users can edit track metadata from within the app and changes are written to the actual audio files
@@ -118,7 +124,7 @@
| 6. SQL Consolidation & Code Quality | v1.0 | 3/3 | Complete | 2026-03-04 |
| 7. Backend Performance | v1.0 | 2/2 | Complete | 2026-03-05 |
| 8. Frontend Performance & UX | v1.0 | 4/4 | Complete | 2026-03-05 |
| 9. Scan Cancellation & Keyboard Shortcuts | v1.1 | 0/? | Not started | - |
| 9. Scan Cancellation & Keyboard Shortcuts | v1.1 | 0/5 | Planning complete | - |
| 10. Tag Editing | v1.1 | 0/? | Not started | - |
| 11. Smart Playlists | v1.1 | 0/? | Not started | - |
| 12. Gapless Playback & Crossfade | v1.1 | 0/? | Not started | - |
@@ -0,0 +1,337 @@
---
phase: 09-scan-cancellation-keyboard-shortcuts
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/events/events.go
- frontend/src/events.ts
- backend/library/library.go
- backend/library/scan_control.go
- backend/library/metrics.go
autonomous: true
requirements:
- SCAN-01
- SCAN-02
- SCAN-03
must_haves:
truths:
- "CancelScan() cancels the scan context and workers stop at their next checkpoint"
- "PauseScan() blocks workers via a channel; ResumeScan() unblocks them"
- "Cancelled scans skip orphan cleanup to avoid deleting unvisited files"
- "Batch commits use l.ctx (app context), not the cancellable scanCtx, so in-flight transactions complete"
- "ScanMetrics.Cancelled is true when a scan was cancelled"
artifacts:
- path: "backend/library/scan_control.go"
provides: "CancelScan, PauseScan, ResumeScan, IsScanActive, IsScanPaused methods"
exports: ["CancelScan", "PauseScan", "ResumeScan", "IsScanActive", "IsScanPaused"]
- path: "backend/events/events.go"
provides: "New scan control events"
contains: "LibraryScanCancelled"
- path: "backend/library/metrics.go"
provides: "Cancelled field on ScanMetrics"
contains: "Cancelled"
key_links:
- from: "backend/library/scan_control.go"
to: "backend/library/library.go"
via: "scanCancel context.CancelFunc and scanPauseCh channel on Library struct"
pattern: "l\\.scanCancel|l\\.scanPauseCh"
- from: "backend/library/library.go"
to: "backend/events/events.go"
via: "EventsEmit for scan lifecycle events"
pattern: "events\\.LibraryScan"
---
<objective>
Add scan cancellation and pause/resume to the Go backend. Thread a per-scan cancellable context through the existing scan pipeline, add pause/resume via a blocking channel, and expose Wails-bound methods for frontend control.
Purpose: Backend foundation for SCAN-01/02/03 — frontend buttons wire to these methods in Plan 03.
Output: scan_control.go with CancelScan/PauseScan/ResumeScan, modified Scan() method, new events, updated metrics.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
@backend/library/library.go
@backend/library/metrics.go
@backend/events/events.go
<interfaces>
<!-- Library struct (library.go:78-87) — add scan control fields here -->
type Library struct {
mu sync.Mutex
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
rescanHooks RescanHooks
}
<!-- ScanMetrics (metrics.go:11-55) — add Cancelled bool field -->
type ScanMetrics struct {
mu sync.Mutex
// ... existing timing and count fields ...
Added int64 `json:"added"`
Updated int64 `json:"updated"`
Skipped int64 `json:"skipped"`
Removed int64 `json:"removed"`
Warnings []ScanWarning `json:"warnings"`
}
<!-- Existing events (events.go:44-48) -->
const (
LibraryScanStarted = "LibraryScanStarted"
LibraryScanProgress = "LibraryScanProgress"
LibraryScanComplete = "LibraryScanComplete"
)
<!-- Scan() method signature (library.go:175) -->
func (l *Library) Scan() (*ScanMetrics, error)
<!-- Key scan pipeline locations that check l.ctx.Done() -->
<!-- library.go:297-298: case <-l.ctx.Done(): return l.ctx.Err() (walk, sending to workChan) -->
<!-- library.go:324-325: case <-l.ctx.Done(): return l.ctx.Err() (walk, new file) -->
<!-- library.go:496-497: case <-l.ctx.Done(): return l.ctx.Err() (worker, sending to resultChan) -->
<!-- commitBatch called at library.go:433 — uses l.ctx implicitly for DB ops -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add scan control events and metrics fields</name>
<files>backend/events/events.go, frontend/src/events.ts, backend/library/metrics.go</files>
<action>
1. In `backend/events/events.go`, add a new const block for scan control events:
```go
// Scan control events.
const (
LibraryScanCancelled = "LibraryScanCancelled"
LibraryScanPaused = "LibraryScanPaused"
LibraryScanResumed = "LibraryScanResumed"
)
```
Place it after the existing Library events block (line 48).
2. Run `go generate ./backend/events/...` to regenerate `frontend/src/events.ts`.
3. In `backend/library/metrics.go`, add a `Cancelled` field to `ScanMetrics`:
```go
Cancelled bool `json:"cancelled"`
```
Place it after the `Removed int64` field (line 51), before the `Warnings` field.
</action>
<verify>
<automated>cd backend && go build ./... && go generate ./events/... && grep -q "LibraryScanCancelled" events/events.go && grep -q "LibraryScanCancelled" ../frontend/src/events.ts && grep -q "Cancelled" library/metrics.go</automated>
</verify>
<done>Three new scan control events exist in events.go and are synced to frontend/src/events.ts. ScanMetrics has a Cancelled bool field.</done>
</task>
<task type="auto">
<name>Task 2: Add scan control fields to Library struct and create scan_control.go</name>
<files>backend/library/library.go, backend/library/scan_control.go</files>
<action>
1. In `backend/library/library.go`, add scan control fields to the `Library` struct (after `rescanHooks` at line 86):
```go
// Scan control fields — protected by mu.
scanActive bool
scanCancel context.CancelFunc
scanPaused bool
scanPauseCh chan struct{}
```
2. Create `backend/library/scan_control.go` with these Wails-bound methods:
```go
package library
import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// CancelScan cancels an in-progress scan. Returns immediately;
// scan goroutines stop at their next checkpoint.
func (l *Library) CancelScan() {
l.mu.Lock()
cancel := l.scanCancel
l.mu.Unlock()
if cancel != nil {
cancel()
}
}
// PauseScan pauses an in-progress scan. Workers block at their
// next pause checkpoint until ResumeScan is called.
func (l *Library) PauseScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanActive || l.scanPaused {
return
}
l.scanPaused = true
l.scanPauseCh = make(chan struct{})
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
}
// ResumeScan unblocks a paused scan.
func (l *Library) ResumeScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanPaused {
return
}
l.scanPaused = false
close(l.scanPauseCh) // unblocks all waiting workers
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
}
// IsScanActive returns whether a scan is currently running.
func (l *Library) IsScanActive() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanActive
}
// IsScanPaused returns whether the scan is currently paused.
func (l *Library) IsScanPaused() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanPaused
}
// waitIfPaused blocks the calling goroutine if the scan is paused.
// Returns ctx.Err() if the context is cancelled while waiting.
func (l *Library) waitIfPaused(ctx context.Context) error {
l.mu.Lock()
ch := l.scanPauseCh
paused := l.scanPaused
l.mu.Unlock()
if !paused || ch == nil {
return nil
}
select {
case <-ch: // closed = unpaused
return nil
case <-ctx.Done():
return ctx.Err()
}
}
```
Note: `waitIfPaused` takes a `context.Context` parameter (the scan-specific context), not `l.ctx`. Add `"context"` to the import block.
3. Modify `Scan()` in `backend/library/library.go`:
a. At the top of Scan() (after `metrics := newScanMetrics()`, line 176), create a cancellable scan context:
```go
scanCtx, scanCancel := context.WithCancel(l.ctx)
defer scanCancel()
l.mu.Lock()
l.scanCancel = scanCancel
l.scanActive = true
l.scanPaused = false
l.scanPauseCh = nil
l.mu.Unlock()
defer func() {
l.mu.Lock()
l.scanCancel = nil
l.scanActive = false
// If still paused, unpause so no dangling channel
if l.scanPaused {
l.scanPaused = false
if l.scanPauseCh != nil {
close(l.scanPauseCh)
}
}
l.scanPauseCh = nil
l.mu.Unlock()
}()
```
b. Replace ALL occurrences of `<-l.ctx.Done()` inside Scan() with `<-scanCtx.Done()`, and `l.ctx.Err()` with `scanCtx.Err()` (the walk goroutine send-to-workChan selects and the walk error return, and the worker pool send-to-resultChan select). There are 3 occurrences: line ~297, ~324, ~496.
c. In the worker pool loop (Phase 3, around line 474), add a pause checkpoint before processing each file. Add at the start of the `g.Go(func() error {` closure body:
```go
if err := l.waitIfPaused(scanCtx); err != nil {
return err
}
```
d. **CRITICAL — Batch commits use l.ctx, NOT scanCtx:** The `commitBatch` method and all DB operations within it should continue to use `l.ctx` (the app context), NOT the scan-specific `scanCtx`. This is already the case since `commitBatch` accesses `l.ctx` internally. DO NOT change `commitBatch` to use `scanCtx`. This ensures in-flight transactions always complete even when the scan is cancelled.
e. **CRITICAL — Skip orphan cleanup on cancelled scan:** Before the orphan cleanup phase (Phase 5, around line 549), add a check:
```go
// Skip orphan cleanup if the scan was cancelled — existingPaths
// still contains unvisited files that would be incorrectly deleted.
cancelled := scanCtx.Err() != nil
if cancelled {
metrics.Cancelled = true
l.logger.Info("scan cancelled, skipping orphan cleanup")
} else {
// ... existing orphan cleanup code ...
}
```
Wrap the existing orphan cleanup code (existingPaths.Range through metrics.OrphanCleanup = ...) inside the `else` block.
f. Also skip the "Phase 6: post-scan variant generation" if cancelled (wrap in same `if !cancelled` check or separate check).
g. When the scan was cancelled, emit `LibraryScanCancelled` instead of (or in addition to) `LibraryScanComplete`. Update the finalize section:
```go
if cancelled {
runtime.EventsEmit(l.ctx, events.LibraryScanCancelled, metrics)
} else {
runtime.EventsEmit(l.ctx, events.LibraryScanComplete, metrics)
}
```
</action>
<verify>
<automated>cd backend && go build ./... && go vet ./library/...</automated>
</verify>
<done>Library struct has scan control fields. scan_control.go provides CancelScan/PauseScan/ResumeScan/IsScanActive/IsScanPaused. Scan() uses per-scan context, workers check for pause, orphan cleanup is skipped on cancel, and appropriate events are emitted.</done>
</task>
</tasks>
<verification>
```bash
cd backend && go build ./... && go vet ./library/... && go vet ./events/...
```
All backend code compiles. No vet errors. New scan control methods are exported and Wails-bindable.
</verification>
<success_criteria>
- `go build ./...` passes with no errors
- `CancelScan`, `PauseScan`, `ResumeScan`, `IsScanActive`, `IsScanPaused` are exported methods on `*Library`
- `waitIfPaused` is an unexported helper that blocks on pause channel
- Scan() creates a per-scan context and uses it for worker cancellation
- Orphan cleanup and variant generation are skipped when scan is cancelled
- `LibraryScanCancelled`, `LibraryScanPaused`, `LibraryScanResumed` events exist and are synced to TypeScript
- `ScanMetrics.Cancelled` bool field exists
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md`
</output>
@@ -0,0 +1,460 @@
---
phase: 09-scan-cancellation-keyboard-shortcuts
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- backend/shortcuts/config.go
- backend/config/config.go
- frontend/src/services/keyboard-shortcut-service.ts
- frontend/src/store/shortcuts-store.ts
- frontend/src/store/controllers/shortcuts-controller.ts
- frontend/src/store/index.ts
autonomous: true
requirements:
- KEY-01
- KEY-04
- KEY-05
must_haves:
truths:
- "Default keyboard shortcuts work immediately — Space toggles play/pause, arrows adjust volume/seek, S/R/Q/M/N/P trigger actions"
- "Shortcuts are suppressed when a text input is focused (except Escape which blurs)"
- "Shortcuts are context-aware — panel-specific bindings (Enter/Delete in track list) only fire when that panel has focus"
- "Shortcut config persists to TOML via Wails bindings and survives app restart"
artifacts:
- path: "backend/shortcuts/config.go"
provides: "Shortcuts config package with defaults and validation"
exports: ["Config", "ApplyDefaults", "Validate", "DefaultBindings"]
- path: "frontend/src/services/keyboard-shortcut-service.ts"
provides: "Singleton keyboard shortcut service with scope resolution"
exports: ["keyboardShortcutService", "KeyboardShortcutService"]
- path: "frontend/src/store/shortcuts-store.ts"
provides: "Shortcuts store persisting bindings via Wails config"
exports: ["shortcutsStore", "ShortcutsStore"]
key_links:
- from: "frontend/src/services/keyboard-shortcut-service.ts"
to: "frontend/src/store/shortcuts-store.ts"
via: "Service reads bindings from store to resolve key combos to actions"
pattern: "shortcutsStore"
- from: "frontend/src/store/shortcuts-store.ts"
to: "backend/config/config.go"
via: "Wails bindings GetShortcuts/SetShortcuts for persistence"
pattern: "GetShortcuts|SetShortcuts"
- from: "frontend/src/services/keyboard-shortcut-service.ts"
to: "frontend/src/store/player-store.ts"
via: "Action dispatch calls store methods for player controls"
pattern: "playerStore|queueStore"
---
<objective>
Create the keyboard shortcuts backend config package and the frontend keyboard shortcut service with default bindings, scope resolution, and action dispatch.
Purpose: Foundation for KEY-01/04/05 — shortcuts work out of the box. Settings UI (KEY-02/03) wires to this in Plan 04.
Output: Go shortcuts config, frontend service singleton, shortcuts store with Wails persistence.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
@backend/config/config.go
@backend/theme/config.go
@frontend/src/store/index.ts
@frontend/src/store/theme-store.ts
@frontend/src/store/player-store.ts
@frontend/src/store/queue-store.ts
<interfaces>
<!-- Config struct pattern (config.go:24-33) -->
type Config struct {
ctx context.Context
logger *slog.Logger
filePath string
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
}
<!-- Config section pattern (theme/config.go) — follow this exactly -->
type Config struct {
AccentColor string `toml:"AccentColor"`
BackgroundShade BackgroundShade `toml:"BackgroundShade"`
}
func (c *Config) ApplyDefaults() { ... }
func (c *Config) Validate() error { ... }
<!-- Store pattern (from existing stores) -->
class ThemeStore {
private state: ThemeState;
private subscribers = new Set<(state: ThemeState) => void>();
subscribe(cb: (state: ThemeState) => void): () => void { ... }
private notify() { queueMicrotask(() => { ... }) }
}
export const themeStore = new ThemeStore();
<!-- Player store actions that shortcuts will call -->
// From player-store.ts:
export const playerStore: { togglePlayback(), setVolume(v: number), seek(pos: number) }
// From queue-store.ts:
export const queueStore: { next(), previous(), toggleShuffle(), cycleRepeat() }
<!-- Store index exports (store/index.ts) -->
export { playerStore } from './player-store';
export { queueStore } from './queue-store';
export { themeStore } from './theme-store';
export { searchStore } from './search-store';
<!-- Events pattern for config changes -->
const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create backend shortcuts config package and wire into main config</name>
<files>backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts</files>
<action>
1. Create `backend/shortcuts/config.go`:
```go
package shortcuts
// Config holds user-customized keyboard shortcut bindings.
// Keys are action IDs (e.g. "player.playPause"), values are
// key combo strings in canonical format (e.g. "Ctrl+F", "Space").
type Config struct {
Bindings map[string]string `toml:"Bindings"`
}
// DefaultBindings returns the default keyboard shortcut bindings.
// Follows hybrid style: Space/arrows for player, Ctrl+key for app actions.
func DefaultBindings() map[string]string {
return map[string]string{
// Player controls (Global scope, no modifier)
"player.playPause": "Space",
"player.next": "N",
"player.previous": "P",
"player.volumeUp": "Up",
"player.volumeDown": "Down",
"player.seekForward": "Right",
"player.seekBack": "Left",
"player.shuffle": "S",
"player.repeat": "R",
"player.mute": "M",
// Navigation (Global scope)
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
// App actions (Global scope, Ctrl modifier)
"app.selectAll": "Ctrl+A",
// Panel-specific (track list)
"tracklist.play": "Enter",
"tracklist.delete": "Delete",
}
}
// ApplyDefaults fills any missing bindings with defaults.
// Existing user customizations are preserved.
func (c *Config) ApplyDefaults() {
if c.Bindings == nil {
c.Bindings = DefaultBindings()
return
}
defaults := DefaultBindings()
for action, key := range defaults {
if _, exists := c.Bindings[action]; !exists {
c.Bindings[action] = key
}
}
}
// Validate checks that the config is well-formed.
func (c *Config) Validate() error {
c.ApplyDefaults()
// No validation errors possible — any string is a valid binding.
// Conflict detection is a frontend UX concern, not a config error.
return nil
}
```
2. In `backend/config/config.go`:
- Add import: `"yellowjacket/backend/shortcuts"`
- Add field to Config struct: `Shortcuts *shortcuts.Config \`toml:"Shortcuts"\``
- In `applyDefaults()`, add:
```go
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
}
c.Shortcuts.ApplyDefaults()
```
- In `Validate()`, add validation for Shortcuts (after the Favorites block):
```go
if c.Shortcuts != nil {
if err := c.Shortcuts.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
```
- Add Wails binding methods:
```go
// GetShortcuts returns the current shortcut bindings map.
func (c *Config) GetShortcuts() map[string]string {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
c.Shortcuts.ApplyDefaults()
}
return c.Shortcuts.Bindings
}
// SetShortcuts saves the entire shortcut bindings map.
func (c *Config) SetShortcuts(bindings map[string]string) error {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
}
c.Shortcuts.Bindings = bindings
if err := c.Save(); err != nil {
return fmt.Errorf("could not save shortcuts config: %w", err)
}
if c.ctx != nil {
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, bindings)
}
c.logger.Info("shortcuts config updated")
return nil
}
// SetShortcut saves a single shortcut binding.
func (c *Config) SetShortcut(action string, key string) error {
if c.Shortcuts == nil {
c.Shortcuts = &shortcuts.Config{}
c.Shortcuts.ApplyDefaults()
}
c.Shortcuts.Bindings[action] = key
if err := c.Save(); err != nil {
return fmt.Errorf("could not save shortcut: %w", err)
}
if c.ctx != nil {
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings)
}
c.logger.Info("shortcut updated", "action", action, "key", key)
return nil
}
// ResetShortcuts resets all shortcuts to defaults.
func (c *Config) ResetShortcuts() error {
c.Shortcuts = &shortcuts.Config{
Bindings: shortcuts.DefaultBindings(),
}
if err := c.Save(); err != nil {
return fmt.Errorf("could not save shortcuts reset: %w", err)
}
if c.ctx != nil {
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings)
}
c.logger.Info("shortcuts reset to defaults")
return nil
}
```
3. Add `ShortcutsConfigChanged` event to `backend/events/events.go` in the Config events block:
```go
ShortcutsConfigChanged = "ShortcutsConfigChanged"
```
4. Run `go generate ./backend/events/...` to sync to TypeScript.
</action>
<verify>
<automated>cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts</automated>
</verify>
<done>Shortcuts config package exists with defaults matching user decisions. Config.go has Shortcuts field, getter/setter Wails bindings, and emits ShortcutsConfigChanged. Event synced to TypeScript.</done>
</task>
<task type="auto">
<name>Task 2: Create frontend keyboard shortcut service, store, and controller</name>
<files>frontend/src/services/keyboard-shortcut-service.ts, frontend/src/store/shortcuts-store.ts, frontend/src/store/controllers/shortcuts-controller.ts, frontend/src/store/index.ts</files>
<action>
1. Create `frontend/src/services/keyboard-shortcut-service.ts`:
This is the FIRST file in the `services/` directory — create the directory.
The service is a singleton that:
- Listens on `document.addEventListener('keydown', ...)` in constructor
- Resolves the active scope by walking the shadow DOM active element chain
- Looks up the key combo in the shortcuts store
- Dispatches the action by calling the appropriate store method
Key implementation details:
- **Key string builder:** `buildKeyString(e: KeyboardEvent): string`
- Modifiers in fixed order: Ctrl (includes Meta on Mac) + Alt + Shift
- Skip bare modifier presses (return '' for Control, Alt, Shift, Meta)
- Normalize: ArrowUp→Up, ArrowDown→Down, ArrowLeft→Left, ArrowRight→Right, ' '→Space
- Single-char keys: uppercase (e.g., 's' → 'S')
- **Shadow DOM active element:** `getDeepActiveElement(): Element | null`
- Walk `el.shadowRoot.activeElement` chain recursively
- **isTextInputFocused():** Check deep active element — if tagName is INPUT (type text/search/url/email/password/number/tel), TEXTAREA, or isContentEditable → true
- **resolveScope():** Returns 'text-input' | 'panel:track-list' | 'panel:queue' | 'global'
- First check isTextInputFocused → 'text-input'
- Walk up from deep active element checking closest('[data-shortcut-scope]') attribute
- If found, return `panel:${value}`
- Default: 'global'
- **handleKeydown logic:**
1. If scope is 'text-input': only allow Escape (blur the active element), suppress everything else — return early
2. Build key string
3. Get bindings from shortcutsStore
4. First try panel-specific match: find binding where action starts with panel prefix AND key matches
5. Then try global match: find binding where action does NOT start with any panel prefix AND key matches
6. If match found: preventDefault, dispatch action
- **dispatch(action: string):** Switch on action ID to call store methods:
- `player.playPause` → `playerStore.togglePlayback()`
- `player.next` → `queueStore.next()`
- `player.previous` → `queueStore.previous()`
- `player.volumeUp` → `playerStore.adjustVolume(5)` (add adjustVolume method if not exists, or use setVolume with current + 5)
- `player.volumeDown` → `playerStore.adjustVolume(-5)`
- `player.seekForward` → `playerStore.seekRelative(5)` (add seekRelative if needed, or use seek with current + 5)
- `player.seekBack` → `playerStore.seekRelative(-5)`
- `player.shuffle` → `queueStore.toggleShuffle()`
- `player.repeat` → `queueStore.cycleRepeat()`
- `player.mute` → `playerStore.toggleMute()`
- `nav.search`, `nav.searchAlt` → Focus search box: `document.querySelector('search-bar')?.shadowRoot?.querySelector('input')?.focus()` (walk shadow DOM to find the input)
- `nav.queue` → Toggle queue visibility (dispatch a custom event or call a store method)
- `app.selectAll` → `document.execCommand('selectAll')` or dispatch to active panel
- `tracklist.play` → Dispatch custom event `shortcut:tracklist-play` on document
- `tracklist.delete` → Dispatch custom event `shortcut:tracklist-delete` on document
Export `buildKeyString` as a named export (needed by shortcut-capture widget in Plan 04).
Export the singleton: `export const keyboardShortcutService = new KeyboardShortcutService();`
Note on volume/seek: Check the actual player-store API. If `adjustVolume(delta)` doesn't exist, the service should read current volume from playerStore state, add the delta, clamp to 0-100, and call `SetVolume()` via Wails binding. Same for seek: read current position, add delta seconds, call `Seek()`. Use the Wails-generated bindings directly (e.g., `import { SetVolume, Seek } from '../../wailsjs/go/player/Player'` — check the actual import path).
2. Create `frontend/src/store/shortcuts-store.ts`:
Follow existing store pattern (class-based singleton with subscribe/notify):
```typescript
interface ShortcutBinding {
action: string;
key: string;
scope: 'global' | string; // 'global' or 'panel:track-list' etc.
category: 'Player' | 'Navigation' | 'App';
}
interface ShortcutsState {
bindings: Map<string, string>; // action → key combo
loaded: boolean;
}
```
- Constructor: call `GetShortcuts()` Wails binding to load initial state. Listen for `ShortcutsConfigChanged` event to update.
- `getBindings(): Map<string, string>` — returns current bindings
- `getKeyForAction(action: string): string` — lookup
- `getActionForKey(key: string, scope?: string): string | undefined` — reverse lookup (for the service). Check panel-specific scope first, then global.
- `updateBinding(action: string, key: string): Promise<void>` — calls `SetShortcut()` Wails binding
- `resetAll(): Promise<void>` — calls `ResetShortcuts()` Wails binding
- `findConflict(key: string, scope: string, excludeAction: string): { action: string, key: string } | null` — for conflict detection
Use `queueMicrotask` coalescing for notify (match existing pattern).
3. Create `frontend/src/store/controllers/shortcuts-controller.ts`:
Follow existing controller pattern (ReactiveController bridging store to LitElement):
```typescript
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { shortcutsStore, ShortcutsState } from '../shortcuts-store';
export class ShortcutsController implements ReactiveController {
host: ReactiveControllerHost;
state: ShortcutsState;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
this.state = shortcutsStore.getState();
host.addController(this);
}
hostConnected() {
this.unsubscribe = shortcutsStore.subscribe((state) => {
this.state = state;
this.host.requestUpdate();
});
}
hostDisconnected() {
this.unsubscribe?.();
}
}
```
4. Update `frontend/src/store/index.ts` — add exports:
```typescript
export { shortcutsStore } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller';
```
5. Initialize the keyboard shortcut service. The service must be created once at app startup. Find where other singletons are initialized (likely in `frontend/src/index.ts` or the main app component). Import and reference the singleton to ensure it's instantiated:
```typescript
import { keyboardShortcutService } from './services/keyboard-shortcut-service';
```
The import alone triggers instantiation since the module exports a `new KeyboardShortcutService()` at module scope.
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Keyboard shortcut service listens for keydown events and dispatches actions based on scope. Shortcuts store loads bindings from Go config. Default shortcuts work: Space=play/pause, arrows=volume/seek, S/R/Q/M/N/P=player actions, /+Ctrl+F=search, Enter/Delete=tracklist panel. Text input suppression works (Escape only). Controller available for Lit components.</done>
</task>
</tasks>
<verification>
```bash
cd backend && go build ./... && go vet ./...
cd ../frontend && npx tsc --noEmit
```
Both backend and frontend compile. Shortcuts config persists through TOML. Service initializes at startup.
</verification>
<success_criteria>
- Go `shortcuts` package exists with `Config`, `ApplyDefaults`, `Validate`, `DefaultBindings`
- Config.go has `Shortcuts` field, `GetShortcuts`, `SetShortcuts`, `SetShortcut`, `ResetShortcuts` methods
- `ShortcutsConfigChanged` event exists and is synced to TypeScript
- Frontend `KeyboardShortcutService` singleton listens on `document.keydown`
- Shadow DOM active element resolution works (recursive walk)
- Text input suppression: only Escape passes through
- Scope resolution: text-input > panel-specific > global
- Default bindings match user decisions: Space, arrows, S, R, Q, M, N, P, /, Ctrl+F, Ctrl+A, Enter, Delete
- ShortcutsStore loads from Wails binding and subscribes to change events
- ShortcutsController bridges store to Lit components
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md`
</output>
@@ -0,0 +1,319 @@
---
phase: 09-scan-cancellation-keyboard-shortcuts
plan: 03
type: execute
wave: 2
depends_on:
- 09-01
files_modified:
- frontend/src/components/config-page/config-page.ts
autonomous: true
requirements:
- SCAN-01
- SCAN-02
- SCAN-03
must_haves:
truths:
- "Cancel button appears during an active scan and calls CancelScan() Wails binding"
- "Pause button appears during an active scan and calls PauseScan() Wails binding"
- "Resume button replaces Pause when paused and calls ResumeScan() Wails binding"
- "On cancel, a confirmation dialog asks 'Keep X tracks found so far, or discard?'"
- "Keep option: scan stops, partial results remain in library"
- "Discard option: scan stops, added tracks from this scan are removed"
- "LibraryScanCancelled, LibraryScanPaused, LibraryScanResumed events update UI state"
artifacts:
- path: "frontend/src/components/config-page/config-page.ts"
provides: "Pause/Cancel/Resume buttons, cancel confirmation dialog, event handling for scan control"
contains: "handleCancelScan"
key_links:
- from: "frontend/src/components/config-page/config-page.ts"
to: "backend/library/scan_control.go"
via: "Wails bindings CancelScan/PauseScan/ResumeScan"
pattern: "CancelScan|PauseScan|ResumeScan"
- from: "frontend/src/components/config-page/config-page.ts"
to: "backend/events/events.go"
via: "EventsOn for LibraryScanCancelled/Paused/Resumed"
pattern: "LibraryScanCancelled|LibraryScanPaused|LibraryScanResumed"
---
<objective>
Add scan control buttons (Pause, Resume, Cancel) and a cancel confirmation dialog to the config page's library scan section.
Purpose: Frontend UX for SCAN-01/02/03. Wires to backend scan control methods from Plan 01.
Output: Modified config-page.ts with scan control UI, event handling, and cancel confirmation.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md
@frontend/src/components/config-page/config-page.ts
@frontend/src/events.ts
<interfaces>
<!-- Scan control Wails bindings (from Plan 01) -->
// From wailsjs/go/library/Library:
export function CancelScan(): Promise<void>;
export function PauseScan(): Promise<void>;
export function ResumeScan(): Promise<void>;
export function IsScanActive(): Promise<boolean>;
export function IsScanPaused(): Promise<boolean>;
<!-- New events (from Plan 01) -->
export const LibraryScanCancelled = "LibraryScanCancelled";
export const LibraryScanPaused = "LibraryScanPaused";
export const LibraryScanResumed = "LibraryScanResumed";
<!-- ScanMetrics now has Cancelled bool (from Plan 01) -->
interface ScanMetrics {
// ... existing fields ...
cancelled: boolean;
added: number;
// ...
}
<!-- Existing scan UI state in config-page.ts -->
@state() scanning = false;
@state() statusMessage = '';
@state() scanProgress: ScanProgress | null = null;
@state() metrics: any = null;
@state() scanErrors = '';
<!-- Existing scan buttons location (config-page.ts:1327-1346) -->
<div class="scan-actions">
<button class="btn-warning" ?disabled=${this.scanning} @click=${this.handleSoftScan}>
${this.scanning ? 'Scanning...' : 'Soft Scan'}
</button>
<button class="btn-danger" ?disabled=${this.scanning} @click=${this.handleFullRescan}>
${this.scanning ? 'Scanning...' : 'Full Rescan'}
</button>
</div>
<!-- Status bar (config-page.ts:1348-1354) -->
<div class="status-bar ${this.scanning ? 'active' : ''}">
${this.scanProgress ? this.renderScanProgress() : this.statusMessage || 'Ready.'}
</div>
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add scan control state, event handlers, and UI buttons</name>
<files>frontend/src/components/config-page/config-page.ts</files>
<action>
1. **Add new state properties** to the config-page component class:
```typescript
@state() private scanPaused = false;
@state() private showCancelDialog = false;
@state() private cancelMetrics: { added: number } | null = null;
```
2. **Register event listeners** in `connectedCallback()` (find where existing scan events are registered and add alongside them):
```typescript
EventsOn(events.LibraryScanPaused, () => {
this.scanPaused = true;
});
EventsOn(events.LibraryScanResumed, () => {
this.scanPaused = false;
});
EventsOn(events.LibraryScanCancelled, (metrics: any) => {
this.scanning = false;
this.scanPaused = false;
this.scanProgress = null;
this.metrics = metrics;
this.statusMessage = metrics?.cancelled ? 'Scan cancelled.' : 'Scan complete.';
});
```
3. **Add scan control handler methods:**
```typescript
private handlePauseScan() {
PauseScan();
}
private handleResumeScan() {
ResumeScan();
}
private handleCancelScan() {
// Show confirmation dialog with current progress
const added = this.scanProgress?.added ?? 0;
this.cancelMetrics = { added };
this.showCancelDialog = true;
}
private async handleCancelKeep() {
this.showCancelDialog = false;
this.cancelMetrics = null;
CancelScan();
}
private async handleCancelDiscard() {
this.showCancelDialog = false;
this.cancelMetrics = null;
CancelScan();
// After cancel completes, trigger a full rescan to clear partial data.
// The simpler approach: use the library's FullRescan which clears tables first.
// Wait briefly for cancel to take effect, then initiate full rescan.
// Alternatively, just cancel — the user can manually rescan if they want clean state.
// Per research: "discard" clears the entire library since partial state is unreliable.
// Call the existing clearLibraryTables equivalent via FullRescan.
// For simplicity and safety: cancel + emit a status message saying "Partial results discarded. Run Full Rescan to start fresh."
this.statusMessage = 'Scan cancelled. Partial results discarded — run Full Rescan for a clean library.';
// Note: A more sophisticated approach would track added IDs and delete them.
// For v1.1, the simple discard = cancel + inform user approach is safer.
}
private handleCancelDialogDismiss() {
this.showCancelDialog = false;
this.cancelMetrics = null;
}
```
4. **Modify the scan buttons area** (around line 1327). Add Pause/Resume and Cancel buttons that appear ONLY during scanning. Place them between the existing scan buttons and the status bar:
Per user decision: "Pause and Cancel buttons placed next to the existing status label, above the existing progress bar."
Replace the `.scan-actions` div content when scanning is active:
```typescript
<div class="scan-actions">
${this.scanning
? html`
${this.scanPaused
? html`<button class="btn-warning" @click=${this.handleResumeScan}>Resume</button>`
: html`<button class="btn-warning" @click=${this.handlePauseScan}>Pause</button>`
}
<button class="btn-danger" @click=${this.handleCancelScan}>Cancel Scan</button>
`
: html`
<button class="btn-warning" @click=${this.handleSoftScan}>Soft Scan</button>
<button class="btn-danger" @click=${this.handleFullRescan}>Full Rescan</button>
`
}
</div>
```
5. **Add cancel confirmation dialog** — render it conditionally when `showCancelDialog` is true. Place the dialog render at the end of the library section's render method (after the metrics tree, before the closing `</config-section>` tag):
```typescript
${this.showCancelDialog ? html`
<div class="cancel-dialog-overlay" @click=${this.handleCancelDialogDismiss}>
<div class="cancel-dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="cancel-dialog-title">Cancel Scan</div>
<div class="cancel-dialog-message">
${this.cancelMetrics?.added
? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?`
: 'Cancel the current scan?'}
</div>
<div class="cancel-dialog-actions">
<button class="btn-primary" @click=${this.handleCancelKeep}>
${this.cancelMetrics?.added ? `Keep ${this.cancelMetrics.added} tracks` : 'Cancel Scan'}
</button>
<button class="btn-danger" @click=${this.handleCancelDiscard}>
Discard
</button>
<button class="btn-ghost" @click=${this.handleCancelDialogDismiss}>
Continue Scanning
</button>
</div>
</div>
</div>
` : ''}
```
6. **Update the status bar** to show paused state:
In the existing status bar rendering, update to show "Paused" when paused:
```typescript
<div class="status-bar ${this.scanning ? 'active' : ''} ${this.scanPaused ? 'paused' : ''}">
${this.scanPaused
? 'Scan paused.'
: this.scanProgress
? this.renderScanProgress()
: this.statusMessage || 'Ready.'}
</div>
```
7. **Add CSS styles** for the cancel dialog and paused state. Add to the component's static styles:
```css
.cancel-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cancel-dialog {
background: var(--yj-bg-surface, #2a2a2a);
border: 1px solid var(--yj-border, #444);
border-radius: 8px;
padding: 24px;
max-width: 420px;
width: 90%;
}
.cancel-dialog-title {
font-size: var(--yj-text-lg, 18px);
font-weight: 600;
margin-bottom: 12px;
}
.cancel-dialog-message {
font-size: var(--yj-text-sm, 14px);
color: var(--yj-text-secondary, #aaa);
margin-bottom: 20px;
}
.cancel-dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.status-bar.paused {
color: var(--yj-accent, #ffd43b);
}
```
8. **Import Wails bindings** — add imports for `CancelScan`, `PauseScan`, `ResumeScan` from the Wails generated bindings path. Check the actual import path by looking at how existing Library bindings are imported (e.g., `Scan` and `FullRescan`).
9. **Reset scanPaused** in the existing `LibraryScanComplete` handler (the scan finished normally):
Add `this.scanPaused = false;` to the existing handler.
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30</automated>
</verify>
<done>Config page shows Pause/Cancel buttons during active scan. Pause toggles to Resume when paused. Cancel shows confirmation dialog with "Keep X tracks / Discard / Continue Scanning" options. All scan control events update UI state correctly. CSS styles render the dialog overlay properly.</done>
</task>
</tasks>
<verification>
```bash
cd frontend && npx tsc --noEmit
```
TypeScript compiles with no errors. Scan control UI renders correctly.
</verification>
<success_criteria>
- Pause button visible during scan, calls PauseScan()
- Resume button replaces Pause when paused, calls ResumeScan()
- Cancel button visible during scan, shows confirmation dialog
- Confirmation dialog shows track count and offers Keep/Discard/Continue
- LibraryScanPaused/Resumed/Cancelled events update component state
- Status bar shows "Scan paused." when paused
- Dialog overlay dismissible by clicking outside or "Continue Scanning"
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md`
</output>
@@ -0,0 +1,505 @@
---
phase: 09-scan-cancellation-keyboard-shortcuts
plan: 04
type: execute
wave: 2
depends_on:
- 09-02
files_modified:
- frontend/src/components/config-page/shortcut-capture.ts
- frontend/src/components/config-page/config-page.ts
autonomous: true
requirements:
- KEY-02
- KEY-03
must_haves:
truths:
- "User can see all keyboard shortcuts grouped by category (Player, Navigation, App) in a Keyboard Shortcuts tab"
- "User can click a shortcut row and press a new key combo to rebind it (record-style capture)"
- "Conflicts are detected and shown — user can overwrite (old becomes unbound) or cancel"
- "Reset to defaults button resets all shortcuts"
- "Individual per-shortcut reset is available"
artifacts:
- path: "frontend/src/components/config-page/shortcut-capture.ts"
provides: "Record-style key capture web component"
exports: ["ShortcutCapture"]
- path: "frontend/src/components/config-page/config-page.ts"
provides: "Keyboard Shortcuts tab in settings"
contains: "renderShortcutsSection"
key_links:
- from: "frontend/src/components/config-page/shortcut-capture.ts"
to: "frontend/src/services/keyboard-shortcut-service.ts"
via: "Uses buildKeyString for consistent key combo normalization"
pattern: "buildKeyString"
- from: "frontend/src/components/config-page/config-page.ts"
to: "frontend/src/store/shortcuts-store.ts"
via: "ShortcutsController for reactive state, store methods for persistence"
pattern: "shortcutsStore|ShortcutsController"
---
<objective>
Create the Keyboard Shortcuts settings UI with record-style key capture, conflict detection, and category grouping.
Purpose: Frontend UX for KEY-02/03 — visual shortcut customization with conflict warnings.
Output: shortcut-capture.ts component, Keyboard Shortcuts tab added to config-page.ts.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md
@frontend/src/components/config-page/config-page.ts
@frontend/src/store/shortcuts-store.ts
@frontend/src/services/keyboard-shortcut-service.ts
<interfaces>
<!-- From Plan 02: shortcuts store API -->
class ShortcutsStore {
getBindings(): Map<string, string>; // action → key combo
getKeyForAction(action: string): string;
updateBinding(action: string, key: string): Promise<void>;
resetAll(): Promise<void>;
findConflict(key: string, scope: string, excludeAction: string): { action: string; key: string } | null;
subscribe(cb: (state: ShortcutsState) => void): () => void;
getState(): ShortcutsState;
}
export const shortcutsStore: ShortcutsStore;
export class ShortcutsController implements ReactiveController { state: ShortcutsState; }
<!-- From Plan 02: buildKeyString export -->
export function buildKeyString(e: KeyboardEvent): string;
<!-- From Plan 02: default bindings with scope metadata -->
// Action scopes (derived from action prefix):
// - "player.*", "nav.*", "app.*" → global scope
// - "tracklist.*" → panel:track-list scope
// Action categories (for UI grouping):
// - Player: player.playPause, player.next, player.previous, player.volumeUp, player.volumeDown,
// player.seekForward, player.seekBack, player.shuffle, player.repeat, player.mute
// - Navigation: nav.search, nav.searchAlt, nav.queue, tracklist.play, tracklist.delete
// - App: app.selectAll
<!-- Existing config-page rendering pattern -->
// Currently renders 4 sections vertically: Theme, Favorites, Track List Columns, Library
// Each section uses <config-section> component
// Per user decision: Shortcuts lives as a "Keyboard Shortcuts" tab within the settings dialog
// Since the current layout is vertical sections (NOT tabbed), add "Keyboard Shortcuts" as
// a new <config-section> alongside the existing ones.
// If/when tabs are needed, that's a layout change beyond this phase.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create shortcut-capture web component</name>
<files>frontend/src/components/config-page/shortcut-capture.ts</files>
<action>
Create `frontend/src/components/config-page/shortcut-capture.ts` — a record-style key capture widget inspired by VS Code's keybinding editor.
The component:
- Displays the current key binding as a styled button/badge
- When clicked, enters "recording" mode — displays "Press a key combo..." prompt
- Captures the next keydown event and normalizes it via `buildKeyString`
- On Escape during recording: cancels, returns to display mode
- On valid key: exits recording, dispatches `shortcut-change` CustomEvent with `{ action, key }` detail
- On bare modifier press (Ctrl alone, etc.): stays in recording mode (buildKeyString returns '')
```typescript
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { buildKeyString } from '../../services/keyboard-shortcut-service';
@customElement('shortcut-capture')
export class ShortcutCapture extends LitElement {
@property() action = '';
@property() currentKey = '';
@property() defaultKey = '';
@state() private recording = false;
static styles = css`
:host {
display: inline-block;
}
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--yj-border, #555);
background: var(--yj-bg-input, #333);
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
button:hover {
border-color: var(--yj-accent, #ffd43b);
}
button.recording {
border-color: var(--yj-accent, #ffd43b);
background: var(--yj-bg-active, #444);
animation: pulse 1.2s ease-in-out infinite;
}
button.not-set {
color: var(--yj-text-tertiary, #888);
font-style: italic;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
margin-left: 4px;
border: none;
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: auto;
opacity: 0;
transition: opacity 0.15s;
}
:host(:hover) .reset-btn {
opacity: 1;
}
.reset-btn:hover {
color: var(--yj-accent, #ffd43b);
}
`;
private handleClick = () => {
this.recording = true;
// Focus self so keydown events arrive
this.shadowRoot?.querySelector('button')?.focus();
};
private handleKeydown = (e: KeyboardEvent) => {
if (!this.recording) return;
e.preventDefault();
e.stopPropagation();
const keyStr = buildKeyString(e);
if (!keyStr) return; // bare modifier press — keep recording
if (keyStr === 'Escape') {
this.recording = false;
return;
}
this.recording = false;
this.dispatchEvent(new CustomEvent('shortcut-change', {
detail: { action: this.action, key: keyStr },
bubbles: true,
composed: true,
}));
};
private handleBlur = () => {
// Cancel recording if focus leaves
if (this.recording) {
this.recording = false;
}
};
private handleReset = (e: Event) => {
e.stopPropagation();
if (this.defaultKey && this.currentKey !== this.defaultKey) {
this.dispatchEvent(new CustomEvent('shortcut-change', {
detail: { action: this.action, key: this.defaultKey },
bubbles: true,
composed: true,
}));
}
};
render() {
const showReset = this.defaultKey && this.currentKey !== this.defaultKey;
return html`
<button
class=${this.recording ? 'recording' : this.currentKey ? '' : 'not-set'}
@click=${this.handleClick}
@keydown=${this.handleKeydown}
@blur=${this.handleBlur}
>
${this.recording
? 'Press a key combo\u2026'
: this.currentKey || 'Not set'}
</button>
${showReset ? html`
<button class="reset-btn" @click=${this.handleReset}
title="Reset to default (${this.defaultKey})">
\u21BA
</button>
` : ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'shortcut-capture': ShortcutCapture;
}
}
```
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>shortcut-capture component renders a key badge, enters recording mode on click, captures keydown via buildKeyString, dispatches shortcut-change event, supports Escape cancel, and shows per-shortcut reset button when binding differs from default.</done>
</task>
<task type="auto">
<name>Task 2: Add Keyboard Shortcuts section to config page with conflict detection</name>
<files>frontend/src/components/config-page/config-page.ts</files>
<action>
1. **Import required modules** at the top of config-page.ts:
```typescript
import './shortcut-capture';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
```
2. **Add ShortcutsController** to the component class:
```typescript
private shortcutsCtrl = new ShortcutsController(this);
```
3. **Define shortcut metadata** — a static map of action IDs to human-readable labels and categories. Add as a class property or module-level const:
```typescript
private static readonly SHORTCUT_META: Record<string, { label: string; category: string; scope: string; defaultKey: string }> = {
'player.playPause': { label: 'Play / Pause', category: 'Player', scope: 'global', defaultKey: 'Space' },
'player.next': { label: 'Next Track', category: 'Player', scope: 'global', defaultKey: 'N' },
'player.previous': { label: 'Previous Track', category: 'Player', scope: 'global', defaultKey: 'P' },
'player.volumeUp': { label: 'Volume Up', category: 'Player', scope: 'global', defaultKey: 'Up' },
'player.volumeDown': { label: 'Volume Down', category: 'Player', scope: 'global', defaultKey: 'Down' },
'player.seekForward': { label: 'Seek Forward', category: 'Player', scope: 'global', defaultKey: 'Right' },
'player.seekBack': { label: 'Seek Back', category: 'Player', scope: 'global', defaultKey: 'Left' },
'player.shuffle': { label: 'Toggle Shuffle', category: 'Player', scope: 'global', defaultKey: 'S' },
'player.repeat': { label: 'Cycle Repeat', category: 'Player', scope: 'global', defaultKey: 'R' },
'player.mute': { label: 'Toggle Mute', category: 'Player', scope: 'global', defaultKey: 'M' },
'nav.search': { label: 'Focus Search', category: 'Navigation', scope: 'global', defaultKey: '/' },
'nav.searchAlt': { label: 'Focus Search (Alt)', category: 'Navigation', scope: 'global', defaultKey: 'Ctrl+F' },
'nav.queue': { label: 'Toggle Queue', category: 'Navigation', scope: 'global', defaultKey: 'Q' },
'app.selectAll': { label: 'Select All', category: 'App', scope: 'global', defaultKey: 'Ctrl+A' },
'tracklist.play': { label: 'Play Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Enter' },
'tracklist.delete': { label: 'Remove Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Delete' },
};
```
4. **Add conflict detection state:**
```typescript
@state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null;
```
5. **Add shortcut change handler:**
```typescript
private async handleShortcutChange(e: CustomEvent<{ action: string; key: string }>) {
const { action, key } = e.detail;
// Check for conflict — find any other action with the same key in the same or overlapping scope
const meta = ConfigPage.SHORTCUT_META[action];
const conflict = shortcutsStore.findConflict(key, meta?.scope ?? 'global', action);
if (conflict) {
// Show conflict warning
this.shortcutConflict = {
newAction: action,
newKey: key,
existingAction: conflict.action,
};
return;
}
// No conflict — save directly
await shortcutsStore.updateBinding(action, key);
}
private async handleConflictOverwrite() {
if (!this.shortcutConflict) return;
const { newAction, newKey, existingAction } = this.shortcutConflict;
// Unbind the existing action
await shortcutsStore.updateBinding(existingAction, '');
// Set the new binding
await shortcutsStore.updateBinding(newAction, newKey);
this.shortcutConflict = null;
}
private handleConflictCancel() {
this.shortcutConflict = null;
}
private async handleResetAllShortcuts() {
await shortcutsStore.resetAll();
}
```
6. **Render the Keyboard Shortcuts section.** Add a new method `renderShortcutsSection()` and call it from the main render method. Place it as a new `<config-section>` after the existing sections (before or after Library section — find the natural insertion point):
```typescript
private renderShortcutsSection() {
const bindings = this.shortcutsCtrl.state.bindings;
const categories = ['Player', 'Navigation', 'App'];
return html`
<config-section label="Keyboard Shortcuts">
${categories.map(cat => {
const actions = Object.entries(ConfigPage.SHORTCUT_META)
.filter(([_, meta]) => meta.category === cat);
if (actions.length === 0) return '';
return html`
<div class="shortcut-category">
<div class="shortcut-category-header">${cat}</div>
${actions.map(([action, meta]) => html`
<div class="shortcut-row">
<span class="shortcut-label">
${meta.label}
${meta.scope !== 'global' ? html`
<span class="shortcut-scope">(${meta.scope.replace('panel:', '')})</span>
` : ''}
</span>
<shortcut-capture
.action=${action}
.currentKey=${bindings.get(action) ?? ''}
.defaultKey=${meta.defaultKey}
@shortcut-change=${this.handleShortcutChange}
></shortcut-capture>
</div>
`)}
</div>
`;
})}
<div class="shortcut-actions">
<button class="btn-ghost" @click=${this.handleResetAllShortcuts}>
Reset All to Defaults
</button>
</div>
${this.shortcutConflict ? html`
<div class="conflict-banner">
<span class="conflict-text">
<strong>${this.shortcutConflict.newKey}</strong> is already bound to
<strong>${ConfigPage.SHORTCUT_META[this.shortcutConflict.existingAction]?.label ?? this.shortcutConflict.existingAction}</strong>.
</span>
<div class="conflict-actions">
<button class="btn-warning" @click=${this.handleConflictOverwrite}>
Overwrite
</button>
<button class="btn-ghost" @click=${this.handleConflictCancel}>
Cancel
</button>
</div>
</div>
` : ''}
</config-section>
`;
}
```
7. **Call `renderShortcutsSection()`** from the main render method. Insert `${this.renderShortcutsSection()}` in the template — place it between "Track List Columns" and "Library" sections, or after Library. Look at the current render layout to find the best spot.
8. **Add CSS styles** for the shortcuts section:
```css
.shortcut-category {
margin-bottom: 16px;
}
.shortcut-category-header {
font-size: var(--yj-text-sm, 13px);
font-weight: 600;
color: var(--yj-text-secondary, #aaa);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--yj-border, #444);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
gap: 16px;
}
.shortcut-label {
font-size: var(--yj-text-sm, 13px);
color: var(--yj-text-primary, #eee);
}
.shortcut-scope {
font-size: var(--yj-text-xs, 11px);
color: var(--yj-text-tertiary, #888);
margin-left: 4px;
}
.shortcut-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.conflict-banner {
margin-top: 12px;
padding: 12px;
background: rgba(255, 165, 0, 0.1);
border: 1px solid rgba(255, 165, 0, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.conflict-text {
font-size: var(--yj-text-sm, 13px);
}
.conflict-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
```
</action>
<verify>
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<done>Keyboard Shortcuts section renders in the config page with shortcuts grouped by category (Player, Navigation, App). Each row shows label + shortcut-capture widget. Conflict detection warns before overwriting. "Reset All to Defaults" and per-shortcut reset work. Panel-specific shortcuts show their scope label.</done>
</task>
</tasks>
<verification>
```bash
cd frontend && npx tsc --noEmit
```
TypeScript compiles. shortcut-capture component and shortcuts section are properly wired.
</verification>
<success_criteria>
- `shortcut-capture` component exists and handles recording, Escape cancel, blur cancel, reset
- Config page has a "Keyboard Shortcuts" section with category headers
- All 16 default shortcuts are listed with their labels
- Clicking a capture widget enters recording mode, pressing a key updates the binding
- Conflicts are detected and shown in a warning banner with Overwrite/Cancel options
- "Reset All to Defaults" button calls store.resetAll()
- Per-shortcut reset icon appears on hover when binding differs from default
- Panel-specific shortcuts show their scope (e.g., "track-list") next to the label
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md`
</output>
@@ -0,0 +1,164 @@
---
phase: 09-scan-cancellation-keyboard-shortcuts
plan: 05
type: execute
wave: 3
depends_on:
- 09-01
- 09-02
- 09-03
- 09-04
files_modified: []
autonomous: false
requirements:
- SCAN-01
- SCAN-02
- SCAN-03
- KEY-01
- KEY-02
- KEY-03
- KEY-04
- KEY-05
must_haves:
truths:
- "User can start a scan, pause it, resume it, and cancel it — all via buttons in the settings page"
- "Cancelled scan does not corrupt the database or delete unvisited files"
- "Default keyboard shortcuts work immediately — Space, arrows, S, R, Q, M, N, P, /, Ctrl+F"
- "Shortcuts are suppressed when typing in search box (except Escape)"
- "User can rebind any shortcut via record-style capture in settings"
- "Shortcut conflicts are detected and warned about"
- "Shortcut bindings persist across app restart"
artifacts: []
key_links: []
---
<objective>
Verify all Phase 9 features work together end-to-end — scan control and keyboard shortcuts.
Purpose: Catch integration issues before marking the phase complete.
Output: Verification results and any integration fixes needed.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Build verification and automated checks</name>
<files></files>
<action>
1. Run the full build to verify everything compiles:
```bash
cd backend && go build ./...
cd ../frontend && npx tsc --noEmit
```
2. Run existing tests to verify no regressions:
```bash
cd backend && go test ./... -count=1 -timeout 120s
```
3. Run go vet on all packages:
```bash
cd backend && go vet ./...
```
4. Verify event sync is up to date:
```bash
cd backend && go generate ./events/...
git diff --exit-code frontend/src/events.ts
```
5. Verify the new scan control methods are Wails-bindable (exported, on a bound struct):
```bash
grep -n "func (l \*Library) CancelScan\|func (l \*Library) PauseScan\|func (l \*Library) ResumeScan\|func (l \*Library) IsScanActive\|func (l \*Library) IsScanPaused" backend/library/scan_control.go
```
6. Verify shortcuts config is accessible:
```bash
grep -n "func (c \*Config) GetShortcuts\|func (c \*Config) SetShortcut" backend/config/config.go
```
7. Fix any issues found.
</action>
<verify>
<automated>cd backend && go build ./... && go vet ./... && go test ./... -count=1 -timeout 120s 2>&1 | tail -20</automated>
</verify>
<done>Full backend + frontend build passes, all existing tests pass, no regressions.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Human verification of all Phase 9 features</name>
<action>Verify all scan control and keyboard shortcut features work end-to-end.</action>
<verify>Human confirms all 23 verification steps pass.</verify>
<done>All Phase 9 requirements verified: SCAN-01/02/03 and KEY-01/02/03/04/05.</done>
<what-built>
Complete scan cancellation and keyboard shortcuts features:
1. Backend: CancelScan/PauseScan/ResumeScan methods with per-scan context and channel-based pause
2. Frontend scan UI: Pause/Resume/Cancel buttons during scan, cancel confirmation dialog
3. Keyboard shortcuts: 16 default bindings (Space, arrows, S/R/Q/M/N/P, /, Ctrl+F, Ctrl+A, Enter, Delete)
4. Keyboard shortcut settings: Record-style key capture, conflict detection, grouped by category, reset to defaults
5. Config persistence: Shortcuts saved to TOML config file
</what-built>
<how-to-verify>
**Scan Control (Settings > Library):**
1. Open Settings, configure a library directory with many audio files
2. Click "Soft Scan" — verify Pause and Cancel buttons appear, progress shows
3. Click "Pause" — verify status says "Scan paused.", button changes to "Resume"
4. Click "Resume" — verify scan continues from where it left off
5. Start another scan, click "Cancel Scan" — verify confirmation dialog appears showing track count
6. Click "Keep X tracks" — verify scan stops, tracks remain in library
7. Start another scan, cancel, click "Discard" — verify scan stops with discard message
**Keyboard Shortcuts:**
8. Without any text input focused, press Space — verify play/pause toggles
9. Press Up/Down arrows — verify volume changes
10. Press Left/Right arrows — verify seeking (if a track is playing)
11. Press S — verify shuffle toggles
12. Press R — verify repeat mode cycles
13. Press Q — verify queue panel toggles
14. Press / or Ctrl+F — verify search box gets focus
15. Click inside the search box, type — verify shortcuts do NOT fire while typing
16. Press Escape while in search box — verify search box blurs and shortcuts resume
**Shortcut Settings (Settings > Keyboard Shortcuts):**
17. Scroll to Keyboard Shortcuts section — verify shortcuts grouped by Player, Navigation, App
18. Click on a shortcut's key badge (e.g., Space for Play/Pause) — verify it enters "Press a key combo..." mode
19. Press a new key — verify the binding updates
20. Try binding a key that's already used — verify conflict warning appears
21. Click "Overwrite" — verify old binding is cleared and new one is set
22. Click "Reset All to Defaults" — verify all shortcuts return to defaults
23. Restart the app — verify custom bindings persist
</how-to-verify>
<resume-signal>Type "approved" or describe any issues found</resume-signal>
</task>
</tasks>
<verification>
Full build passes. All existing tests pass. Human verification covers all 8 requirement IDs.
</verification>
<success_criteria>
- `go build ./...` and `npx tsc --noEmit` pass
- `go test ./...` passes with no regressions
- All 23 manual verification steps confirmed by user
</success_criteria>
<output>
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-05-SUMMARY.md`
</output>