Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2365806d18 | ||
|
|
7d348f243a | ||
|
|
ddd04623f7 | ||
|
|
9ad1477b1e | ||
|
|
70ab3ddf94 | ||
|
|
4f7529c315 | ||
|
|
bb21072386 | ||
|
|
ead1354e4d | ||
|
|
ae85df0dad | ||
|
|
6e7e349e63 | ||
|
|
f9ba9a87d7 | ||
|
|
e4efec6f0c | ||
|
|
99f355b2fc | ||
|
|
c79d4d47a3 | ||
|
|
8efed2dd2b | ||
|
|
12af6ec1f7 | ||
|
|
f3d1ae1c8c | ||
|
|
5af545e38d |
@@ -3732,3 +3732,232 @@ so it is the mechanism and not a test-only door.
|
||||
|
||||
Use the nav item when the *nav* is the subject, and `navigateTo` when
|
||||
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.
|
||||
|
||||
## `scrollWidth` counts the left padding and not the right (measured 2026-08-20)
|
||||
|
||||
The obvious predicate for "does this flex row fit" is
|
||||
`el.scrollWidth <= el.clientWidth`, and on a box with symmetric gutters
|
||||
it **under-reports by one gutter**. `scrollWidth` is the extent of the
|
||||
scrollable content area, which includes `padding-left` and excludes
|
||||
`padding-right`; `clientWidth` includes both. So a child may end up to
|
||||
`padding-right` past where content is allowed to go while the box
|
||||
reports a perfect fit.
|
||||
|
||||
Measured on the top bar (`padding: 0 2em`) at 700x600 with a long-titled
|
||||
scan staged: `clientWidth 700`, `scrollWidth 700` — and
|
||||
`job-indicator`'s right edge at 700 against a content edge of 668, i.e.
|
||||
sitting in the whole right gutter. `#143`'s first fix passed its own
|
||||
measurement and left the indicator visibly jammed against the window
|
||||
edge.
|
||||
|
||||
The predicate `services/top-bar-fit.ts` uses instead is the one its
|
||||
spec asserts: no in-flow child's rect outside the parent's *content*
|
||||
box, both edges, with half a pixel of slack for fractional flex widths.
|
||||
|
||||
This is the same family as #69's title trap — the measurement easiest to
|
||||
reach for is the one that cannot see the failure — and it is worth
|
||||
knowing before writing the next one of these: **the fit test and the
|
||||
assertion that proves it should be the same test.** It was found only
|
||||
because `top-bar-fit.spec.ts` measures per child rather than asserting
|
||||
on the container, which is exactly why #69 needed
|
||||
`header-action-overflow.spec.ts`.
|
||||
|
||||
## The top bar's overflow is 11px idle and 262px while working (measured 2026-08-20)
|
||||
|
||||
#143 was filed as "11px at 600x600" and re-measured as 171. Both are the
|
||||
same defect seen with different jobs running: `job-indicator` is
|
||||
`hidden` when idle, ~144px wide showing "Scanning Music", and **235px**
|
||||
showing a real library's scan title ("Scanning Music from the external
|
||||
drive"), because the label is capped at 12rem and gets there.
|
||||
|
||||
Swept against the running app with that job staged, `header.top-bar`
|
||||
client vs scroll:
|
||||
|
||||
| width | idle | with the long-titled scan |
|
||||
|---|---|---|
|
||||
| 320, 390, 599 | fits | fits (the phone rules drop the filter and the label) |
|
||||
| 600 | 611 | **862** |
|
||||
| 700 | fits | 862 |
|
||||
| 800 | fits | 862 |
|
||||
| 899 | fits | 899 (fits) |
|
||||
| 900 | fits | 946 |
|
||||
| 1100, 1440 | fits | fits |
|
||||
|
||||
Two things worth keeping. The band is **600–610 idle and 600–900 while
|
||||
working**, so "a narrow corner" and "the header is crowded from 900
|
||||
down" are both true and the difference is entirely what is in flight —
|
||||
which is the case a seeded, settled app can never show you. And 899
|
||||
fits while 900 does not, because `nav-history` appears at 900: the worst
|
||||
width for the header is not the narrowest one, the same way 900 rather
|
||||
than 800 is the worst width for the content area.
|
||||
|
||||
Staging it is `/__test/emit` with a `JobsChanged` snapshot; a job with
|
||||
`state: "running"` never completes, so it stays up until an empty
|
||||
snapshot is emitted, which is what makes an idle re-measurement look
|
||||
like the fix not working.
|
||||
|
||||
## Two repaint mechanisms, and neither is pinned alone (measured 2026-08-20)
|
||||
|
||||
`CLAUDE.md` already states the rule — *a virtualized list repaints only
|
||||
when you tell it to, and the accidental way you were telling it may be
|
||||
the thing you are about to delete* — found in `artists-view` and
|
||||
`genres-view`. `queue-panel` is a second instance with numbers, and the
|
||||
numbers are the part worth keeping.
|
||||
|
||||
It repaints its rows **two** ways:
|
||||
|
||||
- `onSelectionChanged()` calls `virtualizer.requestUpdate()`, which is
|
||||
the intended one and the one `track-list` has always had;
|
||||
- `.keyFunction=${(track) => track.id}` is a **per-render arrow**, so it
|
||||
is a changed property on every host update and repaints the rows by
|
||||
itself.
|
||||
|
||||
Removing *either* alone changes nothing observable. That is why #43
|
||||
could not be settled by reading the code: the hypothesis in its Findings
|
||||
(the repaint is missing) was checkable, false, and would have looked
|
||||
identical either way.
|
||||
|
||||
Removing **both** does not break selection either — it delays it. Time
|
||||
from click to `aria-selected`, three clicks each:
|
||||
|
||||
| build | ms to highlight |
|
||||
|---|---|
|
||||
| healthy | 5, 16, 17 |
|
||||
| both mechanisms removed | 134, 3,866, 5,816 |
|
||||
|
||||
The highlight arrives on whatever unrelated render happens next (the
|
||||
player's 1 Hz position report is the usual candidate). **Four seconds is
|
||||
indistinguishable from broken to a user, and invisible to a spec** —
|
||||
`expect.poll`'s default 5 s timeout passes the degraded build on every
|
||||
assertion. `queue-selection.spec.ts` bounds its selection assertions at
|
||||
500 ms for that reason, which is ~30x the healthy case and an order of
|
||||
magnitude under the degraded one.
|
||||
|
||||
The general form, for the next spec about anything push-driven: **a poll
|
||||
generous enough to be stable is generous enough to miss a latency
|
||||
regression entirely.** If "late" is a failure mode worth having, the
|
||||
timeout has to say so.
|
||||
|
||||
## A hit-scan says how much of a row is not selectable (measured 2026-08-20)
|
||||
|
||||
`explore-link` stops the click's propagation on purpose — "the row must
|
||||
not also treat it as a selection" — so a click on a track, album or
|
||||
artist *name* navigates and selects nothing. That is app-wide and
|
||||
deliberate, and the useful question about any given list is how much of
|
||||
its row it costs.
|
||||
|
||||
Asking `elementFromPoint` what is under each x across a row, at three
|
||||
heights:
|
||||
|
||||
| list | link coverage |
|
||||
|---|---|
|
||||
| queue panel | 12% |
|
||||
| track list | 21% |
|
||||
|
||||
This killed a fix in progress. #43 reads as "selection is broken in the
|
||||
queue panel, and fine in the track list", the obvious mechanism is that
|
||||
the queue's narrow rows are mostly name, and it is **wrong**: the panel
|
||||
is *less* link-covered than the list it is being compared against. The
|
||||
scan takes a minute and is worth running before demoting anybody's links
|
||||
— `explore-album-details`'s tracklist (number / title / artist /
|
||||
duration) is the one that plausibly *is* mostly link, and is the one
|
||||
#5 is about to add selection to.
|
||||
|
||||
## A layout is still moving when a guard says it has arrived (measured 2026-08-20)
|
||||
|
||||
`album-dropdown.spec.ts` failed with `Expected 80, Received 10` twice
|
||||
over two sessions, and #133 already strengthened its guard from
|
||||
"scrollable at all" to "has at least the range the assertion needs".
|
||||
That was necessary and could not be sufficient, and the reason is
|
||||
structural rather than a matter of thresholds: **a guard and the write
|
||||
it guards are separate CDP round trips**, so the page is free to
|
||||
re-lay-out between them. Polling harder cannot close a window between
|
||||
two moments; only removing the window can.
|
||||
|
||||
Measured directly, sampling `scrollHeight - clientHeight` on
|
||||
`.grid-scroll-container` every frame across a 1440x900 → 900x600 resize,
|
||||
three runs:
|
||||
|
||||
| t (ms) | range |
|
||||
|---|---|
|
||||
| 0 | 0 |
|
||||
| 1 | **88** |
|
||||
| 8–14 | 330 (settled) |
|
||||
|
||||
88 satisfies a guard asking for 80 and is not the settled value, so the
|
||||
guard can pass while the grid is one layout pass from done. Under
|
||||
full-suite load the transient is worse — the observed failure had 10 —
|
||||
which is why it shows up on the second run of a suite and not in ten
|
||||
consecutive runs of the file alone (0/10 both before and after the fix).
|
||||
|
||||
The shape to write instead: **one page-side call that performs the
|
||||
action and returns what it observes**, with `expect.poll` retrying
|
||||
*that*. `scrollTo()` sets `scrollTop` and returns `scrollTop`, so the
|
||||
assertion is about what the grid did rather than about what it was
|
||||
ready to do. `layout-overflow.spec.ts`'s sidebar probe already had the
|
||||
fused half and was missing the retry; it has both now.
|
||||
|
||||
Worth generalising: a spec that resizes and then measures is asserting
|
||||
about a moving target for the next dozen frames. Fuse, then poll.
|
||||
|
||||
## "The first N tracks" is not a way to ask for an ordinary one (2026-08-20)
|
||||
|
||||
`queue-selection.spec.ts` staged its queue from the first few rows of
|
||||
`library.Library.GetTracks(0)` and clicked a track *name*, which
|
||||
`explore-link` routes to that track's **album** page. Four tracks in the
|
||||
fixture library have no album at all — `01 Tone A`, `02 Tone B`,
|
||||
`Title Only`, `no-tags-at-all` — and a name with nothing to route to
|
||||
renders as **plain text**, not as a link.
|
||||
|
||||
Two things follow, and the second is the sharper one.
|
||||
|
||||
**The order is the scan's.** `GetTracks` returns `audio_files.id` order,
|
||||
i.e. the order the scan inserted rows, which depends on concurrency and
|
||||
directory traversal. Locally the first eight are all from two proper
|
||||
albums, so the spec passed twice over; CI rebuilds its seed with a real
|
||||
scan, got a different eight, and failed on both engines. This is the
|
||||
same family as "a seed freezes every default it has already persisted" —
|
||||
the fixture library is not a list, it is a *set* with an incidental
|
||||
order, and no spec should depend on that order.
|
||||
|
||||
**A loose locator hid it.** The row was located with
|
||||
`.locator('.explore-link').first()`, and a row has two — the title and
|
||||
the artist. When the title is plain text, `first()` silently resolves to
|
||||
the **artist** link, so the click went somewhere real and the assertion
|
||||
was about a destination the test had not exercised. `.track-title
|
||||
.explore-link` is the locator that says which one it means; the loose
|
||||
one turned a fixture problem into a mystery.
|
||||
|
||||
The general rule for this repo's fixture library: it is deliberately
|
||||
full of edge cases (untagged, unicode, duplicates, extremes), so a spec
|
||||
that wants an *ordinary* track has to **say so** — filter on the
|
||||
property it depends on rather than slicing.
|
||||
|
||||
@@ -514,6 +514,36 @@ rather than renaming them.
|
||||
the autotag apply are registered; anything that is not registered has
|
||||
none of that, which is exactly how the three gaps the audit found
|
||||
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.
|
||||
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
||||
- `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
|
||||
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
|
||||
`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
|
||||
@@ -1481,6 +1520,58 @@ three, *no action is ever unreachable at any supported size*. The bands
|
||||
themselves already existed; what was new is that they are a promise and
|
||||
that the queue panel is inside it.
|
||||
|
||||
**The top bar decides what it can afford, and what it gives up is never
|
||||
an action.** Its five children do not fit at the bottom of the Compact
|
||||
band: the bar was 611px inside a 600px viewport idle and **862px while
|
||||
a scan ran**, because `job-indicator` is `hidden` when idle and 235px
|
||||
wide showing a real library's scan title (#143). So `services/
|
||||
top-bar-fit.ts` is `page-header`'s treatment one bar up — a
|
||||
ResizeObserver, every pass starting from all-visible, hiding the
|
||||
lowest-priority child until it fits.
|
||||
|
||||
Five things about it are load-bearing.
|
||||
|
||||
**It is measured rather than breakpointed for a reason specific to this
|
||||
bar**: three of its five children are as wide as their *content* — the
|
||||
library filter is a `<select>` sized by the longest library name, the
|
||||
indicator by the running job's title, the search box by its view-scoped
|
||||
placeholder — so any width picked is right for one library, one job and
|
||||
one view. Swept with a long-titled scan staged, the bar overflowed at
|
||||
**every** width from 600 to 899 *and* at 900 where `nav-history`
|
||||
appears, while 899 fits; a breakpoint fixing "600 to 610" would have
|
||||
fixed whichever case happened to be idle when it was measured.
|
||||
|
||||
**What yields is decided by the promise above, which rules out the two
|
||||
cheapest answers.** Hiding the library filter takes away an action —
|
||||
`library-filter` is the only control in the app that calls
|
||||
`setSelectedLibrary` — so it trades this promise for the same promise
|
||||
(#148 is the phone already doing that). Collapsing the search box to an
|
||||
icon is what #57 wants and #57 is blocked behind #62, so building it
|
||||
here is building it without the thing that blocks it. The two that
|
||||
yield are the two that are **not** actions: the wordmark, which the
|
||||
window's own title bar repeats and which #48 wants down to "YJ" at
|
||||
every width anyway, and then the job indicator's *label*, leaving the
|
||||
ring — which is not a new judgement, since the component already drops
|
||||
it below 600px and its `sr-only` live region is what announces the
|
||||
state either way.
|
||||
|
||||
**The wordmark yields its width, not its existence.** The collapsed
|
||||
rule is visually-hidden rather than `display: none`, because that `h1`
|
||||
is the document's top-level heading as well as the brand.
|
||||
|
||||
**"Fits" is the children against the content box, and `scrollWidth`
|
||||
cannot express it.** `scrollWidth` counts a box's left padding and not
|
||||
its right, so with 2em gutters it under-reports by 32px: the first fix
|
||||
read `700/700` — a perfect fit — with the indicator sitting in the
|
||||
whole right gutter. Same family as #69's title trap, and found only
|
||||
because `top-bar-fit.spec.ts` measures **per child**, which is what
|
||||
`layout-overflow.spec.ts` cannot do and why that spec was green
|
||||
throughout the defect.
|
||||
|
||||
And **the bar does not resize when a job starts**, which is the case the
|
||||
whole thing is for — a ResizeObserver on the header alone never fires,
|
||||
so every element child is observed too.
|
||||
|
||||
**900 is the worst desktop width, not the 800×600 minimum.** The
|
||||
sidebar collapses to icons *below* 900, so the main panel is 843px at
|
||||
899 and 700px at 900 — the narrowest content area any desktop width
|
||||
|
||||
@@ -57,7 +57,17 @@ type GeneralConfig struct {
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||
//
|
||||
// A launch page naming a *retired* view is treated as a zero value
|
||||
// rather than as an error, because the alternative is an app that will
|
||||
// not start for anyone who had that page selected when it was removed.
|
||||
// An unknown-but-not-retired name still fails Validate: that is a typo,
|
||||
// and telling someone about it is the useful answer.
|
||||
func (c *GeneralConfig) ApplyDefaults() {
|
||||
if _, retired := RetiredViews[c.DefaultPage]; retired {
|
||||
c.DefaultPage = ""
|
||||
}
|
||||
|
||||
if c.DefaultPage == "" {
|
||||
c.DefaultPage = DefaultDefaultPage
|
||||
}
|
||||
|
||||
+16
-2
@@ -24,10 +24,25 @@ const (
|
||||
ViewExplore View = "explore"
|
||||
ViewDownloads View = "downloads"
|
||||
ViewAutotag View = "autotag"
|
||||
ViewJobs View = "jobs"
|
||||
ViewSettings View = "settings"
|
||||
)
|
||||
|
||||
// RetiredViews are destinations that used to exist and no longer do.
|
||||
//
|
||||
// A *visibility* entry for a removed view needs no such list: it is a
|
||||
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
|
||||
// is a **value**, and an unknown one fails validation -- which on the
|
||||
// load path means the app refuses to start rather than a setting being
|
||||
// ignored. So the one shape that cannot be retired for free is named
|
||||
// here and reset to the default instead.
|
||||
//
|
||||
// `jobs` was folded into Settings by #27: library scans under
|
||||
// Libraries, index work under Search Index, downloads under the
|
||||
// download clients, and the autotag apply into the Autotag view.
|
||||
var RetiredViews = map[View]struct{}{
|
||||
"jobs": {},
|
||||
}
|
||||
|
||||
// ViewSpec is what the backend knows about a destination. The label and
|
||||
// the icon are deliberately absent: those are presentation, they live
|
||||
// beside the rest of the app's icon vocabulary in
|
||||
@@ -73,7 +88,6 @@ var Views = []ViewSpec{
|
||||
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewJobs, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestViewVisibilityStoredWins(t *testing.T) {
|
||||
general := &GeneralConfig{
|
||||
ViewVisibility: map[string]bool{
|
||||
string(ViewAutotag): true,
|
||||
string(ViewJobs): false,
|
||||
string(ViewExplore): false,
|
||||
},
|
||||
}
|
||||
general.ApplyDefaults()
|
||||
@@ -77,8 +77,8 @@ func TestViewVisibilityStoredWins(t *testing.T) {
|
||||
t.Error("autotag was switched on and should be visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewJobs)] {
|
||||
t.Error("jobs was switched off and should be hidden")
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore was switched off and should be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,58 @@ func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A launch page naming a view that no longer exists resets to the
|
||||
// default instead of failing validation, which on the load path would
|
||||
// mean the app refusing to start for whoever had it selected.
|
||||
//
|
||||
// This is the one shape #25's storage decision does *not* make free: a
|
||||
// visibility entry is a key and an unknown key is dropped, but a launch
|
||||
// page is a value.
|
||||
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "jobs"}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if general.DefaultPage != DefaultDefaultPage {
|
||||
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is merely wrong is still an error: that is a typo, and
|
||||
// saying so is more useful than ignoring it.
|
||||
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "nonsense"}
|
||||
|
||||
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
|
||||
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A retired view is not a view, so nothing offers it and nothing
|
||||
// resolves it -- the visibility map included.
|
||||
func TestARetiredViewIsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for id := range RetiredViews {
|
||||
if _, ok := LookupView(string(id)); ok {
|
||||
t.Errorf("%s is retired but still in Views", id)
|
||||
}
|
||||
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
|
||||
t.Errorf("%s is retired but still resolves a visibility", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings may not be the launch page, which is the shape the old
|
||||
// DefaultPage enum had and is now read off the same table.
|
||||
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
|
||||
@@ -213,7 +265,7 @@ func TestViewVisibilityRoundTrips(t *testing.T) {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
if err := original.SetViewVisible(string(ViewJobs), false); err != nil {
|
||||
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
@@ -228,8 +280,8 @@ func TestViewVisibilityRoundTrips(t *testing.T) {
|
||||
t.Error("autotag should have loaded as visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewJobs)] {
|
||||
t.Error("jobs should have loaded as hidden")
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore should have loaded as hidden")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,27 +110,30 @@ test.describe('the album dropdown', () => {
|
||||
await app.setViewportSize({ width: 900, height: 600 });
|
||||
|
||||
try {
|
||||
// Wait for the range the assertion below actually needs, not for
|
||||
// "scrollable at all" (#133). The guard used to be
|
||||
// `scrollHeight > clientHeight + 40` while the next line asks to
|
||||
// reach 80, so any range in 41-79 satisfied it and could not
|
||||
// satisfy the assertion — and the grid passes through exactly
|
||||
// that while it settles, because it recomputes its columns after
|
||||
// the resize rather than during it. The settled range here is
|
||||
// 330, so this waits rather than weakening anything.
|
||||
// The container has to be a scroller at all, which is the thing
|
||||
// the defect behind this spec broke and is a property rather
|
||||
// than a moment.
|
||||
await expect
|
||||
.poll(() => scrollRange(app))
|
||||
.toMatchObject({ room: true, overflowY: 'auto' });
|
||||
.toMatchObject({ overflowY: 'auto' });
|
||||
|
||||
await app.evaluate((target) => {
|
||||
const sc = document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||
|
||||
if (sc) sc.scrollTop = target;
|
||||
}, SCROLL_TARGET);
|
||||
|
||||
expect(await scrollTop(app)).toBe(SCROLL_TARGET);
|
||||
// **Scrolling it and reading it back are one round trip** (#151).
|
||||
//
|
||||
// #133 made the guard ask for the range this needs rather than
|
||||
// for "scrollable at all", which was necessary and is not
|
||||
// sufficient: a guard and the write it guards are separate
|
||||
// `evaluate` calls, so the grid can satisfy the range and settle
|
||||
// out of it before the write lands. It still does — observed as
|
||||
// `Expected 80, Received 10` in the second of three consecutive
|
||||
// full-suite runs, with the spec green alone on the same app
|
||||
// straight afterwards.
|
||||
//
|
||||
// Polling harder cannot close a window between two moments; only
|
||||
// removing the window can. So the probe sets `scrollTop` and
|
||||
// returns what it reads back, in one page-side call, and the
|
||||
// poll retries *that* — which also means the assertion is about
|
||||
// what the grid did rather than about what it was ready to do.
|
||||
await expect.poll(() => scrollTo(app, SCROLL_TARGET)).toBe(SCROLL_TARGET);
|
||||
|
||||
// And the dropdown it opens is on screen, wherever the manager
|
||||
// decides that leaves the scroll. It is *not* "the position is
|
||||
@@ -261,7 +264,7 @@ async function closeDropdown(app: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the grid can scroll at all, which decides if a probe can move. */
|
||||
/** Whether the grid is a scroller at all, which is what the bug broke. */
|
||||
async function scrollRange(app: Page) {
|
||||
return app.evaluate((target) => {
|
||||
const sc = document
|
||||
@@ -269,22 +272,37 @@ async function scrollRange(app: Page) {
|
||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||
|
||||
return {
|
||||
// `room` is the precondition of the assertion that follows it:
|
||||
// enough range to actually reach the target. A threshold below
|
||||
// what the caller depends on is not a guard.
|
||||
// Reported for the failure message rather than waited on: `room`
|
||||
// was the guard #133 strengthened, and #151 is that a guard in
|
||||
// its own round trip cannot speak for the write in the next one.
|
||||
// `scrollTo` below is the assertion now; this says *why* it did
|
||||
// not reach the target when it does not.
|
||||
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
|
||||
overflowY: sc ? getComputedStyle(sc).overflowY : '',
|
||||
};
|
||||
}, SCROLL_TARGET);
|
||||
}
|
||||
|
||||
async function scrollTop(app: Page): Promise<number> {
|
||||
return app.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelector('.grid-scroll-container')?.scrollTop ?? -1,
|
||||
);
|
||||
/**
|
||||
* Scroll the grid and report where it actually landed, in one call.
|
||||
*
|
||||
* The whole point is that the set and the read share a moment: a
|
||||
* `scrollTop` write is clamped to the range *at the instant it lands*,
|
||||
* so reading it back in a second round trip asks a container that may
|
||||
* have re-laid out in between.
|
||||
*/
|
||||
async function scrollTo(app: Page, target: number): Promise<number> {
|
||||
return app.evaluate((to) => {
|
||||
const sc = document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||
|
||||
if (!sc) return -1;
|
||||
|
||||
sc.scrollTop = to;
|
||||
|
||||
return sc.scrollTop;
|
||||
}, target);
|
||||
}
|
||||
|
||||
/** Whether the open dropdown is inside the scroll container's viewport. */
|
||||
|
||||
@@ -36,9 +36,16 @@ test.describe('a failed binding says so', () => {
|
||||
// Libraries is the one section that starts expanded (H-22), so ask
|
||||
// the disclosure what state it is in rather than assuming one — a
|
||||
// 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
|
||||
.locator('config-section[heading="Libraries"]')
|
||||
.locator('.header');
|
||||
.getByRole('button', { name: 'Libraries' })
|
||||
.first();
|
||||
|
||||
if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,14 @@ const VIEWPORTS = [
|
||||
// was missing its own worst case.
|
||||
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
|
||||
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
|
||||
// Below the enforced minimum on purpose, and for the reason 700×480
|
||||
// is below it further down: a scaled display or a large system font
|
||||
// lands the layout here without the window ever being dragged there,
|
||||
// and 600 is the last width before the phone layout takes over. The
|
||||
// *narrowest header* is a different question from the narrowest
|
||||
// content area and has a different answer — this one (#143), where
|
||||
// the bar was 611px inside 600 sitting still.
|
||||
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -93,9 +101,10 @@ test.describe('the app fits in its own window', () => {
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Settings and Jobs are the two that were unreachable: they are
|
||||
// last in the nav, and the pane used to clip rather than scroll.
|
||||
for (const view of ['jobs', 'settings'] as const) {
|
||||
// Settings is the one that was unreachable: it is last in the nav,
|
||||
// and the pane used to clip rather than scroll. (Jobs was the other
|
||||
// half of this until #27 folded it into Settings.)
|
||||
for (const view of ['explore', 'settings'] as const) {
|
||||
const item = app.getByTestId(`nav-${view}`);
|
||||
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
@@ -123,27 +132,45 @@ test.describe('the app fits in its own window', () => {
|
||||
// be dragged here, but a scaled display or a large system font can
|
||||
// still land the layout in it, and clipping the nav with no scroll
|
||||
// is the failure that made Settings unreachable.
|
||||
const reachable = await app.locator('app-sidebar').evaluate((el) => {
|
||||
const settings = el.shadowRoot?.querySelector<HTMLElement>(
|
||||
'[data-testid="nav-settings"]',
|
||||
);
|
||||
//
|
||||
// The scroll and the measurement share one `evaluate` — #151's
|
||||
// rule, which this already had — and the whole probe is polled,
|
||||
// which it did not: a viewport change settles asynchronously, so a
|
||||
// single attempt reads whatever the sidebar happened to be doing.
|
||||
// The probe is safe to repeat because scrolling to the bottom twice
|
||||
// is scrolling to the bottom.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.locator('app-sidebar').evaluate((el) => {
|
||||
const settings = el.shadowRoot?.querySelector<HTMLElement>(
|
||||
'[data-testid="nav-settings"]',
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
|
||||
const item = settings.getBoundingClientRect();
|
||||
const pane = el.getBoundingClientRect();
|
||||
const item = settings.getBoundingClientRect();
|
||||
const pane = el.getBoundingClientRect();
|
||||
|
||||
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top);
|
||||
});
|
||||
|
||||
expect(reachable).toBe(true);
|
||||
return (
|
||||
item.bottom <= Math.ceil(pane.bottom) &&
|
||||
item.top >= Math.floor(pane.top)
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the title block fits its bar', () => {
|
||||
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
|
||||
// Stated rather than inherited from whatever ran last. Since #143
|
||||
// the wordmark is visually hidden at widths where the bar cannot
|
||||
// afford it, so a test about its *vertical* fit has to say which
|
||||
// width it is asking about.
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
// The state a11y.29 landed in. The pair is flex-centred and a UA
|
||||
// gives an `h1` a 0.67em top margin, so the block measured 67px
|
||||
// inside 64 — pre-existing, and invisible until dropping the h3's
|
||||
@@ -245,16 +272,26 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
|
||||
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
|
||||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||||
|
||||
const excess = await app.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
// Polled, for the reason the track-row test above is: since #143
|
||||
// the top bar's fit is *measured* — a ResizeObserver decides what
|
||||
// it can afford at this width — so a single read taken straight
|
||||
// after the resize races the observer and reports the frame
|
||||
// before it. Read once, this passed alone and failed in the full
|
||||
// suite, which is the shape of a timing assumption rather than of
|
||||
// a defect.
|
||||
//
|
||||
// The other half of the assertion: at every size this app
|
||||
// promises, the fix costs nothing. A scrollbar that is always
|
||||
// there is a worse answer than the clipping it replaced.
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
|
||||
return de.scrollWidth - de.clientWidth;
|
||||
});
|
||||
|
||||
// The other half: at every size this app promises, the fix costs
|
||||
// nothing. A scrollbar that is always there is a worse answer
|
||||
// than the clipping it replaced.
|
||||
expect(excess).toBe(0);
|
||||
return de.scrollWidth - de.clientWidth;
|
||||
}),
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
|
||||
|
||||
const VIEWS = [
|
||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||
'explore', 'downloads', 'jobs', 'settings',
|
||||
'explore', 'downloads', 'autotag', 'settings',
|
||||
];
|
||||
|
||||
type IconState = { name: string; hasSvg: boolean };
|
||||
|
||||
@@ -21,7 +21,6 @@ const VIEWS: [string, string, boolean][] = [
|
||||
['tracks', 'Tracks', true],
|
||||
['explore', 'Explore', false],
|
||||
['downloads', 'Downloads', false],
|
||||
['jobs', 'Background jobs', false],
|
||||
];
|
||||
|
||||
/** The header lives in the view's shadow root, inside its own. */
|
||||
@@ -53,7 +52,6 @@ const TAGS: Record<string, string> = {
|
||||
tracks: 'track-list',
|
||||
explore: 'explore-view',
|
||||
downloads: 'downloads-view',
|
||||
jobs: 'jobs-view',
|
||||
};
|
||||
|
||||
test.describe('every primary view says what it is', () => {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
callBinding,
|
||||
navigateTo,
|
||||
LONG_TRACK,
|
||||
NO_QUEUE_SOURCE,
|
||||
} from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The queue panel's mouse model (#43): single click selects, ctrl and
|
||||
* shift extend, double click plays from that row.
|
||||
*
|
||||
* **All four already worked, and nothing pinned any of them** — which is
|
||||
* the whole reason the report could be made and could not be settled.
|
||||
* `queue-reorder.spec.ts` covers the keyboard, `queue-overlay.spec.ts`
|
||||
* covers the panel's mode, and the component tier has the reorder
|
||||
* arithmetic; the pointer path had no coverage in either tier, so
|
||||
* "selection is broken here" and "selection is fine here" were equally
|
||||
* consistent with a green suite.
|
||||
*
|
||||
* Two things this spec is deliberately shaped around.
|
||||
*
|
||||
* **The clicks are real.** A `dispatchEvent(new MouseEvent('click'))`
|
||||
* on a row exercises the delegated handler and *not* the question being
|
||||
* asked, which is what the pointer lands on: the rows carry
|
||||
* `explore-link` names that take their own clicks, and a synthetic
|
||||
* event aimed at the row reports a selection the mouse would never have
|
||||
* produced. Every click here goes through Playwright.
|
||||
*
|
||||
* **The playing assertions use the 90-second fixture.** Every other
|
||||
* track is 2–6 seconds, so "double click plays row 3" read against a
|
||||
* 2-second track reports whatever auto-advance moved on to — measured
|
||||
* during this work as row 3 double-clicked and row 4 playing, which
|
||||
* reads exactly like an off-by-one in `PlayIndex` and is not one.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How long a click may take to show up as a highlight.
|
||||
*
|
||||
* **A poll with the default 5s timeout cannot see this defect**, and
|
||||
* that is the point of naming it. `queue-panel` repaints its rows two
|
||||
* ways — `onSelectionChanged()` calls `virtualizer.requestUpdate()`,
|
||||
* and `.keyFunction` is a per-render arrow, which is itself a changed
|
||||
* property the virtualizer reacts to. With **both** removed the
|
||||
* highlight still arrives, on whatever unrelated render happens next:
|
||||
* measured at 134ms, 3,866ms and 5,816ms for three clicks, against
|
||||
* 5ms, 16ms and 17ms on a healthy build.
|
||||
*
|
||||
* A user cannot tell "four seconds late" from "broken", which is very
|
||||
* close to what this issue reports. So the assertion is that the
|
||||
* highlight is *prompt*, with a bound ~30x the measured healthy case
|
||||
* and an order of magnitude under the degraded one.
|
||||
*/
|
||||
const HIGHLIGHT_MS = 500;
|
||||
|
||||
/** The queue's own answer, never the DOM's. */
|
||||
async function playing(app: Page): Promise<{ index: number; title: string }> {
|
||||
const state = await callBinding<{
|
||||
currentIndex: number;
|
||||
tracks: { title: string }[];
|
||||
}>(app, 'queue.Queue.GetState');
|
||||
|
||||
return {
|
||||
index: state.currentIndex,
|
||||
title: state.tracks[state.currentIndex]?.title ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Which rows are selected, as the accessibility tree sees it. */
|
||||
const selected = (app: Page) =>
|
||||
app.evaluate(() =>
|
||||
[
|
||||
...document
|
||||
.querySelector('queue-panel')!
|
||||
.shadowRoot!.querySelectorAll('[data-index]'),
|
||||
]
|
||||
.filter((row) => row.getAttribute('aria-selected') === 'true')
|
||||
.map((row) => Number((row as HTMLElement).dataset['index'])),
|
||||
);
|
||||
|
||||
/**
|
||||
* Six tracks with the long one in the middle, so a "play from here"
|
||||
* assertion has something to land on that will still be playing when it
|
||||
* is read back.
|
||||
*/
|
||||
async function queueSixAndOpen(app: Page): Promise<void> {
|
||||
const paths = await app.evaluate(async (longTitle) => {
|
||||
// `TrackName`, not `Title`: the library model names it after the
|
||||
// tag, and the *queue* is what calls it `title`.
|
||||
const tracks = (await window.__yjEvents.call(
|
||||
'library.Library.GetTracks',
|
||||
[0],
|
||||
10_000,
|
||||
)) as { FilePath: string; TrackName: string; Album: string }[];
|
||||
|
||||
const long = tracks.find((t) => t.TrackName === longTitle);
|
||||
|
||||
/**
|
||||
* **Tracks that have an album**, which is a requirement of one of
|
||||
* the tests and was previously left to luck (#156).
|
||||
*
|
||||
* `explore-link` routes a track name to its *album's* page, so a
|
||||
* track with no album renders a name that navigates nowhere — and
|
||||
* the fixture library deliberately contains two (`01 Tone A`,
|
||||
* `02 Tone B`). Which tracks arrive first is `audio_files.id`
|
||||
* order, i.e. the order the **scan** inserted them, which depends
|
||||
* on concurrency and directory traversal: locally the first eight
|
||||
* all had albums and the spec passed twice over, and CI rebuilds
|
||||
* its seed with a real scan and got a different eight.
|
||||
*
|
||||
* Asking for what the test needs is the fix. It is not a
|
||||
* narrowing: every assertion here wants an ordinary track, and
|
||||
* "the first five rows" was never a way to ask for one in a
|
||||
* library whose whole purpose is edge cases.
|
||||
*/
|
||||
const rest = tracks
|
||||
.filter((t) => t.TrackName !== longTitle && t.Album !== '')
|
||||
.slice(0, 5);
|
||||
|
||||
// Index 3 is the long one: far enough down that a shift-extend has
|
||||
// room either side of it.
|
||||
return [
|
||||
...rest.slice(0, 3).map((t) => t.FilePath),
|
||||
long!.FilePath,
|
||||
...rest.slice(3).map((t) => t.FilePath),
|
||||
];
|
||||
}, LONG_TRACK);
|
||||
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [
|
||||
paths,
|
||||
0,
|
||||
false,
|
||||
NO_QUEUE_SOURCE,
|
||||
]);
|
||||
|
||||
// A closed panel renders no list at all.
|
||||
await app.locator('#queue-button').click();
|
||||
await expect(app.locator('queue-panel .track-item').first()).toBeVisible();
|
||||
await expect(app.locator('queue-panel .track-item')).toHaveCount(6);
|
||||
}
|
||||
|
||||
/** The row at a data-index, not the nth child: see the note in the file. */
|
||||
const row = (app: Page, index: number) =>
|
||||
app.locator(`queue-panel .track-item[data-index="${index}"]`);
|
||||
|
||||
test.describe('selecting in the queue with a mouse', () => {
|
||||
// The suite shares one backend in file order, and a queue and an open
|
||||
// panel both outlive the page. `queue-reorder.spec.ts` sets the
|
||||
// precedent and the reason: a spec that spends state fails the next
|
||||
// one, in a list that reads like a regression in whatever you hold.
|
||||
test.afterEach(async ({ app }) => {
|
||||
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
||||
/* an empty queue is the state we were asking for */
|
||||
});
|
||||
|
||||
const open = await app.locator('queue-panel[open]').count();
|
||||
|
||||
if (open > 0) await app.locator('#queue-button').click();
|
||||
});
|
||||
|
||||
test('a single click selects that row and only that row', async ({ app }) => {
|
||||
await queueSixAndOpen(app);
|
||||
|
||||
await row(app, 1).click();
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([1]);
|
||||
|
||||
// And it *replaces* rather than accumulating, which is the half a
|
||||
// test of one click cannot see.
|
||||
await row(app, 4).click();
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([4]);
|
||||
});
|
||||
|
||||
test('ctrl adds a row and shift extends a range', async ({ app }) => {
|
||||
await queueSixAndOpen(app);
|
||||
|
||||
await row(app, 1).click();
|
||||
await row(app, 3).click({ modifiers: ['Control'] });
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([1, 3]);
|
||||
|
||||
// From the last row touched, so 3→5, keeping the ctrl-picked 1.
|
||||
await row(app, 5).click({ modifiers: ['Shift'] });
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([1, 3, 4, 5]);
|
||||
|
||||
// A plain click collapses the whole thing back to one.
|
||||
await row(app, 2).click();
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([2]);
|
||||
});
|
||||
|
||||
test('a double click plays from that row', async ({ app }) => {
|
||||
await queueSixAndOpen(app);
|
||||
|
||||
// Row 3 is the 90-second track. Asked of the backend, because the
|
||||
// panel's own highlight is a different claim.
|
||||
await row(app, 3).dblclick();
|
||||
|
||||
await expect.poll(() => playing(app)).toEqual({
|
||||
index: 3,
|
||||
title: LONG_TRACK,
|
||||
});
|
||||
|
||||
// Playing is not selecting: the double click clears the selection
|
||||
// it made on the way through, or every play leaves a row looking
|
||||
// picked out for an action the user did not ask for.
|
||||
await expect.poll(() => selected(app)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The one collision the report is actually about.
|
||||
*
|
||||
* Every track, album and artist name in the app navigates
|
||||
* (`utils/explore-link.ts`), and it does that by **stopping the
|
||||
* click's propagation** — in its own words, "the row must not also
|
||||
* treat it as a selection". So a click that lands on the name text
|
||||
* navigates and selects nothing, in the queue panel and in the track
|
||||
* list alike.
|
||||
*
|
||||
* That is deliberate and it is pinned here rather than argued with,
|
||||
* because the measurement says the queue is not the surface where it
|
||||
* hurts: a horizontal hit-scan of a row at three heights makes the
|
||||
* queue row **12%** link and the track list's row **21%** — the panel
|
||||
* the report calls broken is *less* covered by links than the list it
|
||||
* calls correct. What is left is one deliberate exception, and a
|
||||
* change to it should have to fail a test.
|
||||
*/
|
||||
test('a click on a name navigates instead, and that is the exception', async ({
|
||||
app,
|
||||
}) => {
|
||||
await queueSixAndOpen(app);
|
||||
|
||||
await row(app, 1).click();
|
||||
await expect
|
||||
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
|
||||
.toEqual([1]);
|
||||
|
||||
// `.track-title .explore-link`, not `.explore-link` first(): a row
|
||||
// has two, and which one `first()` finds depends on whether the
|
||||
// *title* is a link at all. It is not, for a track with no album —
|
||||
// `explore-link` renders plain text where it cannot route — so the
|
||||
// loose locator silently clicked the **artist** instead and the
|
||||
// assertion below was about a different destination than the one
|
||||
// being exercised (#156).
|
||||
await row(app, 2).locator('.track-title .explore-link').click();
|
||||
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'explore-album-details',
|
||||
);
|
||||
|
||||
// Row 2 did not join the selection — the link took the click.
|
||||
await expect.poll(() => selected(app)).toEqual([1]);
|
||||
|
||||
await navigateTo(app, 'tracks');
|
||||
});
|
||||
|
||||
/**
|
||||
* And the other half of that bargain: the link holds its navigation
|
||||
* for one double-click interval and drops it if a second click
|
||||
* arrives, so double-clicking a *name* still plays the row rather
|
||||
* than navigating away from it. That is what makes the exception
|
||||
* above survivable, and it is the part most likely to break silently
|
||||
* if the grace interval is ever removed.
|
||||
*/
|
||||
test('a double click on a name plays rather than navigating', async ({
|
||||
app,
|
||||
}) => {
|
||||
await queueSixAndOpen(app);
|
||||
|
||||
// Read rather than assumed: which view the app lands on is the
|
||||
// user's `DefaultPage`, so naming one here would be asserting on a
|
||||
// config value in a test about a double click.
|
||||
const before = await app
|
||||
.getByTestId('main-content')
|
||||
.getAttribute('data-active-view');
|
||||
|
||||
await row(app, 3).locator('.explore-link').first().dblclick();
|
||||
|
||||
await expect.poll(() => playing(app)).toEqual({
|
||||
index: 3,
|
||||
title: LONG_TRACK,
|
||||
});
|
||||
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
before!,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,16 +18,19 @@ test.describe('Settings is reachable without a mouse', () => {
|
||||
}) => {
|
||||
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);
|
||||
|
||||
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(['true', 'false']).toContain(
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The top bar fits the window it is in (#143).
|
||||
*
|
||||
* **This is measured per child, not on the shell**, which is #69's
|
||||
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
|
||||
* the *document* needs no sideways scrolling, and clipping inside a
|
||||
* component is invisible to it — which is exactly why that spec was
|
||||
* green throughout this defect. What a user sees is a control rendered
|
||||
* past the edge of the bar it belongs to, so that is what is asserted.
|
||||
*
|
||||
* **And it is measured with a job running**, which is the half the
|
||||
* original report missed. `job-indicator` is `hidden` while idle and up
|
||||
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
|
||||
* still and 862px during a scan — 171 to 262px of overflow, arriving
|
||||
* exactly when a user has reason to look at that bar. Nothing else in
|
||||
* this suite has ever measured a layout with work in flight;
|
||||
* `/__test/emit` stages it without staging the scan.
|
||||
*/
|
||||
type Page = import('@playwright/test').Page;
|
||||
|
||||
/**
|
||||
* The widths this asks about.
|
||||
*
|
||||
* 600 is the bottom of the Compact band (#24) and where the defect
|
||||
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
|
||||
* find, at the width that just gained the sidebar's labels); 800 is the
|
||||
* enforced minimum; 390 is a phone, where the answer must be that
|
||||
* nothing collapses because the media queries already did the work.
|
||||
*/
|
||||
const WIDTHS = [390, 600, 800, 899, 900, 1440];
|
||||
|
||||
/**
|
||||
* A scan whose title is as long as a real one gets. The label is capped
|
||||
* at 12rem by the component, so this is the widest the indicator can
|
||||
* be — measuring with "Scanning" instead reports a bar that fits and a
|
||||
* defect that is 100px smaller than it is.
|
||||
*/
|
||||
const LONG_JOB = {
|
||||
id: 'top-bar-fit',
|
||||
kind: 'library-scan',
|
||||
state: 'running',
|
||||
title: 'Scanning Music from the external drive',
|
||||
current: 40,
|
||||
total: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Every child's right edge against the bar's own content box.
|
||||
*
|
||||
* The content box, not `clientWidth`: the bar has a 2em right gutter,
|
||||
* and a control sitting in the padding is already the failure — it is
|
||||
* simply one that `scrollWidth` under-reports, because `scrollWidth`
|
||||
* counts the left padding and not the right.
|
||||
*/
|
||||
const overflowingChildren = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
|
||||
const style = getComputedStyle(bar);
|
||||
const box = bar.getBoundingClientRect();
|
||||
const left = box.left + parseFloat(style.paddingLeft);
|
||||
const right = box.right - parseFloat(style.paddingRight);
|
||||
|
||||
return [...bar.children]
|
||||
.filter((child) => {
|
||||
const cs = getComputedStyle(child);
|
||||
|
||||
// Out of flow is out of the question: a collapsed wordmark is
|
||||
// `position: absolute` and 1px wide precisely so it costs the
|
||||
// row nothing.
|
||||
if (cs.display === 'none' || cs.position === 'absolute') return false;
|
||||
|
||||
const r = child.getBoundingClientRect();
|
||||
|
||||
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
|
||||
})
|
||||
.map((child) => {
|
||||
const r = child.getBoundingClientRect();
|
||||
|
||||
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
|
||||
});
|
||||
});
|
||||
|
||||
/** What the fit pass gave up, read back off the DOM it changed. */
|
||||
const collapsed = (page: Page) =>
|
||||
page.evaluate(() => ({
|
||||
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
|
||||
jobLabel: !!document.querySelector('job-indicator[compact]'),
|
||||
}));
|
||||
|
||||
test.describe('the top bar fits the window', () => {
|
||||
for (const width of WIDTHS) {
|
||||
test(`no control sits outside the bar at ${width}px, idle`, async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.setViewportSize({ width, height: 600 });
|
||||
|
||||
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
||||
});
|
||||
|
||||
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
await app.setViewportSize({ width, height: 600 });
|
||||
await testctl.emit('JobsChanged', [LONG_JOB]);
|
||||
|
||||
// The indicator has to actually be up, or this test passes by
|
||||
// measuring the idle case under another name.
|
||||
await expect(app.locator('job-indicator')).toBeVisible();
|
||||
|
||||
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of "measured, never breakpointed": a rule that
|
||||
* collapses defensively at every narrow width fits just as well and
|
||||
* is a worse app. 1440 is roomy at any job title; 899 was measured to
|
||||
* fit with the longest one, because `nav-history` is not there yet.
|
||||
*/
|
||||
test('nothing is given up where there is room for it', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
await testctl.emit('JobsChanged', [LONG_JOB]);
|
||||
await expect(app.locator('job-indicator')).toBeVisible();
|
||||
|
||||
await expect.poll(() => collapsed(app)).toEqual({
|
||||
wordmark: false,
|
||||
jobLabel: false,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* And it gives them back. The pass starts from all-visible every
|
||||
* time, so this is the property that a rule which only ever *added*
|
||||
* to the collapsed set would fail — the wordmark would be gone for
|
||||
* the rest of the session after one narrow moment.
|
||||
*/
|
||||
test('the wordmark comes back when the window does', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
await testctl.emit('JobsChanged', [LONG_JOB]);
|
||||
await app.setViewportSize({ width: 600, height: 600 });
|
||||
|
||||
await expect.poll(() => collapsed(app)).toEqual({
|
||||
wordmark: true,
|
||||
jobLabel: true,
|
||||
});
|
||||
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await expect.poll(() => collapsed(app)).toEqual({
|
||||
wordmark: false,
|
||||
jobLabel: false,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The wordmark yields its width and not its existence: `display:
|
||||
* none` would take the document from one top-level heading to none.
|
||||
*/
|
||||
test('the collapsed wordmark is still the document heading', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
await testctl.emit('JobsChanged', [LONG_JOB]);
|
||||
await app.setViewportSize({ width: 600, height: 600 });
|
||||
|
||||
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
|
||||
|
||||
await expect(
|
||||
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* And the indicator keeps saying what it is doing after its visible
|
||||
* label goes — the `sr-only` live region is what announces the state,
|
||||
* which is the same argument the phone's own rule was written on.
|
||||
*/
|
||||
test('the job indicator still announces its state without its label', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
await testctl.emit('JobsChanged', [LONG_JOB]);
|
||||
await app.setViewportSize({ width: 600, height: 600 });
|
||||
|
||||
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
|
||||
|
||||
const spoken = await app
|
||||
.locator('job-indicator')
|
||||
.evaluate(
|
||||
(el) =>
|
||||
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
|
||||
);
|
||||
|
||||
expect(spoken).toContain('Scanning Music from the external drive');
|
||||
|
||||
// Leave the app as the next spec expects to find it.
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,42 @@ p {
|
||||
flex: 0 1 320px;
|
||||
}
|
||||
|
||||
/* What the bar gives up when it does not fit is decided by measuring
|
||||
it (`services/top-bar-fit.ts`, #143). Two rules here are what make
|
||||
that measurement mean anything.
|
||||
|
||||
**Nothing but the search box may shrink.** `scrollWidth` reports a
|
||||
perfect fit while a child quietly truncates -- #69's trap, one
|
||||
component over -- and the indicator's label is `text-overflow:
|
||||
ellipsis`, so it would have absorbed the deficit and hidden it. The
|
||||
search box is exempt because it shrinks between its 320px basis and
|
||||
the 200px floor its own stylesheet sets, and a narrower input hides
|
||||
nothing it was showing. */
|
||||
.top-bar hgroup,
|
||||
.top-bar library-filter,
|
||||
.top-bar job-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* **The wordmark yields its width, not its existence.** It is the
|
||||
app's top-level heading as well as its brand, and `display: none`
|
||||
would take a document from one `h1` to none at exactly the widths
|
||||
where the view's own header is the only thing left saying where you
|
||||
are. This is `styles/sr-only.css.ts`'s recipe, written out because
|
||||
that one is a `CSSResult` for shadow roots and this is the light
|
||||
DOM. */
|
||||
.top-bar hgroup.yj-collapsed {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* The bar is `justify-content: space-between`, which with four children
|
||||
spreads them evenly and left back/forward floating in the middle of
|
||||
nothing. Collecting the free space *after* this one puts the pair
|
||||
|
||||
+9
-2
@@ -54,6 +54,7 @@ import '@store/theme-store';
|
||||
import './src/services/keyboard-shortcut-service';
|
||||
import { activateView, deactivateView } from '@utils/view-lifecycle';
|
||||
import { installLongPressContextMenu } from '@utils/long-press';
|
||||
import { installTopBarFit } from './src/services/top-bar-fit';
|
||||
import {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -73,6 +74,14 @@ registerBundledIcons();
|
||||
// on `pointerType === 'touch'` only.
|
||||
installLongPressContextMenu();
|
||||
|
||||
// The top bar decides what it can afford to show (#143). Here rather
|
||||
// than in a component because the bar is light DOM in index.html and
|
||||
// its children are five separate elements; the shell is the only thing
|
||||
// that can see all five at once.
|
||||
const topBar = document.querySelector<HTMLElement>('header.top-bar');
|
||||
|
||||
if (topBar) installTopBarFit(topBar);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View caching navigation system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,7 +110,6 @@ const VIEW_TAGS: Record<string, string> = {
|
||||
explore: 'explore-view',
|
||||
autotag: 'autotag-view',
|
||||
downloads: 'downloads-view',
|
||||
jobs: 'jobs-view',
|
||||
settings: 'config-page',
|
||||
};
|
||||
|
||||
@@ -119,7 +127,6 @@ const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||
explore: () => import('@components/explore-view/explore-view.ts'),
|
||||
autotag: () => import('@components/autotag-view/autotag-view.ts'),
|
||||
downloads: () => import('@components/downloads-view/downloads-view.ts'),
|
||||
jobs: () => import('@components/jobs/jobs-view.ts'),
|
||||
settings: () => import('@components/config-page/config-page.ts'),
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@components/jobs/job-panel';
|
||||
import { list } from '@utils/binding';
|
||||
|
||||
type PendingItem = autotagservice.PendingItem;
|
||||
@@ -135,8 +136,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@@ -3207,7 +3220,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
// sees a blank full-screen "Loading\u2026".
|
||||
return html`
|
||||
<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.renderMain()}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
RemoveLibrary,
|
||||
GetRemovalImpact,
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
ScanLibrary,
|
||||
ScanAllLibraries,
|
||||
FullRescan,
|
||||
} from '@go/library/library.js';
|
||||
import { jobStore } from '@store/job-store';
|
||||
import type { Job } from '@store/job-store';
|
||||
import '@components/jobs/job-panel';
|
||||
import {
|
||||
GetScanConcurrency,
|
||||
SetScanConcurrency,
|
||||
@@ -77,6 +83,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
/** Which destinations the navigation offers (#25). */
|
||||
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 ---
|
||||
private shortcutsCtrl = new ShortcutsController(this);
|
||||
|
||||
@@ -864,8 +888,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
this.scrollMode =
|
||||
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
|
||||
|
||||
// Scan progress lives in the jobs panel now — this page only
|
||||
// needs to know when the library list itself changes.
|
||||
// Scanning is started and watched here (#27), so the job
|
||||
// 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(
|
||||
Events.LibraryAdded,
|
||||
() => void this.loadLibraries(),
|
||||
@@ -896,6 +926,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected override onViewDeactivate(): void {
|
||||
this.unsubscribeJobs?.();
|
||||
this.unsubscribeJobs = null;
|
||||
|
||||
this.cancelLibraryAdded?.();
|
||||
this.cancelLibraryRenamed?.();
|
||||
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 = (
|
||||
view: string,
|
||||
visible: boolean,
|
||||
@@ -1554,6 +1695,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
.value=${this.allowMeteredCatalogDownload}
|
||||
@config-change=${this.handleAllowMeteredChange}
|
||||
></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>
|
||||
`;
|
||||
}
|
||||
@@ -1674,18 +1829,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
description:
|
||||
'The page the app opens to on launch.',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'home', label: 'Home' },
|
||||
{ value: 'tracks', label: 'Tracks' },
|
||||
{ value: 'albums', label: 'Albums' },
|
||||
{ value: 'artists', label: 'Artists' },
|
||||
{ value: 'genres', label: 'Genres' },
|
||||
{ value: 'playlists', label: 'Playlists' },
|
||||
{ value: 'explore', label: 'Explore' },
|
||||
{ value: 'downloads', label: 'Downloads' },
|
||||
{ value: 'autotag', label: 'Autotag' },
|
||||
{ value: 'jobs', label: 'Jobs' },
|
||||
],
|
||||
// Derived, not written out: this list was a
|
||||
// second copy of the launchable set, and #27
|
||||
// removing a destination is exactly the change
|
||||
// that would have left the two disagreeing.
|
||||
// Settings is excluded because it is the one
|
||||
// view the backend refuses to launch into.
|
||||
options: VIEW_META
|
||||
.filter((v) => v.alwaysShown !== true)
|
||||
.map((v) => ({ value: v.id, label: v.label })),
|
||||
}}
|
||||
.value=${this.defaultPage}
|
||||
@config-change=${this.handleDefaultPageChange}
|
||||
@@ -2161,8 +2313,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
return html`
|
||||
<config-section
|
||||
heading="Libraries"
|
||||
description="Manage your music library folders. Scanning and its
|
||||
progress live in the Jobs panel."
|
||||
description="Manage your music library folders, and scan them
|
||||
for new and changed files."
|
||||
.open=${true}
|
||||
>
|
||||
<div class="scan-actions">
|
||||
@@ -2172,6 +2324,23 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
>
|
||||
Add Library
|
||||
</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>
|
||||
|
||||
${this.libraries.length > 0
|
||||
@@ -2209,7 +2378,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
<span class="library-count">
|
||||
${this.removingLibraryId === lib.id
|
||||
? 'Removing…'
|
||||
: html`${lib.trackCount} tracks`}
|
||||
: this.libraryScanStatus(lib.id)
|
||||
?? html`${lib.trackCount} tracks`}
|
||||
</span>
|
||||
<div class="overflow-wrapper">
|
||||
<button
|
||||
@@ -2232,6 +2402,16 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
>
|
||||
Rename
|
||||
</div>
|
||||
${this.libraryScanStatus(lib.id) === null
|
||||
? html`
|
||||
<div
|
||||
class="overflow-item"
|
||||
@click=${() => this.handleScanLibrary(lib.id)}
|
||||
>
|
||||
Scan now
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div
|
||||
class="overflow-item overflow-item--danger"
|
||||
@click=${() => void this.handleRemoveClick(lib.id)}
|
||||
@@ -2275,6 +2455,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
@config-change=${this.handleConcurrencyChange}
|
||||
></config-field>
|
||||
|
||||
<job-panel
|
||||
kinds="library-scan"
|
||||
heading="Scans"
|
||||
></job-panel>
|
||||
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { compact } from '@utils/binding';
|
||||
import { describeError, explainError } from '@utils/describe-error';
|
||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||
import './config-section';
|
||||
import '@components/jobs/job-panel';
|
||||
import { pickDirectory } from '../../utils/pick-directory';
|
||||
|
||||
/**
|
||||
@@ -274,6 +275,17 @@ export class DownloadClients extends LitElement {
|
||||
</wa-button>
|
||||
</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
|
||||
|
||||
@@ -246,7 +246,7 @@ export class DownloadPicker extends LitElement {
|
||||
return html`
|
||||
<wa-callout variant="success">
|
||||
Found a clear match and started downloading it. Progress is
|
||||
in the background jobs panel.
|
||||
on the Downloads page.
|
||||
</wa-callout>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -155,13 +155,27 @@ export class JobIndicator extends LitElement {
|
||||
goes -- the live region in render() is what announces
|
||||
this, and it is unaffected, so the ring keeps its
|
||||
accessible name and screen readers keep hearing the
|
||||
state change. */
|
||||
state change.
|
||||
|
||||
[compact] is the same removal asked for by measurement
|
||||
rather than by width, and it is set from outside: the
|
||||
shell's fit pass (services/top-bar-fit.ts, #143) owns
|
||||
it, because between 600 and 900 whether this label fits
|
||||
depends on what else is in the bar and on how long the
|
||||
running job's title is -- 235px for "Scanning Music from
|
||||
the external drive" -- rather than on the viewport. Two
|
||||
triggers, one effect, and the phone's is unconditional
|
||||
because it was argued and pinned before this existed. */
|
||||
@media (max-width: 599px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host([compact]) .label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,26 @@ export class QueuePanel
|
||||
return this.queue.tracks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the rows when the selection changes.
|
||||
*
|
||||
* `<lit-virtualizer>` renders through the `virtualize` directive,
|
||||
* which reacts to its *own* properties and not to the host having
|
||||
* re-rendered, so host state like a selection reaches the rows only
|
||||
* if it is pushed. `track-list` has always done this and both
|
||||
* playlist views had to be taught it.
|
||||
*
|
||||
* **There is a second, accidental mechanism here and it must not be
|
||||
* mistaken for this one**: `.keyFunction` below is a per-render
|
||||
* arrow, so it is a changed property on every host update and
|
||||
* repaints the rows by itself. Removing *either* alone changes
|
||||
* nothing observable, which is why #43 could not be settled by
|
||||
* reading the code. With both gone the highlight still arrives —
|
||||
* on whatever unrelated render happens next, measured at 134ms,
|
||||
* 3,866ms and 5,816ms against 5–17ms healthy, which a user cannot
|
||||
* tell from broken. `queue-selection.spec.ts` asserts the
|
||||
* *promptness* rather than the eventual state for that reason.
|
||||
*/
|
||||
onSelectionChanged(): void {
|
||||
this.virtualizer?.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* What the top bar drops when it runs out of room (#143).
|
||||
*
|
||||
* The bar holds five children — the wordmark, back/forward, the library
|
||||
* filter, the search box and the job indicator — and at the bottom of
|
||||
* the Compact band they do not all fit. Measured on `main` at 600×600:
|
||||
* the bar is 611px inside a 600px viewport sitting still, and **862px
|
||||
* while a scan with a long title is running**, because `job-indicator`
|
||||
* is `hidden` when idle and up to 235px wide when it is not. `body` is
|
||||
* `overflow-x: auto`, so what a user sees is a horizontal scrollbar on
|
||||
* a shell that #24 promised would not need one.
|
||||
*
|
||||
* **The fit is measured, never breakpointed**, which is `page-header`'s
|
||||
* rule (#69) and applies here for a reason specific to this bar: three
|
||||
* of its five children are as wide as their *content*. The library
|
||||
* filter is a `<select>` sized by the longest library name, the job
|
||||
* indicator by the running job's title, and the search box by its
|
||||
* view-scoped placeholder — so any width you pick is right for exactly
|
||||
* one library, one job and one view. The same sweep that produced the
|
||||
* numbers above found the bar overflowing at every width from 600 to
|
||||
* 899 *and* at 900, where `nav-history` reappears; a breakpoint fixing
|
||||
* "600 to 610" would have fixed the case that happened to be idle.
|
||||
*
|
||||
* **What yields is chosen by #24's own sentence** — *no action is ever
|
||||
* unreachable at any supported size* — which rules out the two cheapest
|
||||
* candidates the issue lists. Hiding the library filter takes away an
|
||||
* action: `library-filter` is the **only** control in the app that sets
|
||||
* the selected library (nothing else calls `setSelectedLibrary`), so
|
||||
* hiding it is trading this promise for the same promise. Collapsing
|
||||
* the search box to an icon is what #57 wants on a phone, but #57 is
|
||||
* blocked behind #62 and building its modal here would be building it
|
||||
* without the thing that blocks it.
|
||||
*
|
||||
* So the two things that yield are the two that are **not** actions and
|
||||
* whose content survives elsewhere:
|
||||
*
|
||||
* 1. **The wordmark**, which is a brand — the window's own title bar
|
||||
* says the same thing, and #48 wants it down to "YJ" at every width
|
||||
* anyway. It yields its *width*, not its existence: the rule in
|
||||
* `index.css` is visually-hidden rather than `display: none`, so the
|
||||
* document keeps its top-level heading.
|
||||
* 2. **The job indicator's label**, leaving the ring. This is not a new
|
||||
* judgement — the component already drops it below 600px for exactly
|
||||
* this reason, and its `sr-only` live region is what announces the
|
||||
* state either way, so nothing is lost to anyone. What a measurement
|
||||
* adds is the band between 600 and 900, where whether the label fits
|
||||
* depends on what else is in the bar rather than on the width alone.
|
||||
*
|
||||
* Measured against the running app with a long-titled scan staged, that
|
||||
* order fits at every width from 320 to 1440 — and collapses nothing at
|
||||
* 320, 390, 599, 899 and 1100, which is the other half of the claim.
|
||||
*
|
||||
* Three things about the mechanism are load-bearing.
|
||||
*
|
||||
* **Every pass starts from all-visible**, so the collapsed set is a
|
||||
* pure function of the current width rather than of how the window got
|
||||
* there. `page-header` states the same rule and the same reasons: a
|
||||
* pass that only ever added would never give the wordmark back, and one
|
||||
* that adjusted by a step would need a hysteresis band to stop it
|
||||
* oscillating on the pixel where it exactly fits.
|
||||
*
|
||||
* **"Fits" is the children against the content box, not `scrollWidth`
|
||||
* against `clientWidth`** — and that distinction is not pedantry, it
|
||||
* is a measured false pass. `scrollWidth` counts a box's *left*
|
||||
* padding and not its right, so with this bar's 2em gutters it
|
||||
* under-reports by 32px: at 700px with a scan running it read
|
||||
* `700/700`, a perfect fit, while `job-indicator` ended 32px past
|
||||
* where the content may go and sat in the gutter. Same family as #69's
|
||||
* title trap, one property over — the measurement that is easiest to
|
||||
* reach for is the one that cannot see the failure. So the predicate
|
||||
* here is the same one `top-bar-fit.spec.ts` asserts: no in-flow child
|
||||
* outside the content box.
|
||||
*
|
||||
* That is only truthful in turn because **nothing here absorbs pressure
|
||||
* by truncating**. The collapsible children are `flex-shrink: 0` in
|
||||
* `index.css`, so a deficit shows up as a child out of bounds instead
|
||||
* of quietly eating the indicator's label, which is `text-overflow:
|
||||
* ellipsis` and would have. The search box is the one child that may
|
||||
* shrink, between its 320px basis and the 200px floor its own
|
||||
* stylesheet sets, and a narrower input hides nothing it was showing.
|
||||
*
|
||||
* **The bar does not resize when a job starts**, which is the case the
|
||||
* whole thing is for. A ResizeObserver on the header alone never fires:
|
||||
* the indicator goes 0 → 235 inside a bar whose width has not changed.
|
||||
* Every element child is observed too.
|
||||
*/
|
||||
|
||||
/** One thing the bar can give up, cheapest first. */
|
||||
interface FitStep {
|
||||
/** For tests and for reading the DOM back. */
|
||||
readonly id: string;
|
||||
/** Applied to the bar; `on` collapses. */
|
||||
readonly collapse: (bar: HTMLElement, on: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order things are given up in. Lowest priority first — see the
|
||||
* argument above for why these two and not the library filter.
|
||||
*/
|
||||
export const FIT_STEPS: readonly FitStep[] = [
|
||||
{
|
||||
id: 'wordmark',
|
||||
collapse: (bar, on) =>
|
||||
bar.querySelector('hgroup')?.classList.toggle('yj-collapsed', on),
|
||||
},
|
||||
{
|
||||
id: 'job-label',
|
||||
collapse: (bar, on) =>
|
||||
bar.querySelector('job-indicator')?.toggleAttribute('compact', on),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Decide what the bar shows at its current width.
|
||||
*
|
||||
* Exported for the component tier, which can hand it a bar of known
|
||||
* widths; the app installs the observer below and never calls this.
|
||||
*
|
||||
* @returns the ids collapsed, in the order they were given up.
|
||||
*/
|
||||
export function measureTopBarFit(bar: HTMLElement): string[] {
|
||||
const fits = () => {
|
||||
const style = getComputedStyle(bar);
|
||||
const box = bar.getBoundingClientRect();
|
||||
const left = box.left + parseFloat(style.paddingLeft);
|
||||
const right = box.right - parseFloat(style.paddingRight);
|
||||
|
||||
for (const child of bar.children) {
|
||||
const cs = getComputedStyle(child);
|
||||
|
||||
// Out of flow is out of the question: a collapsed wordmark
|
||||
// is absolutely positioned and 1px wide precisely so that
|
||||
// it costs the row nothing.
|
||||
if (cs.display === 'none' || cs.position === 'absolute') continue;
|
||||
|
||||
const r = child.getBoundingClientRect();
|
||||
|
||||
// Sub-pixel slack: a flex row's widths are fractional and a
|
||||
// rounding difference is not an overflow anyone can see.
|
||||
if (r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const step of FIT_STEPS) step.collapse(bar, false);
|
||||
|
||||
const collapsed: string[] = [];
|
||||
|
||||
if (!fits()) {
|
||||
for (const step of FIT_STEPS) {
|
||||
step.collapse(bar, true);
|
||||
collapsed.push(step.id);
|
||||
|
||||
if (fits()) break;
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the bar and its children, and keep it fitting.
|
||||
*
|
||||
* Returns the uninstaller, which the tests use; the app installs once
|
||||
* for the life of the session.
|
||||
*/
|
||||
export function installTopBarFit(bar: HTMLElement): () => void {
|
||||
let measuring = false;
|
||||
|
||||
const measure = () => {
|
||||
// A pass resizes the children it collapses, which the observer
|
||||
// would report back to us. It settles either way — the pass is
|
||||
// idempotent at a given width — but re-entering it is work for
|
||||
// no news, and it is what "ResizeObserver loop completed with
|
||||
// undelivered notifications" is.
|
||||
if (measuring) return;
|
||||
|
||||
measuring = true;
|
||||
|
||||
try {
|
||||
measureTopBarFit(bar);
|
||||
} finally {
|
||||
measuring = false;
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
|
||||
observer.observe(bar);
|
||||
|
||||
for (const child of bar.children) observer.observe(child);
|
||||
|
||||
measure();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export type View =
|
||||
| 'explore'
|
||||
| 'downloads'
|
||||
| 'autotag'
|
||||
| 'jobs'
|
||||
| 'settings';
|
||||
|
||||
export interface ViewMeta {
|
||||
@@ -61,6 +60,5 @@ export const VIEW_META: ViewMeta[] = [
|
||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
|
||||
];
|
||||
|
||||
@@ -30,6 +30,7 @@ export type JobState =
|
||||
export type JobKind =
|
||||
| 'library-scan'
|
||||
| 'index-build'
|
||||
| 'download'
|
||||
| 'autotag-apply'
|
||||
| 'catalog-enrich';
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ describe('<app-sidebar>', () => {
|
||||
'nav-explore',
|
||||
'nav-downloads',
|
||||
'nav-autotag',
|
||||
'nav-jobs',
|
||||
'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');
|
||||
|
||||
expect(items).toHaveLength(11);
|
||||
expect(items).toHaveLength(10);
|
||||
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-log-view';
|
||||
import '@components/jobs/job-row';
|
||||
import '@components/jobs/jobs-view';
|
||||
import '@components/jobs/job-panel';
|
||||
import '@components/library-filter/library-filter';
|
||||
import '@components/library-status-indicator/library-status-indicator';
|
||||
import '@components/now-playing/now-playing';
|
||||
@@ -87,8 +87,8 @@ const TAGS = [
|
||||
'job-details-drawer',
|
||||
'job-indicator',
|
||||
'job-log-view',
|
||||
'job-panel',
|
||||
'job-row',
|
||||
'jobs-view',
|
||||
'library-filter',
|
||||
'library-status-indicator',
|
||||
'now-playing',
|
||||
|
||||
@@ -27,7 +27,6 @@ import '@components/explore-view/explore-view';
|
||||
import '@components/home-view/home-view';
|
||||
import '@components/downloads-view/downloads-view';
|
||||
|
||||
import '@components/jobs/jobs-view';
|
||||
import '@components/playlist-view/playlist-view';
|
||||
import { fixture } from '@test/support/render';
|
||||
import { stub, flush } from '@test/support/harness';
|
||||
@@ -223,7 +222,6 @@ const CACHED_VIEWS = [
|
||||
'artists-view',
|
||||
'genres-view',
|
||||
'downloads-view',
|
||||
'jobs-view',
|
||||
'playlist-view',
|
||||
'explore-view',
|
||||
'home-view',
|
||||
|
||||
@@ -72,7 +72,6 @@ const ALL_VISIBLE = {
|
||||
explore: true,
|
||||
downloads: true,
|
||||
autotag: true,
|
||||
jobs: true,
|
||||
settings: true,
|
||||
};
|
||||
|
||||
@@ -96,7 +95,6 @@ describe('view visibility', () => {
|
||||
'explore',
|
||||
'downloads',
|
||||
'autotag',
|
||||
'jobs',
|
||||
'settings',
|
||||
]);
|
||||
});
|
||||
@@ -104,11 +102,11 @@ describe('view visibility', () => {
|
||||
it('drops the ones the user switched off', async () => {
|
||||
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;
|
||||
|
||||
expect(navIDs(el)).not.toContain('autotag');
|
||||
expect(navIDs(el)).not.toContain('jobs');
|
||||
expect(navIDs(el)).not.toContain('explore');
|
||||
expect(navIDs(el)).toContain('settings');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user