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. * * **`*` means every kind**, which is the phone's band (#62) and * nothing else: there, this panel is standing in for the header * indicator, whose whole job was to be the one view of everything * at once. It is spelled `*` rather than taken as the meaning of an * empty attribute, because empty is what a typo and a missing * binding both produce and "show everything" is the wrong thing to * do by accident. Empty still shows nothing. */ @property({ type: String }) kinds = ''; /** Heading above the rows. Omitted renders no heading. */ @property({ type: String }) heading = ''; /** * Row density, passed to `job-row`. * * `full` adds elapsed time and per-job statistics and is what a * settings section wants, so it stays the default and the four * existing call sites are unchanged. `compact` is what `job-row` * itself calls "the popover density", and it is what the phone's * band uses (#62) — there this panel *is* the popover, on a screen * 439 CSS px tall, and the full density spent 259 of them. */ @property({ type: String }) density: 'compact' | 'full' = 'full'; /** * Drop finished rows. * * For the phone's band (#62), which is *in the layout*: a finished * row there is a banner that stays after the work is done and keeps * the content pushed down. Settings keeps them, because that is * where "did the last scan work" is asked, and a finished row there * dismisses itself. */ @property({ type: Boolean, attribute: 'active-only' }) activeOnly = false; @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 ofKind = this.kinds.trim() === '*' ? this.jobs : this.jobs.filter((job) => this.wanted.has(job.kind as JobKind), ); return this.activeOnly ? ofKind.filter((job) => !isTerminal(job)) : ofKind; } 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; } }