docs(09): create phase plan — 5 plans in 3 waves for scan cancellation & keyboard shortcuts
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user