diff --git a/backend/config/general.go b/backend/config/general.go index 750ec65..5e6609e 100644 --- a/backend/config/general.go +++ b/backend/config/general.go @@ -57,7 +57,17 @@ type GeneralConfig struct { } // ApplyDefaults fills zero-value fields with sensible defaults. +// +// A launch page naming a *retired* view is treated as a zero value +// rather than as an error, because the alternative is an app that will +// not start for anyone who had that page selected when it was removed. +// An unknown-but-not-retired name still fails Validate: that is a typo, +// and telling someone about it is the useful answer. func (c *GeneralConfig) ApplyDefaults() { + if _, retired := RetiredViews[c.DefaultPage]; retired { + c.DefaultPage = "" + } + if c.DefaultPage == "" { c.DefaultPage = DefaultDefaultPage } diff --git a/backend/config/views.go b/backend/config/views.go index 87eb47b..fdafd37 100644 --- a/backend/config/views.go +++ b/backend/config/views.go @@ -24,10 +24,25 @@ const ( ViewExplore View = "explore" ViewDownloads View = "downloads" ViewAutotag View = "autotag" - ViewJobs View = "jobs" ViewSettings View = "settings" ) +// RetiredViews are destinations that used to exist and no longer do. +// +// A *visibility* entry for a removed view needs no such list: it is a +// key in a map, and an unknown key is dropped on load. A `DefaultPage` +// is a **value**, and an unknown one fails validation -- which on the +// load path means the app refuses to start rather than a setting being +// ignored. So the one shape that cannot be retired for free is named +// here and reset to the default instead. +// +// `jobs` was folded into Settings by #27: library scans under +// Libraries, index work under Search Index, downloads under the +// download clients, and the autotag apply into the Autotag view. +var RetiredViews = map[View]struct{}{ + "jobs": {}, +} + // ViewSpec is what the backend knows about a destination. The label and // the icon are deliberately absent: those are presentation, they live // beside the rest of the app's icon vocabulary in @@ -73,7 +88,6 @@ var Views = []ViewSpec{ {ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true}, {ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true}, {ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true}, - {ID: ViewJobs, VisibleByDefault: true, Hideable: true, CanLaunch: true}, {ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false}, } diff --git a/backend/config/views_test.go b/backend/config/views_test.go index f14b14f..5d3916f 100644 --- a/backend/config/views_test.go +++ b/backend/config/views_test.go @@ -66,7 +66,7 @@ func TestViewVisibilityStoredWins(t *testing.T) { general := &GeneralConfig{ ViewVisibility: map[string]bool{ string(ViewAutotag): true, - string(ViewJobs): false, + string(ViewExplore): false, }, } general.ApplyDefaults() @@ -77,8 +77,8 @@ func TestViewVisibilityStoredWins(t *testing.T) { t.Error("autotag was switched on and should be visible") } - if resolved[string(ViewJobs)] { - t.Error("jobs was switched off and should be hidden") + if resolved[string(ViewExplore)] { + t.Error("explore was switched off and should be hidden") } } @@ -136,6 +136,58 @@ func TestValidateRevealsAHiddenLaunchPage(t *testing.T) { } } +// A launch page naming a view that no longer exists resets to the +// default instead of failing validation, which on the load path would +// mean the app refusing to start for whoever had it selected. +// +// This is the one shape #25's storage decision does *not* make free: a +// visibility entry is a key and an unknown key is dropped, but a launch +// page is a value. +func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) { + t.Parallel() + + general := &GeneralConfig{DefaultPage: "jobs"} + + if err := general.Validate(); err != nil { + t.Fatalf("Validate() error: %v", err) + } + + if general.DefaultPage != DefaultDefaultPage { + t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage) + } +} + +// A name that is merely wrong is still an error: that is a typo, and +// saying so is more useful than ignoring it. +func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) { + t.Parallel() + + general := &GeneralConfig{DefaultPage: "nonsense"} + + if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) { + t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err) + } +} + +// A retired view is not a view, so nothing offers it and nothing +// resolves it -- the visibility map included. +func TestARetiredViewIsGone(t *testing.T) { + t.Parallel() + + for id := range RetiredViews { + if _, ok := LookupView(string(id)); ok { + t.Errorf("%s is retired but still in Views", id) + } + + general := &GeneralConfig{} + general.ApplyDefaults() + + if _, ok := general.ResolvedViewVisibility()[string(id)]; ok { + t.Errorf("%s is retired but still resolves a visibility", id) + } + } +} + // Settings may not be the launch page, which is the shape the old // DefaultPage enum had and is now read off the same table. func TestValidateRejectsAnUnlaunchablePage(t *testing.T) { @@ -213,7 +265,7 @@ func TestViewVisibilityRoundTrips(t *testing.T) { t.Fatalf("SetViewVisible() error: %v", err) } - if err := original.SetViewVisible(string(ViewJobs), false); err != nil { + if err := original.SetViewVisible(string(ViewExplore), false); err != nil { t.Fatalf("SetViewVisible() error: %v", err) } @@ -228,8 +280,8 @@ func TestViewVisibilityRoundTrips(t *testing.T) { t.Error("autotag should have loaded as visible") } - if resolved[string(ViewJobs)] { - t.Error("jobs should have loaded as hidden") + if resolved[string(ViewExplore)] { + t.Error("explore should have loaded as hidden") } } diff --git a/frontend/index.ts b/frontend/index.ts index 7b51668..3ebfa2b 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -101,7 +101,6 @@ const VIEW_TAGS: Record = { explore: 'explore-view', autotag: 'autotag-view', downloads: 'downloads-view', - jobs: 'jobs-view', settings: 'config-page', }; @@ -119,7 +118,6 @@ const VIEW_LOADERS: Record Promise> = { explore: () => import('@components/explore-view/explore-view.ts'), autotag: () => import('@components/autotag-view/autotag-view.ts'), downloads: () => import('@components/downloads-view/downloads-view.ts'), - jobs: () => import('@components/jobs/jobs-view.ts'), settings: () => import('@components/config-page/config-page.ts'), }; diff --git a/frontend/src/components/jobs/jobs-view.ts b/frontend/src/components/jobs/jobs-view.ts deleted file mode 100644 index cdd1261..0000000 --- a/frontend/src/components/jobs/jobs-view.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { LitElement, html, css, nothing } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import '@awesome.me/webawesome/dist/components/icon/icon.js'; -import '@components/page-header/page-header'; -import { designTokens } from '../../styles/tokens.css'; -import { - GetAllLibrariesWithTrackCounts, - ScanLibrary, - ScanAllLibraries, - FullRescan, -} from '@go/library/library.js'; -import type * as library from '@go/library/models.js'; -import { EventsOn } from '@runtime/runtime'; -import { Events } from '../../events'; -import { jobStore } from '@store/job-store'; -import type { Job } from '@store/job-store'; -import { notificationStore } from '@store/notification-store'; -import { describeError } from '@utils/describe-error'; -import { confirmAction } from '../confirm-dialog/confirm-dialog'; -import './job-row'; -import './job-details-drawer'; -import { applyJobControl } from './job-controls'; -import { jobStateStyles } from './job-format'; -import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; - -type LibraryInfo = library.Info; - -/** Job states meaning the job will not progress further. */ -const TERMINAL_STATES: ReadonlySet = new Set([ - 'complete', - 'cancelled', - 'error', -]); - -/** - * Full-page view of background work: everything running right now, the - * per-library scan controls that used to live in Settings, and a short - * history of what recently finished. - * - * This is the same job rows as the top-bar popover at a larger density — - * one implementation, two placements, so the two can never disagree. - */ -@customElement('jobs-view') -export class JobsView extends ViewLifecycleMixin(LitElement) { - @state() - private jobs: Job[] = []; - - @state() - private libraries: LibraryInfo[] = []; - - @state() - private drawerJobId = ''; - - @state() - private drawerOpen = false; - - /** - * Set between pressing a scan button and the job snapshot that - * proves it started. `anyScanning` is derived from `JobsChanged`, - * which is coalesced at 250 ms — long enough for a second click to - * start a second scan (errors.M5). - */ - @state() - private starting = false; - - private unsubscribe: (() => void) | null = null; - - private eventCleanups: Array<() => void> = []; - - static override styles = [ - designTokens, - jobStateStyles, - css` - :host { - display: block; - overflow-y: auto; - height: 100%; - padding: 1.5em 1.75em 3em; - box-sizing: border-box; - } - - /* The header supplies its own padding and rule, so it runs - to the edge of a host that pads its own content. */ - page-header { - margin: -1.5em -1.75em 1em; - } - - h1 { - font-size: var(--yj-text-xl); - color: var(--yj-text-primary, #e9ecef); - margin: 0 0 0.2em; - } - - .page-sub { - font-size: var(--yj-text-md); - color: var(--yj-text-tertiary, #868e96); - margin: 0 0 1.75em; - } - - section { - margin-bottom: 2em; - } - - .section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1em; - margin-bottom: 0.75em; - } - - h2 { - font-size: var(--yj-text-sm); - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--yj-text-tertiary, #868e96); - margin: 0; - } - - .card { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 10px; - overflow: hidden; - } - - .card > * + * { - border-top: 1px solid rgba(255, 255, 255, 0.06); - } - - .job-entry { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 0.5em; - padding-right: 0.75em; - } - - .empty { - padding: 1.1em; - font-size: var(--yj-text-md); - color: var(--yj-text-tertiary, #868e96); - font-style: italic; - } - - .library-row { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 1em; - padding: 0.75em 0.9em; - } - - .library-name { - font-size: var(--yj-text-md); - color: var(--yj-text-primary, #e9ecef); - } - - .library-meta { - font-size: var(--yj-text-sm); - color: var(--yj-text-tertiary, #868e96); - margin-top: 0.15em; - overflow-wrap: anywhere; - } - - .library-state { - font-size: var(--yj-text-sm); - color: var(--job-tone); - margin-top: 0.15em; - } - - button.action { - display: inline-flex; - align-items: center; - gap: 0.45em; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 7px; - background: rgba(255, 255, 255, 0.05); - color: var(--yj-text-primary, #e9ecef); - font-size: var(--yj-text-sm); - padding: 0.42em 0.85em; - cursor: pointer; - white-space: nowrap; - transition: - background-color 120ms ease, - border-color 120ms ease; - } - - button.action:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.11); - } - - button.action:disabled { - opacity: 0.4; - cursor: default; - } - - button.action:focus-visible { - outline: 2px solid var(--yj-accent, #ffd43b); - outline-offset: 2px; - } - - button.action.danger { - color: var(--yj-error-text, #ff8787); - border-color: rgba(255, 107, 107, 0.35); - } - - button.action.danger:hover:not(:disabled) { - background: rgba(255, 107, 107, 0.12); - } - - button.link { - border: none; - background: transparent; - color: var(--yj-accent-text, #ffd43b); - font-size: var(--yj-text-sm); - cursor: pointer; - padding: 0.2em 0.4em; - border-radius: 5px; - } - - button.link:hover { - text-decoration: underline; - } - - .details-btn { - border: none; - background: transparent; - color: var(--yj-text-secondary, #adb5bd); - font-size: var(--yj-text-sm); - cursor: pointer; - padding: 0.3em 0.5em; - border-radius: 6px; - white-space: nowrap; - } - - .details-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--yj-text-primary, #e9ecef); - } - `, - ]; - - protected override onViewActivate(): void { - this.unsubscribe = jobStore.subscribe(() => { - this.jobs = jobStore.jobs; - }); - void jobStore.init(); - this.jobs = jobStore.jobs; - void this.loadLibraries(); - - // Library CRUD happens elsewhere; keep the picker in step. - for (const event of [ - Events.LibraryAdded, - Events.LibraryRemoved, - Events.LibraryRenamed, - Events.LibraryScanComplete, - ]) { - this.eventCleanups.push( - EventsOn(event, () => void this.loadLibraries()), - ); - } - } - - protected override onViewDeactivate(): void { - this.unsubscribe?.(); - this.unsubscribe = null; - this.eventCleanups.forEach((off) => off()); - this.eventCleanups = []; - } - - private async loadLibraries(): Promise { - try { - this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? []; - } catch (err) { - console.error('Failed to load libraries:', err); - } - } - - /** The scan job for a library, if one is registered. */ - private jobForLibrary(id: number): Job | undefined { - return jobStore.getJob(`scan:${id}`); - } - - private openDetails(id: string) { - this.drawerJobId = id; - this.drawerOpen = true; - } - - private onDrawerClosed = () => { - this.drawerOpen = false; - }; - - /** - * Run something that starts a job, holding the buttons until the - * snapshot lands and saying so when it does not start at all. - * - * Persistent, not a toast: the user asked for work to happen, it - * did not, and retrying is exactly the useful response. - */ - private async startJob( - what: string, - start: () => Promise, - retry: () => void, - ): Promise { - if (this.starting) return; - - this.starting = true; - - try { - await start(); - } catch (err) { - console.error(`${what} failed:`, err); - notificationStore.persistent({ - key: 'scan-start', - title: 'Scan did not start', - text: `${what} failed. ${describeError(err)}`, - detail: String(err), - action: { label: 'Try again', run: retry }, - }); - } finally { - this.starting = false; - } - } - - private async startScan(id: number) { - await this.startJob( - 'Scanning that library', - () => ScanLibrary(id), - () => void this.startScan(id), - ); - } - - private async startAllScans() { - await this.startJob( - 'Scanning your libraries', - () => ScanAllLibraries(), - () => void this.startAllScans(), - ); - } - - private async clearFinished() { - await jobStore.clearFinished(); - } - - private async fullRescan() { - const ok = await confirmAction({ - title: 'Full rescan', - message: - 'This deletes all library data — including downloaded ' + - 'cover art — and rebuilds it from your files.', - impact: - 'It is not the same as “Scan now”, which only picks up ' + - 'what changed.', - confirmLabel: 'Rebuild everything', - danger: true, - }); - - if (!ok) return; - - await this.startJob( - 'The full rescan', - () => FullRescan(), - () => void this.fullRescan(), - ); - } - - private renderJobList(list: Job[], emptyText: string) { - if (list.length === 0) { - return html`
-
${emptyText}
-
`; - } - - return html` -
- ${list.map( - (job) => html` -
- - -
- `, - )} -
- `; - } - - /** The status line under a library name in the scan-control list. */ - private libraryStatus(job: Job | undefined): string | null { - if (!job) return null; - - switch (job.state) { - case 'running': - return job.phase ? `Scanning · ${job.phase}` : 'Scanning'; - case 'queued': - return 'Queued'; - case 'paused': - return 'Paused'; - case 'pausing': - return 'Pausing…'; - case 'cancelling': - return 'Stopping…'; - default: - return null; - } - } - - private renderLibraryRow(lib: LibraryInfo) { - const job = this.jobForLibrary(lib.id); - const status = this.libraryStatus(job); - const busy = status !== null; - - return html` -
-
-
${lib.name}
-
- ${lib.trackCount.toLocaleString()} tracks · ${lib.path} -
- ${status - ? html`
${status}
` - : nothing} -
- - ${busy - ? html` - - ` - : html` - - `} -
- `; - } - - override render() { - // Derived from `this.jobs` rather than the store getters so Lit - // sees the reactive dependency and re-renders on every snapshot. - const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state)); - const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state)); - const anyScanning = this.libraries.some((lib) => - Boolean(this.libraryStatus(this.jobForLibrary(lib.id))), - ); - - return html` - -

