Fold the Jobs tab into the places the work is started #147
@@ -3732,3 +3732,32 @@ so it is the mechanism and not a test-only door.
|
|||||||
|
|
||||||
Use the nav item when the *nav* is the subject, and `navigateTo` when
|
Use the nav item when the *nav* is the subject, and `navigateTo` when
|
||||||
the view is.
|
the view is.
|
||||||
|
|
||||||
|
## `config-section .header` is ambiguous once a job exists (2026-08-19)
|
||||||
|
|
||||||
|
#27 embeds `<job-panel>` inside four Settings sections, and a panel with
|
||||||
|
any job in it also mounts a `job-details-drawer` — whose own header
|
||||||
|
carries the class `.header`. So `config-page config-section .header`,
|
||||||
|
which `settings-reach.spec.ts` had used since plan 007, resolves to two
|
||||||
|
elements and fails Playwright's strict mode the moment a scan has run.
|
||||||
|
|
||||||
|
Two things follow. A spec asserting on a section's *disclosure* should
|
||||||
|
locate it by role and name (`getByRole('button', {name: heading})`) or
|
||||||
|
scope per section and take `.first()`, not by that class. And this is a
|
||||||
|
worked example of the more general trap: a class name is not a
|
||||||
|
selector's contract, and a component that embeds another inherits its
|
||||||
|
class names into every ancestor query.
|
||||||
|
|
||||||
|
It also only appears in a suite that has *done* something — the
|
||||||
|
sections are empty on a fresh app, so this cannot be reproduced by
|
||||||
|
opening Settings and looking.
|
||||||
|
|
||||||
|
**And it appears on the second engine, not the first.** CI runs
|
||||||
|
chromium then webkit against **one app**, so a spec that scans in the
|
||||||
|
chromium pass leaves a finished job the webkit pass then trips over.
|
||||||
|
Three specs used that selector; two failed locally and the third
|
||||||
|
(`failure-voice.spec.ts`) was green on chromium and red on webkit in
|
||||||
|
the same run. Reproducing it locally is running the suite twice against
|
||||||
|
one `make dev-headless` — which is worth doing for any change that
|
||||||
|
leaves state behind, since it is the only place a cross-engine order
|
||||||
|
dependency shows up.
|
||||||
|
|||||||
@@ -514,6 +514,36 @@ rather than renaming them.
|
|||||||
the autotag apply are registered; anything that is not registered has
|
the autotag apply are registered; anything that is not registered has
|
||||||
none of that, which is exactly how the three gaps the audit found
|
none of that, which is exactly how the three gaps the audit found
|
||||||
came about.
|
came about.
|
||||||
|
|
||||||
|
**Its rows are shown where the work is started, not on a page of
|
||||||
|
their own.** #27 folded the Jobs destination away, and the shape it
|
||||||
|
folded into is `<job-panel kinds="…">` embedded four times — scans in
|
||||||
|
Settings → Libraries, index and enrichment in Settings → Search
|
||||||
|
Index, downloads under the download clients, the autotag apply in
|
||||||
|
`autotag-view`. One "Background jobs" section in Settings was the
|
||||||
|
obvious reading of the report and is the tab again under another
|
||||||
|
name.
|
||||||
|
|
||||||
|
Four things about it are load-bearing. **Four of the five kinds
|
||||||
|
already had a home** that showed their work — the tier list, the
|
||||||
|
download list, the apply ring — and what none of them had is the
|
||||||
|
*generic* affordances, so the panel carries pause, cancel, Details
|
||||||
|
and the log to each rather than replacing what is there. **The
|
||||||
|
controls are `applyJobControl`**, not a reimplementation, which is
|
||||||
|
what keeps the "you will discard hours of downloading" confirmation
|
||||||
|
alive: it is keyed on `KindIndexBuild` inside the shared handler, and
|
||||||
|
a host drawing its own buttons would drop it silently. **A panel with
|
||||||
|
nothing to say is `hidden`**, host margin included, because an idle
|
||||||
|
panel in four places is four pieces of furniture describing an
|
||||||
|
absence. And **there is no "Clear finished"** in it, because
|
||||||
|
`ClearFinishedJobs` is global — a Clear under Libraries would discard
|
||||||
|
the index build's history too; a finished row dismisses itself.
|
||||||
|
|
||||||
|
The header `job-indicator` is untouched and is still the one view of
|
||||||
|
everything at once, from every page. One consequence worth knowing
|
||||||
|
before writing a spec: a section holding a `job-panel` also holds a
|
||||||
|
`job-details-drawer`, whose own header carries `.header` — so
|
||||||
|
`config-section .header` is ambiguous the moment a job exists.
|
||||||
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
||||||
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
||||||
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
|
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
|
||||||
@@ -1086,6 +1116,15 @@ tap away. Which four tabs is still plan 016's committed subset; this
|
|||||||
only removes from it, and "More" is never filtered because it is how
|
only removes from it, and "More" is never filtered because it is how
|
||||||
everything else stays reachable.
|
everything else stays reachable.
|
||||||
|
|
||||||
|
**A retired destination is the one shape this does not make free.** An
|
||||||
|
absent visibility key takes its default and an unknown one is dropped,
|
||||||
|
but `DefaultPage` is a *value*: a launch page naming a view that no
|
||||||
|
longer exists fails validation, and on the load path that means the app
|
||||||
|
refuses to start for whoever had it selected. `RetiredViews` is that
|
||||||
|
list, and `ApplyDefaults` treats a retired name as a zero value while
|
||||||
|
an unknown-but-not-retired one still errors — a typo is worth being
|
||||||
|
told about. #27 retiring `jobs` is its first entry.
|
||||||
|
|
||||||
**The list of destinations is `services/view-meta.ts`**, on
|
**The list of destinations is `services/view-meta.ts`**, on
|
||||||
`shortcut-meta.ts`'s pattern, because #25 gave it a second reader:
|
`shortcut-meta.ts`'s pattern, because #25 gave it a second reader:
|
||||||
Settings renders a toggle per view and needs the same labels in the
|
Settings renders a toggle per view and needs the same labels in the
|
||||||
|
|||||||
@@ -57,7 +57,17 @@ type GeneralConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
// 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() {
|
func (c *GeneralConfig) ApplyDefaults() {
|
||||||
|
if _, retired := RetiredViews[c.DefaultPage]; retired {
|
||||||
|
c.DefaultPage = ""
|
||||||
|
}
|
||||||
|
|
||||||
if c.DefaultPage == "" {
|
if c.DefaultPage == "" {
|
||||||
c.DefaultPage = DefaultDefaultPage
|
c.DefaultPage = DefaultDefaultPage
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -24,10 +24,25 @@ const (
|
|||||||
ViewExplore View = "explore"
|
ViewExplore View = "explore"
|
||||||
ViewDownloads View = "downloads"
|
ViewDownloads View = "downloads"
|
||||||
ViewAutotag View = "autotag"
|
ViewAutotag View = "autotag"
|
||||||
ViewJobs View = "jobs"
|
|
||||||
ViewSettings View = "settings"
|
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
|
// ViewSpec is what the backend knows about a destination. The label and
|
||||||
// the icon are deliberately absent: those are presentation, they live
|
// the icon are deliberately absent: those are presentation, they live
|
||||||
// beside the rest of the app's icon vocabulary in
|
// 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: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||||
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||||
{ID: ViewAutotag, VisibleByDefault: false, 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},
|
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestViewVisibilityStoredWins(t *testing.T) {
|
|||||||
general := &GeneralConfig{
|
general := &GeneralConfig{
|
||||||
ViewVisibility: map[string]bool{
|
ViewVisibility: map[string]bool{
|
||||||
string(ViewAutotag): true,
|
string(ViewAutotag): true,
|
||||||
string(ViewJobs): false,
|
string(ViewExplore): false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
general.ApplyDefaults()
|
general.ApplyDefaults()
|
||||||
@@ -77,8 +77,8 @@ func TestViewVisibilityStoredWins(t *testing.T) {
|
|||||||
t.Error("autotag was switched on and should be visible")
|
t.Error("autotag was switched on and should be visible")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolved[string(ViewJobs)] {
|
if resolved[string(ViewExplore)] {
|
||||||
t.Error("jobs was switched off and should be hidden")
|
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
|
// Settings may not be the launch page, which is the shape the old
|
||||||
// DefaultPage enum had and is now read off the same table.
|
// DefaultPage enum had and is now read off the same table.
|
||||||
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
|
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
|
||||||
@@ -213,7 +265,7 @@ func TestViewVisibilityRoundTrips(t *testing.T) {
|
|||||||
t.Fatalf("SetViewVisible() error: %v", err)
|
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)
|
t.Fatalf("SetViewVisible() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +280,8 @@ func TestViewVisibilityRoundTrips(t *testing.T) {
|
|||||||
t.Error("autotag should have loaded as visible")
|
t.Error("autotag should have loaded as visible")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resolved[string(ViewJobs)] {
|
if resolved[string(ViewExplore)] {
|
||||||
t.Error("jobs should have loaded as hidden")
|
t.Error("explore should have loaded as hidden")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,16 @@ test.describe('a failed binding says so', () => {
|
|||||||
// Libraries is the one section that starts expanded (H-22), so ask
|
// Libraries is the one section that starts expanded (H-22), so ask
|
||||||
// the disclosure what state it is in rather than assuming one — a
|
// the disclosure what state it is in rather than assuming one — a
|
||||||
// blind click used to expand it and now collapses it.
|
// blind click used to expand it and now collapses it.
|
||||||
|
//
|
||||||
|
// By role and name, not by `.header`: since #27 the section also
|
||||||
|
// contains a `job-panel`, and an open `job-details-drawer` inside
|
||||||
|
// it carries the same class. That only bites once a job exists,
|
||||||
|
// which is why it showed up on the *second* engine of a CI run and
|
||||||
|
// not the first.
|
||||||
const disclosure = page
|
const disclosure = page
|
||||||
.locator('config-section[heading="Libraries"]')
|
.locator('config-section[heading="Libraries"]')
|
||||||
.locator('.header');
|
.getByRole('button', { name: 'Libraries' })
|
||||||
|
.first();
|
||||||
|
|
||||||
if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
|
if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
|
||||||
await disclosure.click();
|
await disclosure.click();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { test, expect, waitForEvent } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Jobs tab folded into the places the work is started (#27).
|
||||||
|
*
|
||||||
|
* The assertion worth making is not that the tab is gone — that is one
|
||||||
|
* line of a table — but that **nothing became unreachable when it
|
||||||
|
* went**. Scanning is the case that mattered: the per-library controls
|
||||||
|
* lived only on that page, and the tab's own comment says they had been
|
||||||
|
* moved there out of Settings in the first place.
|
||||||
|
*
|
||||||
|
* `#24` wrote down one sentence covering all three size bands: *no
|
||||||
|
* action is ever unreachable at any supported size*. Deleting a
|
||||||
|
* destination is exactly the change that can quietly break it.
|
||||||
|
*/
|
||||||
|
type Page = import('@playwright/test').Page;
|
||||||
|
|
||||||
|
const section = (page: Page, heading: string) =>
|
||||||
|
page.locator(`config-page config-section[heading="${heading}"]`);
|
||||||
|
|
||||||
|
async function openSettings(page: Page, heading: string): Promise<void> {
|
||||||
|
await page.getByTestId('nav-settings').click();
|
||||||
|
|
||||||
|
// The section's own disclosure, by role rather than by `.header`:
|
||||||
|
// an open Libraries section also contains `job-details-drawer`,
|
||||||
|
// whose own header matches that class and makes it ambiguous.
|
||||||
|
const header = section(page, heading)
|
||||||
|
.getByRole('button', { name: heading })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
|
||||||
|
if ((await header.getAttribute('aria-expanded')) === 'false') {
|
||||||
|
await header.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('background jobs live where the work is started', () => {
|
||||||
|
test('the Jobs destination is gone', async ({ app }) => {
|
||||||
|
await expect(app.getByTestId('nav-jobs')).toHaveCount(0);
|
||||||
|
|
||||||
|
// And it is not offered as a launch page either, which is the copy
|
||||||
|
// of the destination list that is easiest to forget.
|
||||||
|
await openSettings(app, 'General');
|
||||||
|
|
||||||
|
const options = await section(app, 'General')
|
||||||
|
.locator('select')
|
||||||
|
.first()
|
||||||
|
.locator('option')
|
||||||
|
.allTextContents();
|
||||||
|
|
||||||
|
expect(options).not.toContain('Jobs');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanning is startable from Settings → Libraries, and the job that
|
||||||
|
* results is visible there with its controls. One assertion covers
|
||||||
|
* both halves, because a Scan All that started nothing would leave
|
||||||
|
* the panel empty and read exactly like a panel that does not work.
|
||||||
|
*/
|
||||||
|
test('a scan is started and watched in Settings', async ({ app }) => {
|
||||||
|
await openSettings(app, 'Libraries');
|
||||||
|
|
||||||
|
const libraries = section(app, 'Libraries');
|
||||||
|
|
||||||
|
await libraries.getByRole('button', { name: 'Scan All' }).click();
|
||||||
|
|
||||||
|
await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 });
|
||||||
|
|
||||||
|
const panel = libraries.locator('job-panel');
|
||||||
|
|
||||||
|
await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
|
||||||
|
// The generic affordances are the point of the panel: the tier
|
||||||
|
// list and the progress rings the other surfaces already had
|
||||||
|
// cannot open a log.
|
||||||
|
await expect(
|
||||||
|
panel.getByRole('button', { name: /^Details/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A finished job dismisses from where it is shown. */
|
||||||
|
test('a finished scan can be dismissed in place', async ({ app }) => {
|
||||||
|
await openSettings(app, 'Libraries');
|
||||||
|
|
||||||
|
const panel = section(app, 'Libraries').locator('job-panel');
|
||||||
|
const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first();
|
||||||
|
|
||||||
|
await expect(dismiss).toBeVisible({ timeout: 10_000 });
|
||||||
|
await dismiss.click();
|
||||||
|
|
||||||
|
await expect(panel.locator('job-row')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full rescan is destructive and asks first. It is asserted at the
|
||||||
|
* dialog rather than through it — running one against the seeded app
|
||||||
|
* would delete the library the rest of the suite reads.
|
||||||
|
*/
|
||||||
|
test('Full Rescan asks before it does anything', async ({ app }) => {
|
||||||
|
await openSettings(app, 'Libraries');
|
||||||
|
|
||||||
|
await section(app, 'Libraries')
|
||||||
|
.getByRole('button', { name: 'Full Rescan' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dialog = app.getByRole('dialog', { name: 'Full rescan' });
|
||||||
|
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
|
||||||
|
// The message is read off the *host*, not the dialog: a wa-dialog
|
||||||
|
// keeps its slotted content in the host's shadow root, so
|
||||||
|
// `toContainText` on the dialog itself sees only Web Awesome's
|
||||||
|
// chrome.
|
||||||
|
await expect(app.locator('confirm-dialog')).toContainText(
|
||||||
|
'deletes all library data',
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,9 +93,10 @@ test.describe('the app fits in its own window', () => {
|
|||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
// Settings and Jobs are the two that were unreachable: they are
|
// Settings is the one that was unreachable: it is last in the nav,
|
||||||
// last in the nav, and the pane used to clip rather than scroll.
|
// and the pane used to clip rather than scroll. (Jobs was the other
|
||||||
for (const view of ['jobs', 'settings'] as const) {
|
// half of this until #27 folded it into Settings.)
|
||||||
|
for (const view of ['explore', 'settings'] as const) {
|
||||||
const item = app.getByTestId(`nav-${view}`);
|
const item = app.getByTestId(`nav-${view}`);
|
||||||
|
|
||||||
await item.scrollIntoViewIfNeeded();
|
await item.scrollIntoViewIfNeeded();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
|
|||||||
|
|
||||||
const VIEWS = [
|
const VIEWS = [
|
||||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||||
'explore', 'downloads', 'jobs', 'settings',
|
'explore', 'downloads', 'autotag', 'settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
type IconState = { name: string; hasSvg: boolean };
|
type IconState = { name: string; hasSvg: boolean };
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const VIEWS: [string, string, boolean][] = [
|
|||||||
['tracks', 'Tracks', true],
|
['tracks', 'Tracks', true],
|
||||||
['explore', 'Explore', false],
|
['explore', 'Explore', false],
|
||||||
['downloads', 'Downloads', false],
|
['downloads', 'Downloads', false],
|
||||||
['jobs', 'Background jobs', false],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The header lives in the view's shadow root, inside its own. */
|
/** The header lives in the view's shadow root, inside its own. */
|
||||||
@@ -53,7 +52,6 @@ const TAGS: Record<string, string> = {
|
|||||||
tracks: 'track-list',
|
tracks: 'track-list',
|
||||||
explore: 'explore-view',
|
explore: 'explore-view',
|
||||||
downloads: 'downloads-view',
|
downloads: 'downloads-view',
|
||||||
jobs: 'jobs-view',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test.describe('every primary view says what it is', () => {
|
test.describe('every primary view says what it is', () => {
|
||||||
|
|||||||
@@ -18,16 +18,19 @@ test.describe('Settings is reachable without a mouse', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await app.getByTestId('nav-settings').click();
|
await app.getByTestId('nav-settings').click();
|
||||||
|
|
||||||
const headers = app.locator('config-page config-section .header');
|
// Per *section*, not per `.header`: a section holding a
|
||||||
|
// `job-panel` (#27) also contains `job-details-drawer`, whose own
|
||||||
|
// header carries that class and is not a disclosure.
|
||||||
|
const sections = app.locator('config-page config-section');
|
||||||
|
|
||||||
await expect(headers.first()).toBeVisible();
|
await expect(sections.first()).toBeVisible();
|
||||||
|
|
||||||
const count = await headers.count();
|
const count = await sections.count();
|
||||||
|
|
||||||
expect(count).toBeGreaterThan(4);
|
expect(count).toBeGreaterThan(4);
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const header = headers.nth(i);
|
const header = sections.nth(i).locator('.header').first();
|
||||||
|
|
||||||
expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON');
|
expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON');
|
||||||
expect(['true', 'false']).toContain(
|
expect(['true', 'false']).toContain(
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ const VIEW_TAGS: Record<string, string> = {
|
|||||||
explore: 'explore-view',
|
explore: 'explore-view',
|
||||||
autotag: 'autotag-view',
|
autotag: 'autotag-view',
|
||||||
downloads: 'downloads-view',
|
downloads: 'downloads-view',
|
||||||
jobs: 'jobs-view',
|
|
||||||
settings: 'config-page',
|
settings: 'config-page',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
|
|||||||
explore: () => import('@components/explore-view/explore-view.ts'),
|
explore: () => import('@components/explore-view/explore-view.ts'),
|
||||||
autotag: () => import('@components/autotag-view/autotag-view.ts'),
|
autotag: () => import('@components/autotag-view/autotag-view.ts'),
|
||||||
downloads: () => import('@components/downloads-view/downloads-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'),
|
settings: () => import('@components/config-page/config-page.ts'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
|
|||||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||||
|
import '@components/jobs/job-panel';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
|
||||||
type PendingItem = autotagservice.PendingItem;
|
type PendingItem = autotagservice.PendingItem;
|
||||||
@@ -135,8 +136,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
/* The header and the apply-job panel share the header row.
|
||||||
|
A wrapper rather than a third grid row, because the panel
|
||||||
|
is display:none while nothing is applying and a grid
|
||||||
|
row would still spend the container's gap on it -- the
|
||||||
|
idle case, which is nearly always. */
|
||||||
|
.header-area {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -3207,7 +3220,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
|||||||
// sees a blank full-screen "Loading\u2026".
|
// sees a blank full-screen "Loading\u2026".
|
||||||
return html`
|
return html`
|
||||||
<div class="root">
|
<div class="root">
|
||||||
${this.renderHeader()}
|
<div class="header-area">
|
||||||
|
${this.renderHeader()}
|
||||||
|
<!--
|
||||||
|
Applying rewrites tags on disk, and until #27 the
|
||||||
|
only way to stop a run was the Jobs tab or the
|
||||||
|
header popover. The per-album ring says work is
|
||||||
|
happening; this is what can stop it, and what has
|
||||||
|
the log when it goes wrong.
|
||||||
|
-->
|
||||||
|
<job-panel
|
||||||
|
kinds="autotag-apply"
|
||||||
|
heading="Applying tags"
|
||||||
|
></job-panel>
|
||||||
|
</div>
|
||||||
${this.renderFolderSidebar()}
|
${this.renderFolderSidebar()}
|
||||||
${this.renderMain()}
|
${this.renderMain()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
RemoveLibrary,
|
RemoveLibrary,
|
||||||
GetRemovalImpact,
|
GetRemovalImpact,
|
||||||
GetAllLibrariesWithTrackCounts,
|
GetAllLibrariesWithTrackCounts,
|
||||||
|
ScanLibrary,
|
||||||
|
ScanAllLibraries,
|
||||||
|
FullRescan,
|
||||||
} from '@go/library/library.js';
|
} from '@go/library/library.js';
|
||||||
|
import { jobStore } from '@store/job-store';
|
||||||
|
import type { Job } from '@store/job-store';
|
||||||
|
import '@components/jobs/job-panel';
|
||||||
import {
|
import {
|
||||||
GetScanConcurrency,
|
GetScanConcurrency,
|
||||||
SetScanConcurrency,
|
SetScanConcurrency,
|
||||||
@@ -77,6 +83,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
/** Which destinations the navigation offers (#25). */
|
/** Which destinations the navigation offers (#25). */
|
||||||
private viewsCtrl = new ViewVisibilityController(this);
|
private viewsCtrl = new ViewVisibilityController(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job snapshot, for the per-library scan status (#27).
|
||||||
|
*
|
||||||
|
* Held as state rather than read from the store in `render()` so
|
||||||
|
* Lit sees the dependency: the store notifies, and a getter read
|
||||||
|
* inside a template is not a reactive input.
|
||||||
|
*/
|
||||||
|
@state() private jobs: Job[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set between pressing a scan button and the job snapshot that
|
||||||
|
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
|
||||||
|
* is long enough for a second click to start a second scan.
|
||||||
|
*/
|
||||||
|
@state() private startingScan = false;
|
||||||
|
|
||||||
|
private unsubscribeJobs: (() => void) | null = null;
|
||||||
|
|
||||||
// --- Shortcuts controller ---
|
// --- Shortcuts controller ---
|
||||||
private shortcutsCtrl = new ShortcutsController(this);
|
private shortcutsCtrl = new ShortcutsController(this);
|
||||||
|
|
||||||
@@ -864,8 +888,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
this.scrollMode =
|
this.scrollMode =
|
||||||
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
|
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
|
||||||
|
|
||||||
// Scan progress lives in the jobs panel now — this page only
|
// Scanning is started and watched here (#27), so the job
|
||||||
// needs to know when the library list itself changes.
|
// snapshot is a live input to this page.
|
||||||
|
this.unsubscribeJobs = jobStore.subscribe(() => {
|
||||||
|
this.jobs = jobStore.jobs;
|
||||||
|
});
|
||||||
|
this.jobs = jobStore.jobs;
|
||||||
|
void jobStore.init();
|
||||||
|
|
||||||
this.cancelLibraryAdded = EventsOn(
|
this.cancelLibraryAdded = EventsOn(
|
||||||
Events.LibraryAdded,
|
Events.LibraryAdded,
|
||||||
() => void this.loadLibraries(),
|
() => void this.loadLibraries(),
|
||||||
@@ -896,6 +926,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override onViewDeactivate(): void {
|
protected override onViewDeactivate(): void {
|
||||||
|
this.unsubscribeJobs?.();
|
||||||
|
this.unsubscribeJobs = null;
|
||||||
|
|
||||||
this.cancelLibraryAdded?.();
|
this.cancelLibraryAdded?.();
|
||||||
this.cancelLibraryRenamed?.();
|
this.cancelLibraryRenamed?.();
|
||||||
this.cancelLibraryRemoved?.();
|
this.cancelLibraryRemoved?.();
|
||||||
@@ -1096,6 +1129,114 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SCANNING (#27 — back from the Jobs tab)
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/** The scan job for a library, if one is registered. */
|
||||||
|
private jobForLibrary(id: number): Job | undefined {
|
||||||
|
return this.jobs.find((job) => job.id === `scan:${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status line under a library name while it is being scanned. */
|
||||||
|
private libraryScanStatus(id: number): string | null {
|
||||||
|
const job = this.jobForLibrary(id);
|
||||||
|
|
||||||
|
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 get anyScanning(): boolean {
|
||||||
|
return this.libraries.some(
|
||||||
|
(lib) => this.libraryScanStatus(lib.id) !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<unknown>,
|
||||||
|
retry: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.startingScan) return;
|
||||||
|
|
||||||
|
this.startingScan = 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.startingScan = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleScanLibrary = (id: number): void => {
|
||||||
|
this.activeMenuId = null;
|
||||||
|
void this.startJob(
|
||||||
|
'Scanning that library',
|
||||||
|
() => ScanLibrary(id),
|
||||||
|
() => this.handleScanLibrary(id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleScanAll = (): void => {
|
||||||
|
void this.startJob(
|
||||||
|
'Scanning your libraries',
|
||||||
|
() => ScanAllLibraries(),
|
||||||
|
() => this.handleScanAll(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleFullRescan = async (): Promise<void> => {
|
||||||
|
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.handleFullRescan(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
private handleViewToggle = (
|
private handleViewToggle = (
|
||||||
view: string,
|
view: string,
|
||||||
visible: boolean,
|
visible: boolean,
|
||||||
@@ -1554,6 +1695,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
.value=${this.allowMeteredCatalogDownload}
|
.value=${this.allowMeteredCatalogDownload}
|
||||||
@config-change=${this.handleAllowMeteredChange}
|
@config-change=${this.handleAllowMeteredChange}
|
||||||
></config-field>
|
></config-field>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The tier list above says what the build is *doing*;
|
||||||
|
this says it is a job, and gives it the pause, cancel
|
||||||
|
and log the tier list never had (#27). Cancelling one
|
||||||
|
still asks first — that confirmation is inside
|
||||||
|
applyJobControl, keyed on the kind, which is why
|
||||||
|
this embeds the shared rows rather than drawing its
|
||||||
|
own.
|
||||||
|
-->
|
||||||
|
<job-panel
|
||||||
|
kinds="index-build,catalog-enrich"
|
||||||
|
heading="Index jobs"
|
||||||
|
></job-panel>
|
||||||
</config-section>
|
</config-section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1674,18 +1829,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
description:
|
description:
|
||||||
'The page the app opens to on launch.',
|
'The page the app opens to on launch.',
|
||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
options: [
|
// Derived, not written out: this list was a
|
||||||
{ value: 'home', label: 'Home' },
|
// second copy of the launchable set, and #27
|
||||||
{ value: 'tracks', label: 'Tracks' },
|
// removing a destination is exactly the change
|
||||||
{ value: 'albums', label: 'Albums' },
|
// that would have left the two disagreeing.
|
||||||
{ value: 'artists', label: 'Artists' },
|
// Settings is excluded because it is the one
|
||||||
{ value: 'genres', label: 'Genres' },
|
// view the backend refuses to launch into.
|
||||||
{ value: 'playlists', label: 'Playlists' },
|
options: VIEW_META
|
||||||
{ value: 'explore', label: 'Explore' },
|
.filter((v) => v.alwaysShown !== true)
|
||||||
{ value: 'downloads', label: 'Downloads' },
|
.map((v) => ({ value: v.id, label: v.label })),
|
||||||
{ value: 'autotag', label: 'Autotag' },
|
|
||||||
{ value: 'jobs', label: 'Jobs' },
|
|
||||||
],
|
|
||||||
}}
|
}}
|
||||||
.value=${this.defaultPage}
|
.value=${this.defaultPage}
|
||||||
@config-change=${this.handleDefaultPageChange}
|
@config-change=${this.handleDefaultPageChange}
|
||||||
@@ -2161,8 +2313,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
return html`
|
return html`
|
||||||
<config-section
|
<config-section
|
||||||
heading="Libraries"
|
heading="Libraries"
|
||||||
description="Manage your music library folders. Scanning and its
|
description="Manage your music library folders, and scan them
|
||||||
progress live in the Jobs panel."
|
for new and changed files."
|
||||||
.open=${true}
|
.open=${true}
|
||||||
>
|
>
|
||||||
<div class="scan-actions">
|
<div class="scan-actions">
|
||||||
@@ -2172,6 +2324,23 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
Add Library
|
Add Library
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
?disabled=${this.anyScanning
|
||||||
|
|| this.startingScan
|
||||||
|
|| this.libraries.length === 0}
|
||||||
|
@click=${this.handleScanAll}
|
||||||
|
>
|
||||||
|
Scan All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
?disabled=${this.anyScanning
|
||||||
|
|| this.startingScan
|
||||||
|
|| this.libraries.length === 0}
|
||||||
|
@click=${this.handleFullRescan}
|
||||||
|
>
|
||||||
|
Full Rescan
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.libraries.length > 0
|
${this.libraries.length > 0
|
||||||
@@ -2209,7 +2378,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
<span class="library-count">
|
<span class="library-count">
|
||||||
${this.removingLibraryId === lib.id
|
${this.removingLibraryId === lib.id
|
||||||
? 'Removing…'
|
? 'Removing…'
|
||||||
: html`${lib.trackCount} tracks`}
|
: this.libraryScanStatus(lib.id)
|
||||||
|
?? html`${lib.trackCount} tracks`}
|
||||||
</span>
|
</span>
|
||||||
<div class="overflow-wrapper">
|
<div class="overflow-wrapper">
|
||||||
<button
|
<button
|
||||||
@@ -2232,6 +2402,16 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</div>
|
</div>
|
||||||
|
${this.libraryScanStatus(lib.id) === null
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="overflow-item"
|
||||||
|
@click=${() => this.handleScanLibrary(lib.id)}
|
||||||
|
>
|
||||||
|
Scan now
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
<div
|
<div
|
||||||
class="overflow-item overflow-item--danger"
|
class="overflow-item overflow-item--danger"
|
||||||
@click=${() => void this.handleRemoveClick(lib.id)}
|
@click=${() => void this.handleRemoveClick(lib.id)}
|
||||||
@@ -2275,6 +2455,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
@config-change=${this.handleConcurrencyChange}
|
@config-change=${this.handleConcurrencyChange}
|
||||||
></config-field>
|
></config-field>
|
||||||
|
|
||||||
|
<job-panel
|
||||||
|
kinds="library-scan"
|
||||||
|
heading="Scans"
|
||||||
|
></job-panel>
|
||||||
|
|
||||||
</config-section>
|
</config-section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { compact } from '@utils/binding';
|
|||||||
import { describeError, explainError } from '@utils/describe-error';
|
import { describeError, explainError } from '@utils/describe-error';
|
||||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
|
import '@components/jobs/job-panel';
|
||||||
import { pickDirectory } from '../../utils/pick-directory';
|
import { pickDirectory } from '../../utils/pick-directory';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,6 +275,17 @@ export class DownloadClients extends LitElement {
|
|||||||
</wa-button>
|
</wa-button>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The Downloads view already shows every download's
|
||||||
|
lifecycle state; what it has never had is pause,
|
||||||
|
cancel and the log, which the Jobs tab carried (#27).
|
||||||
|
Renders nothing while nothing is downloading.
|
||||||
|
-->
|
||||||
|
<job-panel
|
||||||
|
kinds="download"
|
||||||
|
heading="Downloads in progress"
|
||||||
|
></job-panel>
|
||||||
</config-section>
|
</config-section>
|
||||||
|
|
||||||
<config-section
|
<config-section
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export class DownloadPicker extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<wa-callout variant="success">
|
<wa-callout variant="success">
|
||||||
Found a clear match and started downloading it. Progress is
|
Found a clear match and started downloading it. Progress is
|
||||||
in the background jobs panel.
|
on the Downloads page.
|
||||||
</wa-callout>
|
</wa-callout>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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`<h3>${this.heading}</h3>` : nothing}
|
||||||
|
<div class="card">
|
||||||
|
${[...active, ...finished].map(
|
||||||
|
(job) => html`
|
||||||
|
<div class="job-entry">
|
||||||
|
<job-row
|
||||||
|
.job=${job}
|
||||||
|
variant="full"
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> = 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<void> {
|
|
||||||
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<unknown>,
|
|
||||||
retry: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
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`<div class="card">
|
|
||||||
<div class="empty">${emptyText}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="card">
|
|
||||||
${list.map(
|
|
||||||
(job) => html`
|
|
||||||
<div class="job-entry">
|
|
||||||
<job-row
|
|
||||||
.job=${job}
|
|
||||||
variant="full"
|
|
||||||
@job-control=${applyJobControl}
|
|
||||||
></job-row>
|
|
||||||
<button
|
|
||||||
class="details-btn"
|
|
||||||
@click=${() => this.openDetails(job.id)}
|
|
||||||
>
|
|
||||||
Details${job.warnCount
|
|
||||||
? ` · ${job.warnCount}⚠`
|
|
||||||
: ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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`
|
|
||||||
<div class="library-row">
|
|
||||||
<div>
|
|
||||||
<div class="library-name">${lib.name}</div>
|
|
||||||
<div class="library-meta">
|
|
||||||
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
|
|
||||||
</div>
|
|
||||||
${status
|
|
||||||
? html`<div class="library-state">${status}</div>`
|
|
||||||
: nothing}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${busy
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
class="link"
|
|
||||||
@click=${() => this.openDetails(`scan:${lib.id}`)}
|
|
||||||
>
|
|
||||||
View progress
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
<button
|
|
||||||
class="action"
|
|
||||||
?disabled=${this.starting}
|
|
||||||
@click=${() => this.startScan(lib.id)}
|
|
||||||
>
|
|
||||||
<wa-icon name="arrows-rotate"></wa-icon>
|
|
||||||
Scan now
|
|
||||||
</button>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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`
|
|
||||||
<page-header heading="Background jobs"></page-header>
|
|
||||||
<p class="page-sub">
|
|
||||||
Library scans and search index builds, with their progress and
|
|
||||||
output.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Running now</h2>
|
|
||||||
</div>
|
|
||||||
${this.renderJobList(active, 'Nothing is running.')}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Libraries</h2>
|
|
||||||
<button
|
|
||||||
class="action"
|
|
||||||
?disabled=${anyScanning ||
|
|
||||||
this.starting ||
|
|
||||||
this.libraries.length === 0}
|
|
||||||
@click=${this.startAllScans}
|
|
||||||
>
|
|
||||||
<wa-icon name="arrows-rotate"></wa-icon>
|
|
||||||
Scan all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
${this.libraries.length === 0
|
|
||||||
? html`<div class="empty">
|
|
||||||
No libraries yet — add one in Settings.
|
|
||||||
</div>`
|
|
||||||
: this.libraries.map((lib) =>
|
|
||||||
this.renderLibraryRow(lib),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Maintenance</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="library-row">
|
|
||||||
<div>
|
|
||||||
<div class="library-name">Full rescan</div>
|
|
||||||
<div class="library-meta">
|
|
||||||
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.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="action danger"
|
|
||||||
?disabled=${anyScanning ||
|
|
||||||
this.starting ||
|
|
||||||
this.libraries.length === 0}
|
|
||||||
@click=${this.fullRescan}
|
|
||||||
>
|
|
||||||
<wa-icon name="triangle-exclamation"></wa-icon>
|
|
||||||
Full rescan
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
${finished.length > 0
|
|
||||||
? html`
|
|
||||||
<section>
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Recently finished</h2>
|
|
||||||
<button
|
|
||||||
class="link"
|
|
||||||
@click=${this.clearFinished}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
${this.renderJobList(finished, '')}
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
<job-details-drawer
|
|
||||||
job-id=${this.drawerJobId}
|
|
||||||
?open=${this.drawerOpen}
|
|
||||||
@drawer-closed=${this.onDrawerClosed}
|
|
||||||
></job-details-drawer>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
'jobs-view': JobsView;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ export type View =
|
|||||||
| 'explore'
|
| 'explore'
|
||||||
| 'downloads'
|
| 'downloads'
|
||||||
| 'autotag'
|
| 'autotag'
|
||||||
| 'jobs'
|
|
||||||
| 'settings';
|
| 'settings';
|
||||||
|
|
||||||
export interface ViewMeta {
|
export interface ViewMeta {
|
||||||
@@ -61,6 +60,5 @@ export const VIEW_META: ViewMeta[] = [
|
|||||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||||
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||||
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
|
||||||
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
|
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type JobState =
|
|||||||
export type JobKind =
|
export type JobKind =
|
||||||
| 'library-scan'
|
| 'library-scan'
|
||||||
| 'index-build'
|
| 'index-build'
|
||||||
|
| 'download'
|
||||||
| 'autotag-apply'
|
| 'autotag-apply'
|
||||||
| 'catalog-enrich';
|
| 'catalog-enrich';
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ describe('<app-sidebar>', () => {
|
|||||||
'nav-explore',
|
'nav-explore',
|
||||||
'nav-downloads',
|
'nav-downloads',
|
||||||
'nav-autotag',
|
'nav-autotag',
|
||||||
'nav-jobs',
|
|
||||||
'nav-settings',
|
'nav-settings',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) => ({
|
||||||
|
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<void> {
|
||||||
|
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('<job-panel>', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
stub('jobs.Service.GetJobs', []);
|
||||||
|
await snapshot([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the kinds it was asked for', async () => {
|
||||||
|
const el = await fixture<LitElement>('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<LitElement>('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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job()]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job()]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(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<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job({ state: 'complete' })]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Dismiss"]')?.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']);
|
||||||
|
expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ describe('<app-sidebar> is reachable', () => {
|
|||||||
|
|
||||||
const items = shadowAll(el, 'li button');
|
const items = shadowAll(el, 'li button');
|
||||||
|
|
||||||
expect(items).toHaveLength(11);
|
expect(items).toHaveLength(10);
|
||||||
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
|
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import '@components/jobs/job-details-drawer';
|
|||||||
import '@components/jobs/job-indicator';
|
import '@components/jobs/job-indicator';
|
||||||
import '@components/jobs/job-log-view';
|
import '@components/jobs/job-log-view';
|
||||||
import '@components/jobs/job-row';
|
import '@components/jobs/job-row';
|
||||||
import '@components/jobs/jobs-view';
|
import '@components/jobs/job-panel';
|
||||||
import '@components/library-filter/library-filter';
|
import '@components/library-filter/library-filter';
|
||||||
import '@components/library-status-indicator/library-status-indicator';
|
import '@components/library-status-indicator/library-status-indicator';
|
||||||
import '@components/now-playing/now-playing';
|
import '@components/now-playing/now-playing';
|
||||||
@@ -87,8 +87,8 @@ const TAGS = [
|
|||||||
'job-details-drawer',
|
'job-details-drawer',
|
||||||
'job-indicator',
|
'job-indicator',
|
||||||
'job-log-view',
|
'job-log-view',
|
||||||
|
'job-panel',
|
||||||
'job-row',
|
'job-row',
|
||||||
'jobs-view',
|
|
||||||
'library-filter',
|
'library-filter',
|
||||||
'library-status-indicator',
|
'library-status-indicator',
|
||||||
'now-playing',
|
'now-playing',
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import '@components/explore-view/explore-view';
|
|||||||
import '@components/home-view/home-view';
|
import '@components/home-view/home-view';
|
||||||
import '@components/downloads-view/downloads-view';
|
import '@components/downloads-view/downloads-view';
|
||||||
|
|
||||||
import '@components/jobs/jobs-view';
|
|
||||||
import '@components/playlist-view/playlist-view';
|
import '@components/playlist-view/playlist-view';
|
||||||
import { fixture } from '@test/support/render';
|
import { fixture } from '@test/support/render';
|
||||||
import { stub, flush } from '@test/support/harness';
|
import { stub, flush } from '@test/support/harness';
|
||||||
@@ -223,7 +222,6 @@ const CACHED_VIEWS = [
|
|||||||
'artists-view',
|
'artists-view',
|
||||||
'genres-view',
|
'genres-view',
|
||||||
'downloads-view',
|
'downloads-view',
|
||||||
'jobs-view',
|
|
||||||
'playlist-view',
|
'playlist-view',
|
||||||
'explore-view',
|
'explore-view',
|
||||||
'home-view',
|
'home-view',
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ const ALL_VISIBLE = {
|
|||||||
explore: true,
|
explore: true,
|
||||||
downloads: true,
|
downloads: true,
|
||||||
autotag: true,
|
autotag: true,
|
||||||
jobs: true,
|
|
||||||
settings: true,
|
settings: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +95,6 @@ describe('view visibility', () => {
|
|||||||
'explore',
|
'explore',
|
||||||
'downloads',
|
'downloads',
|
||||||
'autotag',
|
'autotag',
|
||||||
'jobs',
|
|
||||||
'settings',
|
'settings',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -104,11 +102,11 @@ describe('view visibility', () => {
|
|||||||
it('drops the ones the user switched off', async () => {
|
it('drops the ones the user switched off', async () => {
|
||||||
const el = await fixture<LitElement>('app-sidebar');
|
const el = await fixture<LitElement>('app-sidebar');
|
||||||
|
|
||||||
await setViews({ ...ALL_VISIBLE, autotag: false, jobs: false });
|
await setViews({ ...ALL_VISIBLE, autotag: false, explore: false });
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
expect(navIDs(el)).not.toContain('autotag');
|
expect(navIDs(el)).not.toContain('autotag');
|
||||||
expect(navIDs(el)).not.toContain('jobs');
|
expect(navIDs(el)).not.toContain('explore');
|
||||||
expect(navIDs(el)).toContain('settings');
|
expect(navIDs(el)).toContain('settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user