From f3d1ae1c8c303153ac8a8f97bfb3306ea3ac16d0 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 19 Aug 2026 20:27:22 -0400 Subject: [PATCH] feat(jobs): show background work where the work is started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the app before moving anything turned up that four of the five job kinds already have a home showing their work: Settings → Search Index draws per-tier index progress, `downloads-view` draws every download's lifecycle state, `autotag-view` draws its own apply ring, and only `library-scan` had nowhere but the Jobs tab. What none of the four had is the *generic* affordances — pause, cancel, Details, the log, and a finished job you can dismiss. So this is a panel embedded beside each of them rather than one "Background jobs" section in Settings, which would have been the tab again under another name. Three rules in it. The controls are `applyJobControl`, not a reimplementation, which is what keeps the index build's "you will discard hours of downloading" confirmation alive across the move. A panel with nothing to say is `hidden` rather than empty, host margin included, because an idle panel in four places is four pieces of furniture describing an absence. And there is no "Clear finished": `ClearFinishedJobs` is global, so a Clear under Libraries would discard the index build's history too. `JobKind` also gains `download`, which the backend has had all along. --- frontend/src/components/jobs/job-panel.ts | 229 +++++++++++++++++++++ frontend/src/store/job-store.ts | 1 + frontend/test/components/job-panel.test.ts | 160 ++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 frontend/src/components/jobs/job-panel.ts create mode 100644 frontend/test/components/job-panel.test.ts diff --git a/frontend/src/components/jobs/job-panel.ts b/frontend/src/components/jobs/job-panel.ts new file mode 100644 index 0000000..5bc5808 --- /dev/null +++ b/frontend/src/components/jobs/job-panel.ts @@ -0,0 +1,229 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { designTokens } from '../../styles/tokens.css'; +import { jobStore } from '@store/job-store'; +import type { Job, JobKind } from '@store/job-store'; +import { isTerminal } from '@store/job-store'; +import './job-row'; +import './job-details-drawer'; +import { applyJobControl } from './job-controls'; +import { jobStateStyles } from './job-format'; + +/** + * The background work of one kind, rendered wherever that work is + * started or configured. + * + * #27 folded the Jobs tab away, and the shape it folded into is this + * rather than one "Background jobs" panel in Settings — which would + * have been the tab again under another name. Reading the app first + * turned up that **four of the five job kinds already had a home** + * showing their work: Settings → Search Index draws per-tier index + * progress, `downloads-view` draws every download's lifecycle state, + * `autotag-view` draws its own apply ring, and only `library-scan` had + * nowhere but the tab. What none of those four had is the *generic* + * affordances — pause, cancel, "Details", the log, and a finished job + * you can dismiss — which is what this carries to each of them. + * + * Three things about it are load-bearing. + * + * **The controls are `applyJobControl`, not a reimplementation.** That + * is what keeps the "you will discard hours of downloading" + * confirmation on an index build alive across the move: it is keyed on + * `KindIndexBuild` inside the shared handler, and a host that rendered + * its own buttons would silently drop it. + * + * **A panel with nothing to say renders nothing at all**, host padding + * included — an idle panel in four places is four pieces of furniture + * describing an absence. That is the rule `startBackfillJob` follows + * for the indicator, one layer up. + * + * **There is no "Clear finished" here**, because `ClearFinishedJobs` is + * global: a Clear in the Libraries panel would silently discard the + * index build's history too. A finished row dismisses itself, which is + * per-job and is what `job-row` already offers. + */ +@customElement('job-panel') +export class JobPanel extends LitElement { + /** + * Comma-separated job kinds, e.g. `index-build,catalog-enrich`. + * + * An attribute rather than a property because every call site is a + * literal in a template, and one of them is inside an HTMX-adjacent + * settings page where a property binding would be one more thing to + * remember. + */ + @property({ type: String }) + kinds = ''; + + /** Heading above the rows. Omitted renders no heading. */ + @property({ type: String }) + heading = ''; + + @state() + private jobs: Job[] = []; + + @state() + private drawerJobId = ''; + + @state() + private drawerOpen = false; + + private unsubscribe: (() => void) | null = null; + + static override styles = [ + designTokens, + jobStateStyles, + css` + :host { + display: block; + margin-top: 1em; + } + + /* An empty panel takes no room at all, margin included. */ + :host([hidden]) { + display: none; + } + + h3 { + font-size: var(--yj-text-sm); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--yj-text-tertiary, #868e96); + margin: 0 0 0.5em; + } + + .card { + background: var(--yj-bg-surface, #2b3035); + border: 1px solid var(--yj-border, #495057); + border-radius: 6px; + overflow: hidden; + } + + .job-entry { + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--yj-border-subtle, #3a4046); + } + + .job-entry:last-child { + border-bottom: none; + } + + job-row { + flex: 1; + /* A grid child's implicit minimum is its content, and a + job title is long. */ + min-width: 0; + } + + .details-btn { + background: none; + border: 1px solid var(--yj-border, #495057); + border-radius: 4px; + color: var(--yj-text-secondary, #adb5bd); + cursor: pointer; + font-family: inherit; + font-size: var(--yj-text-sm); + padding: 0.3em 0.6em; + white-space: nowrap; + } + + .details-btn:hover { + color: var(--yj-text-primary, #e9ecef); + } + `, + ]; + + override connectedCallback() { + super.connectedCallback(); + this.unsubscribe = jobStore.subscribe(() => { + this.jobs = jobStore.jobs; + }); + this.jobs = jobStore.jobs; + void jobStore.init(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.unsubscribe?.(); + this.unsubscribe = null; + } + + /** The kinds this panel answers for. */ + private get wanted(): ReadonlySet { + return new Set( + this.kinds + .split(',') + .map((k) => k.trim()) + .filter(Boolean), + ); + } + + private get mine(): Job[] { + const wanted = this.wanted; + + return this.jobs.filter((job) => wanted.has(job.kind as JobKind)); + } + + private openDetails(id: string) { + this.drawerJobId = id; + this.drawerOpen = true; + } + + private onDrawerClosed = () => { + this.drawerOpen = false; + }; + + override render() { + const mine = this.mine; + + // Hidden rather than empty: see the class comment. The drawer + // goes with it, since it can only have been opened from a row. + this.hidden = mine.length === 0; + + if (mine.length === 0) return nothing; + + const active = mine.filter((job) => !isTerminal(job)); + const finished = mine.filter(isTerminal); + + return html` + ${this.heading ? html`

${this.heading}

` : nothing} +
+ ${[...active, ...finished].map( + (job) => html` +
+ + +
+ `, + )} +
+ + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'job-panel': JobPanel; + } +} diff --git a/frontend/src/store/job-store.ts b/frontend/src/store/job-store.ts index 29e83f8..7f06c49 100644 --- a/frontend/src/store/job-store.ts +++ b/frontend/src/store/job-store.ts @@ -30,6 +30,7 @@ export type JobState = export type JobKind = | 'library-scan' | 'index-build' + | 'download' | 'autotag-apply' | 'catalog-enrich'; diff --git a/frontend/test/components/job-panel.test.ts b/frontend/test/components/job-panel.test.ts new file mode 100644 index 0000000..978d42b --- /dev/null +++ b/frontend/test/components/job-panel.test.ts @@ -0,0 +1,160 @@ +/** + * Background work, shown where the work is started (#27). + * + * The Jobs tab is gone; each kind's rows now live beside the thing that + * starts it — scans in Settings → Libraries, index work in Search + * Index, downloads under the download clients, the autotag apply in the + * Autotag view. What those four surfaces never had, and what this + * carries to them, is the *generic* affordances: pause, cancel, + * "Details", and a finished job you can dismiss. + * + * The assertions are about which rows a panel owns and what its buttons + * do, not about the store — `job-store` already has the snapshot + * covered, and a panel that renders the right rows for the wrong reason + * would pass either way. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/jobs/job-panel'; +import { emit, flush, lastArgs, calls, resetHarness, stub } from '@test/support/harness'; +import { Events } from '../../src/events'; +import { fixture, shadow, shadowAll } from '@test/support/render'; +import type { LitElement } from 'lit'; + +/** A job snapshot entry, with the fields `job-row` actually reads. */ +const job = (over: Record = {}) => ({ + id: 'scan:1', + kind: 'library-scan', + title: 'Scanning Music', + state: 'running', + current: 3, + total: 10, + caps: { pausable: true, cancellable: true }, + stages: null, + stats: null, + startedAt: Date.now(), + updatedAt: Date.now(), + logCount: 0, + warnCount: 0, + errorCount: 0, + ...over, +}); + +/** Push a full snapshot, which is what the backend emits. */ +async function snapshot(jobs: unknown[]): Promise { + emit(Events.JobsChanged, jobs); + await flush(); +} + +const rows = (el: HTMLElement) => shadowAll(el, 'job-row'); + +const titles = (el: HTMLElement) => + rows(el).map((row) => (row as HTMLElement & { job: { title: string } }).job.title); + +describe('', () => { + beforeEach(async () => { + resetHarness(); + stub('jobs.Service.GetJobs', []); + await snapshot([]); + }); + + it('renders only the kinds it was asked for', async () => { + const el = await fixture('job-panel', { + kinds: 'index-build,catalog-enrich', + }); + + await snapshot([ + job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }), + job({ id: 'idx', kind: 'index-build', title: 'Building the index' }), + job({ id: 'enrich', kind: 'catalog-enrich', title: 'Filling in artists' }), + ]); + await el.updateComplete; + + // The title is inside `job-row`'s own shadow root, so this asks + // the rows what they are drawing rather than reading the panel's + // text -- which would pass whether or not a row rendered. + expect(titles(el)).toEqual(['Building the index', 'Filling in artists']); + }); + + /** + * An idle panel in four places is four pieces of furniture describing + * an absence — and `hidden` rather than an empty render, because the + * host's own margin would otherwise still be spent. + */ + it('takes up no room when it has nothing to say', async () => { + const el = await fixture('job-panel', { kinds: 'download' }); + + await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]); + await el.updateComplete; + + expect(el.hidden).toBe(true); + expect(rows(el)).toHaveLength(0); + + await snapshot([ + job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }), + ]); + await el.updateComplete; + + expect(el.hidden).toBe(false); + expect(rows(el)).toHaveLength(1); + }); + + /** + * The controls go through `applyJobControl`, which is what carries + * the index build's "you will discard hours of downloading" + * confirmation across this move. A host drawing its own buttons would + * have dropped it silently. + */ + it('pauses through the shared handler', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job()]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Pause"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.PauseJob')).toEqual(['scan:1']); + }); + + /** + * Cancelling an index build asks first; cancelling a scan does not, + * because a scan is cheap to re-run. Both answers live in + * `applyJobControl` and both had to survive the move. + */ + it('does not ask before cancelling a scan', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job()]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Stop"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.CancelJob')).toEqual(['scan:1']); + }); + + /** + * A finished job is dismissed one at a time. There is deliberately no + * "Clear finished" here: `ClearFinishedJobs` is global, so a Clear in + * the Libraries panel would discard the index build's history too. + */ + it('keeps finished jobs, dismissible one by one', async () => { + const el = await fixture('job-panel', { kinds: 'library-scan' }); + + await snapshot([job({ state: 'complete' })]); + await el.updateComplete; + + const row = rows(el)[0]!; + + shadow(row, 'button[aria-label^="Dismiss"]')?.click(); + await flush(); + + expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']); + expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0); + }); +});