Three properties the phone's band needs, added here so it is the same panel rather than a second job UI -- which is what keeps `applyJobControl` and its "you will discard hours of downloading" confirmation in the picture. `kinds="*"` is every kind, which is what the header indicator was for. Spelled as a star rather than taken as the meaning of an empty attribute, because empty is what a typo and a dropped binding both produce and "show everything" is the wrong thing to do by accident; empty still shows nothing. `density` is passed to `job-row`, whose `compact` variant its own source calls "the popover density" -- which is exactly what the band replaces. `full` stays the default, so the four settings call sites are untouched. `active-only` drops terminal rows. The band is in the layout, so a finished row there holds the content down after the work is done; Settings keeps them, because that is where "did the last scan work" is asked and a finished row there dismisses itself.
268 lines
9.0 KiB
TypeScript
268 lines
9.0 KiB
TypeScript
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<string> {
|
|
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`<h3>${this.heading}</h3>` : nothing}
|
|
<div class="card">
|
|
${[...active, ...finished].map(
|
|
(job) => html`
|
|
<div class="job-entry">
|
|
<job-row
|
|
.job=${job}
|
|
variant=${this.density}
|
|
@job-control=${applyJobControl}
|
|
></job-row>
|
|
<button
|
|
type="button"
|
|
class="details-btn"
|
|
@click=${() => this.openDetails(job.id)}
|
|
>
|
|
Details${job.warnCount
|
|
? ` · ${job.warnCount}⚠`
|
|
: ''}
|
|
</button>
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
|
|
<job-details-drawer
|
|
job-id=${this.drawerJobId}
|
|
?open=${this.drawerOpen}
|
|
@drawer-closed=${this.onDrawerClosed}
|
|
></job-details-drawer>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'job-panel': JobPanel;
|
|
}
|
|
}
|