- Library scans and search index builds, with their progress and - output. -

- -
-
-

Running now

-
- ${this.renderJobList(active, 'Nothing is running.')} -
- -
-
-

Libraries

- -
- -
- ${this.libraries.length === 0 - ? html`
- No libraries yet — add one in Settings. -
` - : this.libraries.map((lib) => - this.renderLibraryRow(lib), - )} -
-
- -
-
-

Maintenance

-
-
-
-
-
Full rescan
-
- Wipes all library data and cover art, then - rebuilds from your files. Only needed when the - library is corrupt — a normal scan already - picks up changes. -
-
- -
-
-
- - ${finished.length > 0 - ? html` -
-
-

Recently finished

- -
- ${this.renderJobList(finished, '')} -
- ` - : nothing} - - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'jobs-view': JobsView; - } -} diff --git a/frontend/src/services/view-meta.ts b/frontend/src/services/view-meta.ts index 871bab1..e51bbea 100644 --- a/frontend/src/services/view-meta.ts +++ b/frontend/src/services/view-meta.ts @@ -15,7 +15,6 @@ export type View = | 'explore' | 'downloads' | 'autotag' - | 'jobs' | 'settings'; export interface ViewMeta { @@ -61,6 +60,5 @@ export const VIEW_META: ViewMeta[] = [ { id: 'explore', label: 'Explore', icon: 'globe' }, { id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED }, { id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG }, - { id: 'jobs', label: 'Jobs', icon: 'list-check' }, { id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true }, ];