diff --git a/.github/workflows/release-please-bump-lockfile.yml b/.github/workflows/release-please-bump-lockfile.yml index 35dacd49f..c101d8dac 100644 --- a/.github/workflows/release-please-bump-lockfile.yml +++ b/.github/workflows/release-please-bump-lockfile.yml @@ -109,6 +109,33 @@ jobs: return; } + // Bind the artifact to the run that built it: the PR it + // names must be one GitHub attached to that run, at the same + // branch and commit. Otherwise a run built for one PR could + // hand over a lockfile claiming to be another's. Release PRs + // come from this repository, so the list is never empty for + // them; an empty list fails like a mismatch. + const run = context.payload.workflow_run; + const linked = (run.pull_requests || []).find( + (p) => p.number === prNumber, + ); + if (!linked) { + core.setFailed( + `artifact names PR #${prNumber}, which is not a PR of run ${run.id}`, + ); + return; + } + if ( + linked.head.ref !== branch || + linked.head.sha !== artifactHeadSha || + run.head_sha !== artifactHeadSha + ) { + core.setFailed( + `run ${run.id} built ${linked.head.ref}@${linked.head.sha}, artifact claims ${branch}@${artifactHeadSha}`, + ); + return; + } + // Verify the PR is still open, still authored by the // release-please bot, and its head ref still matches the // artifact's claim. diff --git a/.github/workflows/release-please-lockfile-build.yml b/.github/workflows/release-please-lockfile-build.yml index b4fba8246..a58636039 100644 --- a/.github/workflows/release-please-lockfile-build.yml +++ b/.github/workflows/release-please-lockfile-build.yml @@ -27,7 +27,8 @@ name: Build Cargo.lock for release-please PR "on": pull_request: - branches: [main] + # Maintenance branches get release PRs too (see release-please.yml). + branches: [main, "release/**"] types: [opened, synchronize, reopened] permissions: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4062917e0..eeaf5f251 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,9 +24,15 @@ name: Release Please # workflow keeps gating correctly on `startsWith(head_ref, # 'release-please--')`.** +# Also on maintenance branches (`release/1.9.x`, …): a patch release of a +# shipped line is cut from there, so the features already on main stay +# out of it. Each run targets the branch that was pushed +# (`--target-branch`), and reads that branch's config and manifest. +# A push to a branch runs that branch's copy of this file, so a +# maintenance branch needs this version of it too. "on": push: - branches: [main] + branches: [main, "release/**"] permissions: contents: write @@ -47,27 +53,41 @@ jobs: with: node-version: 20 - # Phase A — open / update the release PR. Idempotent: re-runs - # on every push, only mutates GitHub when something changed. - - name: Update release PR + # release-please is pinned to an exact version: `@17` resolved to + # whatever 17.x npm served at each run, handed the write token. + # Bump it by hand, on both steps. + # + # Phase A — when the head commit IS the release PR's merge + # commit, cut the GitHub release + tag. No-op on every other + # push, so we can run both phases unconditionally. + # + # **First, not second.** Right after a release PR merges, the + # update below looks for the release of the version the manifest + # now names; when that release does not exist yet it walks back up + # to `commit-search-depth` (1500) commits, one API call each, and + # the job's timeout cut it before this step ever ran — no tag, and + # a manual rerun on every release (1.8.1, 1.9.0, 1.9.1). Tagged + # first, the update finds its starting point at once. + - name: Publish GitHub release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 release-pr \ + npx --yes release-please@17.11.2 github-release \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ + --target-branch="${GITHUB_REF_NAME}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json - # Phase B — when the head commit IS the release PR's merge - # commit, cut the GitHub release + tag. No-op on every other - # push, so we can run both phases unconditionally. - - name: Publish GitHub release + # Phase B — open / update the release PR. Idempotent: re-runs + # on every push, only mutates GitHub when something changed. + - name: Update release PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 github-release \ + npx --yes release-please@17.11.2 release-pr \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ + --target-branch="${GITHUB_REF_NAME}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f994886db..ef52c33ee 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -130,14 +130,18 @@ gh secret set SIGNTOOL_PFX_PASSWORD --body "" ### 1. Cut the release via release-please -You do **not** hand-bump versions anymore. Every push to `main` -runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml), -which: +You do **not** hand-bump versions anymore. Every push to `main` — or to +a maintenance branch, see +[Patch releases from a maintenance branch](#patch-releases-from-a-maintenance-branch) — +runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml) +against that branch, which: 1. Parses the Conventional Commits since the last tag. 2. Computes the next semver (`feat:` → minor, `fix:` → patch, `feat!:` / `BREAKING CHANGE:` → major, no relevant commits → no PR). -3. Opens or refreshes a **chore(main): release X.Y.Z** PR that +3. Opens or refreshes a release PR — **chore(main): release X.Y.Z** on + `main`, **chore(release/X.Y.x): release X.Y.Z+1** on a maintenance + branch — that bumps every version manifest in lockstep: - [`package.json`](../package.json) (canonical, owned by release-please) - [`src-tauri/crates/app/tauri.conf.json`](../src-tauri/crates/app/tauri.conf.json) (`$.version`) @@ -152,17 +156,30 @@ which: To ship, **review and merge the release PR** (squash or merge, release-please tolerates both). The merge causes release-please to: -- create the `vX.Y.Z` tag on `main`, +- create the `vX.Y.Z` tag on the branch it targets (`main`, or the + maintenance branch), - create the matching GitHub Release with the auto-generated notes, - update [`.release-please-manifest.json`](../.release-please-manifest.json) to record the new version. -The new tag triggers [`release.yml`](../.github/workflows/release.yml) -exactly as before — no change needed downstream. +The tag is created with the workflow's `GITHUB_TOKEN`, and a tag pushed +that way **does not trigger** [`release.yml`](../.github/workflows/release.yml) +(GitHub does not start workflows from events the token itself caused). +Start it by hand once the tag exists: + +```bash +gh workflow run release.yml -f tag_name=vX.Y.Z +``` + +The workflow tags first and updates the release PR second: in the other +order, the update right after a release PR merged went looking for a +release that did not exist yet, walked up to 1,500 commits one API call at +a time, and the job's timeout cut it before the tag was ever made. ### 2. Watch the workflow -Pushing the tag triggers `.github/workflows/release.yml`: +`.github/workflows/release.yml`, started for the tag as above (a tag +pushed by hand would start it on its own; release-please's does not): - Builds Linux on `ubuntu-latest` — produces an `.AppImage` (universal, also the updater payload), a `.deb` (Debian/Ubuntu), @@ -215,6 +232,41 @@ updater errors. Common causes: pubkey mismatch (private/public keys regenerated), endpoint 404, malformed `latest.json`, signature corrupted on upload. +## Patch releases from a maintenance branch + +Once `main` carries features for the next minor or major, a fix for the +version users have cannot be cut from it: release-please would bump to +the next minor and ship those features with it. A patch is cut from a +maintenance branch instead, `release/X.Y.x`, branched from the last tag +of that line: + +1. **Create the branch** from the tag: `git push origin vX.Y.Z:refs/heads/release/X.Y.x`. +2. **Bring the workflows** to it. A push runs the pushed branch's copy of + [`release-please.yml`](../.github/workflows/release-please.yml), and + a pull request runs its base's copy of + [`release-please-lockfile-build.yml`](../.github/workflows/release-please-lockfile-build.yml); + a tag older than their `release/**` support has neither. Cherry-pick + the commit that added it. +3. **Cherry-pick each fix** from `main` (signed: `git cherry-pick -S`), + through a pull request into `release/X.Y.x` so CI runs on it. The + **Maintenance branches** ruleset protects `release/**` the way `Main` + protects `main` — no deletion or force push, signed commits, a pull + request with one approval — without the CodeQL gate, since CodeQL only + analyses pull requests into `main`. A push to these branches runs their + own copy of the workflow with a write token, which is why they must not + take direct pushes. +4. release-please, run on the branch with `--target-branch`, opens + `chore(release/X.Y.x): release X.Y.Z+1` against it; the Cargo.lock + pipeline fills it in as on `main`. Merge it, then start `release.yml` + for the new tag as above. Downstream (AUR, winget, COPR, apt) and the + updater manifest work the same, since everything starts from the tag. + +`main` keeps its own manifest, so its next release still counts from its +last stable version; its changelog will list the cherry-picked fixes a +second time, under the version that ships them on `main`. For a major, +set `release-as` in [`release-please-config.json`](../release-please-config.json) +before merging the release PR. + ## Beta channel WaveFlow ships an **opt-in beta channel** so testers can run diff --git a/docs/features/library.md b/docs/features/library.md index 3f072ed93..6d7d1338b 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -91,7 +91,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src - **Artist page** — its track list comes from `list_tracks` with an `artist_id`, matched **by row id**: every track that credits the artist through `track_artist`, in any position, plus every track of an album whose album artist they are. It used to load the whole library and keep the rows whose display string, split on `", "`, contained the name — so "Earth, Wind & Fire" had no tracks, the multi-artist rule broken on the one page that needed it. [`get_artist_detail`](../../src-tauri/crates/app/src/commands/browse.rs) counts and lists albums the same way, so an artist who is only ever an album artist ("Various Artists", reached from a compilation's page) gets a page instead of "artist not found". - **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three. - **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly. -- **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on hover, on keyboard focus (it is hidden by opacity, so it stays in the tab order), and on every row once a selection has been _asked for_ — a checkbox, a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. With single-click play on, hovering no longer reveals the box, which sits where the number is and caught clicks meant to play the track; a selection there starts with Ctrl, Shift or Ctrl+A, and the box stays in the tab order, so Space or Enter on it still starts one from the keyboard. Each box is named after its row for screen readers, so a selection can be built without a modifier; its click and Space never reach the row, which would play it or reset the selection. Escape clears the selection and, when the mouse picked the row, drops that row's focus too, or the key would paint a focus ring on it; focus anywhere else is left alone ([`inputModality`](../../src/lib/inputModality.ts)). Server rows have no rowid to select and keep their number. +- **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on keyboard focus (it is hidden by opacity, so it stays in the tab order) and on every row once a selection has been _asked for_ — a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. The box no longer shows on hover, in either click mode ([#867](https://github.com/InstaZDLL/WaveFlow/issues/867)): it sits where the number is, so it caught clicks meant for the row, and it put a checkbox under every pass of the mouse. Each box is named after its row for screen readers; its click and Space never reach the row, which would play it or reset the selection. A row clicked with the mouse never shows a focus ring, whatever key is pressed next — Escape, a digit, or a desktop shortcut such as Super+Q under Hyprland ([#866](https://github.com/InstaZDLL/WaveFlow/issues/866)): [`inputModality`](../../src/lib/inputModality.ts) marks focus that arrives from a pointer, and `app.css` keeps a marked element ringless; focus reached from the keyboard keeps its ring. Server rows have no rowid to select and keep their number. - **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. **What a save sends, and what it changes.** Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from `get_track_artists`, the credits joined with `"; "` — the only separator `split_artist_name` splits on — not from the row's display `artist_name`, which is joined with `", "`: echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then **respells** that row (`respell` in `edit.rs`), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (`refresh_album_years`, shared with the folder scan), which the header otherwise kept until the next scan of that folder. - **POPM ratings** — 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline `StarRating` in the library track list, integer-star submenu in the right-click `TrackContextMenu` (any view), full half-star widget in the `TrackPropertiesModal`. The backend command `set_track_rating` writes the POPM frame back to the file (binary `\0` for ID3v2, text `RATING=0-100` for Vorbis / MP4 / APE), updates `track.rating` in the DB, then emits `track:updated` so every open view re-fetches without polling. The write goes through `edit::patch_file` like every other tag edit (#644), so a `.dsf` gets its POPM frame through the DSF writer rather than stopping at the database. **ID3v2 ratings are written as the POPM frame itself**, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag — it arrives as `||` through a whole-star `StarRating` rather than as the raw frame body its own documentation still describes — so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. `.dff` is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file — the DSD extractor lifts title / artist / album / year and no more — so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the `rating_min` rule — see [smart-playlists.md](smart-playlists.md#custom-smart-playlists-recursive-boolean-rule-tree). - **Lightbox** — double-click any cover or artist photo to view full-size with keyboard navigation. diff --git a/src/app.css b/src/app.css index 2a268b4d7..1639eb4e9 100644 --- a/src/app.css +++ b/src/app.css @@ -886,6 +886,21 @@ html.mini-player-window #root { border-radius: 4px; } +/* No ring on what the mouse focused (#866), whatever key comes next. + * + * The browser paints `:focus-visible` on a clicked element as soon as any + * key is pressed, so a desktop shortcut (Super+Q under Hyprland), a digit + * or an F-key left a ring on the last row clicked until the next click. + * `lib/inputModality.ts` marks an element focused by a pointer; keyboard + * focus (Tab, arrows) is unmarked and keeps its ring. Unlayered, so it + * also takes off Tailwind's `focus-visible:ring-*`, which lives in a + * layer. High contrast keeps every ring (its own rule is more specific). */ +[data-pointer-focus]:focus-visible { + outline: none; + --tw-ring-shadow: 0 0 #0000; + --tw-ring-offset-shadow: 0 0 #0000; +} + /* Glassy chrome, opted in by the surfaces that carry a translucent background *behind text* (issue #596). * diff --git a/src/components/common/RowSelectCheckbox.tsx b/src/components/common/RowSelectCheckbox.tsx index 86d46c911..c6fb2fa9a 100644 --- a/src/components/common/RowSelectCheckbox.tsx +++ b/src/components/common/RowSelectCheckbox.tsx @@ -8,19 +8,12 @@ interface RowSelectCheckboxProps { /** * Whether the list is in checkbox mode (`useMultiSelect().checkboxMode`). * While it is, every row shows its box, so adding to the selection - * needs no modifier. A row picked by a plain click is highlighted by - * the row itself and keeps its number, so the box waits for hover. + * needs no modifier. Outside it the box stays hidden, hover included: + * a selection starts with Ctrl or Shift + click, or Ctrl+A (#867). */ selectionActive: boolean; /** Same as a Ctrl+click on the row. */ onToggle: () => void; - /** - * Whether hovering the row reveals the box (default). Off where a plain - * click plays: the box sits where the number is, so a click meant to - * start the track ticked it instead (#859). There a selection starts - * with Ctrl, Shift or Ctrl+A, and the boxes follow once it is open. - */ - revealOnHover?: boolean; /** The row's title, so each box has a name of its own. */ title: string; /** What the cell shows otherwise: the row number or the playing bars. */ @@ -29,8 +22,13 @@ interface RowSelectCheckboxProps { /** * The leading cell of a selectable track row: its number, giving way to a - * checkbox on hover and while the list is in checkbox mode. - * The row must carry the `group` class for the hover state. + * checkbox while the list is in checkbox mode. + * + * Not on hover any more (#867): the box sat where the number is, so it + * caught clicks meant for the row — a play in single-click mode (#859), + * the first click of a double-click otherwise — and it showed a checkbox + * to every user on every pass of the mouse. Keyboard focus still reveals + * it, so a selection can be started from the keyboard. * * Its own click and Space/Enter never reach the row, which would start * playback or reset the selection. @@ -39,7 +37,6 @@ export function RowSelectCheckbox({ checked, selectionActive, onToggle, - revealOnHover = true, title, children, }: RowSelectCheckboxProps) { @@ -55,17 +52,13 @@ export function RowSelectCheckbox({ return ( {children}