feat(locallibrary): Tracks tab — server-paginated flat list
Adds the Tracks browse tab. Each page is a search_with_filter_page query (new paged variant of search_with_filter) appended on scroll-near-bottom, so the list never materializes the whole table — the documented ~16K-row Tracks freeze. Reuses the shared TrackRow in a virtualized ListView + ListScrollbar (the Favorites Tracks pattern) and the Albums controller's generation-guard (TRACKS_GEN) so a search/retry can't be clobbered by a stale in-flight page. - qbz-library: search_with_filter_page(query, offset, limit, include_qobuz, exclude_network) — same ordering as search_with_filter, LIMIT/OFFSET. - local_library.rs: map_local_track (LocalTrack is Send -> crosses spawn_blocking; TrackItem built on the UI thread), fetch_tracks_page, apply/append, reload/ensure/load_more (TRACKS_GEN-guarded). - LocalLibraryView.slint: Tracks body (ListView + TrackRow, show-artwork off per Tauri's perf default, show-favorite/show-download off) + a search toolbar + loading/error/empty states. - state.slint + main.rs: tracks state/actions + lazy load on first visit + search/load-more/retry wiring. Group-by, multi-select/bulk, and per-row playback land with the source-aware playback slice (track-action is a stub, like Albums). Built green (cargo check -p qbz-slint); runtime smoke-test pending. cc
This commit is contained in:
5 files changed
+428
-5
No files matched your search
@@ -2810,6 +2810,63 @@ impl LibraryDatabase {
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Paged variant of `search_with_filter` — the performant path for the
|
||||
/// Tracks tab. Returns one page (`LIMIT`/`OFFSET`) with the SAME ordering,
|
||||
/// so the frontend never materializes the whole table (the documented
|
||||
/// ~16K-row freeze). An empty `query` matches everything.
|
||||
pub fn search_with_filter_page(
|
||||
&self,
|
||||
query: &str,
|
||||
offset: u64,
|
||||
limit: u64,
|
||||
include_qobuz_downloads: bool,
|
||||
exclude_network_folders: bool,
|
||||
) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
""
|
||||
} else {
|
||||
"AND (source IS NULL OR source != 'qobuz_download')"
|
||||
};
|
||||
let network_filter = if exclude_network_folders {
|
||||
"AND NOT EXISTS (
|
||||
SELECT 1 FROM library_folders nf
|
||||
WHERE nf.is_network = 1
|
||||
AND local_tracks.file_path LIKE nf.path || '%'
|
||||
)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT {} FROM local_tracks \
|
||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
|
||||
{} {} \
|
||||
ORDER BY album COLLATE NOCASE, \
|
||||
COALESCE(album_artist, artist) COLLATE NOCASE, \
|
||||
disc_number, \
|
||||
track_number, \
|
||||
title COLLATE NOCASE \
|
||||
LIMIT ?2 OFFSET ?3",
|
||||
Self::TRACK_COLUMNS,
|
||||
source_filter,
|
||||
network_filter,
|
||||
);
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map(params![&pattern, limit as i64, offset as i64], |row| {
|
||||
Self::row_to_track(row)
|
||||
})
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
let mut tracks = Vec::new();
|
||||
for track in rows {
|
||||
tracks.push(track.map_err(|e| LibraryError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Get library statistics
|
||||
pub fn get_stats(&self, include_qobuz_downloads: bool) -> Result<LibraryStats, LibraryError> {
|
||||
let source_filter = if include_qobuz_downloads {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::{AlbumCardItem, AppWindow, LocalLibraryState};
|
||||
use crate::{AlbumCardItem, AppWindow, LocalLibraryState, TrackItem};
|
||||
|
||||
/// The four browse tabs. Order mirrors Tauri's default tab order
|
||||
/// (`tracks / folders / albums / artists`); the visible order is a user
|
||||
@@ -411,6 +411,172 @@ pub fn load_more_albums(
|
||||
});
|
||||
}
|
||||
|
||||
// =============================== Tracks tab ===============================
|
||||
//
|
||||
// Server-paginated flat list (the perf path that avoids the documented ~16K
|
||||
// freeze): each page is a `search_with_filter_page` query, appended on
|
||||
// scroll. Track artwork is off by default (Tauri's perf default), so there
|
||||
// are no per-row artwork jobs here. Group-by / multi-select / per-row
|
||||
// playback land with the source-aware playback slice.
|
||||
|
||||
const TRACKS_PAGE: u64 = 200;
|
||||
|
||||
static TRACKS_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn fmt_duration(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
/// Map one local track row to the rendered `TrackItem` (UI thread — holds a
|
||||
/// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the
|
||||
/// artist/album link ids are empty (the row renders them as plain text).
|
||||
fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
|
||||
let tier = match t.bit_depth {
|
||||
Some(b) if b >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
};
|
||||
TrackItem {
|
||||
id: t.id.to_string().into(),
|
||||
number: t.track_number.map(|n| n.to_string()).unwrap_or_default().into(),
|
||||
title: t.title.into(),
|
||||
artist: t.artist.into(),
|
||||
album: t.album.into(),
|
||||
duration: fmt_duration(t.duration_secs).into(),
|
||||
quality_tier: tier.into(),
|
||||
explicit: false,
|
||||
selected: false,
|
||||
artwork_url: t.artwork_path.unwrap_or_default().into(),
|
||||
artwork: slint::Image::default(),
|
||||
is_favorite: false,
|
||||
artist_id: "".into(),
|
||||
album_id: "".into(),
|
||||
removing: false,
|
||||
cache_status: 0,
|
||||
cache_progress: 0.0,
|
||||
unlocking: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one tracks page off the UI thread. `LocalTrack` is Send, so it
|
||||
/// crosses the `spawn_blocking` boundary; the conversion to `TrackItem`
|
||||
/// happens on the UI thread. has_more = the page came back full.
|
||||
fn fetch_tracks_page(query: String, offset: u64) -> Option<(Vec<qbz_library::LocalTrack>, bool)> {
|
||||
let rows = crate::library_db::with_db(|db| {
|
||||
db.search_with_filter_page(query.trim(), offset, TRACKS_PAGE, true, false)
|
||||
})?;
|
||||
let has_more = rows.len() as u64 == TRACKS_PAGE;
|
||||
Some((rows, has_more))
|
||||
}
|
||||
|
||||
fn apply_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, has_more: bool) {
|
||||
let items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let n = items.len() as i32;
|
||||
s.set_tracks(ModelRc::new(VecModel::from(items)));
|
||||
s.set_tracks_next_offset(n);
|
||||
s.set_tracks_has_more(has_more);
|
||||
s.set_tracks_loading(false);
|
||||
s.set_tracks_loading_more(false);
|
||||
s.set_tracks_load_failed(false);
|
||||
}
|
||||
|
||||
fn append_tracks(window: &AppWindow, rows: Vec<qbz_library::LocalTrack>, has_more: bool) {
|
||||
let new_items: Vec<TrackItem> = rows.into_iter().map(map_local_track).collect();
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let model = s.get_tracks();
|
||||
let mut combined: Vec<TrackItem> = (0..model.row_count())
|
||||
.filter_map(|i| model.row_data(i))
|
||||
.collect();
|
||||
let added = new_items.len() as i32;
|
||||
combined.extend(new_items);
|
||||
s.set_tracks(ModelRc::new(VecModel::from(combined)));
|
||||
s.set_tracks_next_offset(s.get_tracks_next_offset() + added);
|
||||
s.set_tracks_has_more(has_more);
|
||||
s.set_tracks_loading_more(false);
|
||||
}
|
||||
|
||||
fn spawn_tracks_page_load(window: &AppWindow, handle: tokio::runtime::Handle, gen: u64) {
|
||||
let s = window.global::<LocalLibraryState>();
|
||||
let query = s.get_tracks_search().to_string();
|
||||
let weak = window.as_weak();
|
||||
handle.spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, 0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
if TRACKS_GEN.load(Ordering::SeqCst) != gen {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Some((rows, has_more)) => apply_tracks(&w, rows, has_more),
|
||||
None => {
|
||||
let s = w.global::<LocalLibraryState>();
|
||||
s.set_tracks_loading(false);
|
||||
s.set_tracks_load_failed(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)load page 1 of the tracks list with the current search.
|
||||
pub fn reload_tracks(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let gen = TRACKS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let s = w.global::<LocalLibraryState>();
|
||||
s.set_tracks_loading(true);
|
||||
s.set_tracks_load_failed(false);
|
||||
spawn_tracks_page_load(&w, handle, gen);
|
||||
});
|
||||
}
|
||||
|
||||
/// Lazy load on first visit (re-entry keeps the loaded set + scroll).
|
||||
pub fn ensure_tracks_loaded(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let s = w.global::<LocalLibraryState>();
|
||||
if s.get_tracks().row_count() == 0 && !s.get_tracks_loading() {
|
||||
let gen = TRACKS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
s.set_tracks_loading(true);
|
||||
s.set_tracks_load_failed(false);
|
||||
spawn_tracks_page_load(&w, handle, gen);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch + append the next tracks page (scroll-near-bottom).
|
||||
pub fn load_more_tracks(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let s = w.global::<LocalLibraryState>();
|
||||
if !s.get_tracks_has_more() || s.get_tracks_loading_more() || s.get_tracks_loading() {
|
||||
return;
|
||||
}
|
||||
let offset = s.get_tracks_next_offset().max(0) as u64;
|
||||
let query = s.get_tracks_search().to_string();
|
||||
let gen = TRACKS_GEN.load(Ordering::SeqCst);
|
||||
s.set_tracks_loading_more(true);
|
||||
let weak2 = w.as_weak();
|
||||
handle.spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || fetch_tracks_page(query, offset))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let _ = weak2.upgrade_in_event_loop(move |w| {
|
||||
let s = w.global::<LocalLibraryState>();
|
||||
if TRACKS_GEN.load(Ordering::SeqCst) != gen {
|
||||
s.set_tracks_loading_more(false);
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Some((rows, has_more)) => append_tracks(&w, rows, has_more),
|
||||
None => s.set_tracks_loading_more(false),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -914,9 +914,15 @@ fn navigate_local_library(
|
||||
w.global::<LocalLibraryState>().set_active_tab(tab_id.into());
|
||||
w.global::<NavState>().set_view(ContentView::LocalLibrary);
|
||||
});
|
||||
// Lazy per-tab load — Albums fetches page 1 on first visit.
|
||||
if tab == local_library::LibTab::Albums {
|
||||
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
|
||||
// Lazy per-tab load on first visit.
|
||||
match tab {
|
||||
local_library::LibTab::Albums => {
|
||||
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
|
||||
}
|
||||
local_library::LibTab::Tracks => {
|
||||
local_library::ensure_tracks_loaded(weak, handle.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4019,6 +4025,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::debug!("[qbz-slint] local album action (playback slice pending): {id} {action}");
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_tracks_search(move |_query| {
|
||||
// The query is two-way bound to tracks-search; reload page 1.
|
||||
local_library::reload_tracks(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_tracks_load_more(move || {
|
||||
local_library::load_more_tracks(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_tracks_retry(move || {
|
||||
local_library::reload_tracks(weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
window
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_track_action(move |id, action| {
|
||||
// TODO(locallibrary): play / queue local tracks (source-aware slice).
|
||||
log::debug!("[qbz-slint] local track action (playback slice pending): {id} {action}");
|
||||
});
|
||||
}
|
||||
|
||||
// Discover "View all" — open the full-list page for a section,
|
||||
// recording it as a history entry (mirrors the favorites branch
|
||||
|
||||
@@ -26,6 +26,8 @@ import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
// One tab in the Local Library tab menu — underline style (ADR-008: NO
|
||||
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
|
||||
@@ -248,6 +250,24 @@ export component LocalLibraryView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks toolbar — search.
|
||||
if LocalLibraryState.active-tab == "tracks"
|
||||
&& (LocalLibraryState.tracks.length > 0
|
||||
|| LocalLibraryState.tracks-search != ""): HorizontalLayout {
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ExpandableSearch {
|
||||
placeholder: @tr("Search");
|
||||
sm: true;
|
||||
query <=> LocalLibraryState.tracks-search;
|
||||
edited(q) => {
|
||||
LocalLibraryActions.tracks-search(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 14px; }
|
||||
@@ -394,8 +414,128 @@ export component LocalLibraryView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Tracks tab — server-paginated flat list ------------
|
||||
if LocalLibraryState.active-tab == "tracks": Rectangle {
|
||||
if LocalLibraryState.tracks-loading: VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
}
|
||||
if !LocalLibraryState.tracks-loading
|
||||
&& LocalLibraryState.tracks-load-failed: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 10px;
|
||||
Text {
|
||||
text: @tr("Couldn't load your local tracks.");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: tretry-label.preferred-width + 28px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: tretry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
tretry-label := Text {
|
||||
text: @tr("Retry");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.legal;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
tretry-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
LocalLibraryActions.tracks-retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !LocalLibraryState.tracks-loading
|
||||
&& !LocalLibraryState.tracks-load-failed
|
||||
&& LocalLibraryState.tracks.length == 0
|
||||
&& LocalLibraryState.tracks-search == "": VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No tracks in your local library yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
if !LocalLibraryState.tracks-loading
|
||||
&& !LocalLibraryState.tracks-load-failed
|
||||
&& LocalLibraryState.tracks.length == 0
|
||||
&& LocalLibraryState.tracks-search != "": VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No tracks match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
if !LocalLibraryState.tracks-loading
|
||||
&& !LocalLibraryState.tracks-load-failed
|
||||
&& LocalLibraryState.tracks.length > 0: VerticalLayout {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
tracks-list := ListView {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
changed viewport-y => {
|
||||
if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px
|
||||
&& LocalLibraryState.tracks-has-more
|
||||
&& !LocalLibraryState.tracks-loading-more
|
||||
&& !LocalLibraryState.tracks-loading) {
|
||||
LocalLibraryActions.tracks-load-more();
|
||||
}
|
||||
}
|
||||
for track[i] in LocalLibraryState.tracks: TrackRow {
|
||||
track: track;
|
||||
index: i;
|
||||
show-artwork: false;
|
||||
show-album: true;
|
||||
show-favorite: false;
|
||||
show-download: false;
|
||||
media-action(kind, id, action) => {
|
||||
if (kind == "album" && action == "open") {
|
||||
LocalLibraryActions.open-album(id);
|
||||
} else if (kind == "artist" && action == "open") {
|
||||
LocalLibraryActions.open-artist(id);
|
||||
} else {
|
||||
LocalLibraryActions.track-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: tracks-list.viewport-height;
|
||||
visible-height: tracks-list.visible-height;
|
||||
viewport-y <=> tracks-list.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Other tabs — placeholder until their slices land ----
|
||||
if LocalLibraryState.active-tab != "albums": VerticalLayout {
|
||||
if LocalLibraryState.active-tab != "albums"
|
||||
&& LocalLibraryState.active-tab != "tracks": VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 6px;
|
||||
Text {
|
||||
|
||||
@@ -363,6 +363,17 @@ export global LocalLibraryState {
|
||||
in-out property <string> albums-sort: "artist-asc";
|
||||
// "grid" | "list" — the list view toggle lands with the next slice.
|
||||
in-out property <string> albums-view-mode: "grid";
|
||||
|
||||
// --- Tracks tab (server-paginated flat list — the 16K-freeze fix) ---
|
||||
// Group-by / multi-select / per-row play land with the playback slice.
|
||||
in property <[TrackItem]> tracks: [];
|
||||
in property <int> tracks-total: 0;
|
||||
in property <bool> tracks-loading: false;
|
||||
in property <bool> tracks-loading-more: false;
|
||||
in property <bool> tracks-has-more: false;
|
||||
in property <bool> tracks-load-failed: false;
|
||||
in-out property <int> tracks-next-offset: 0;
|
||||
in-out property <string> tracks-search: "";
|
||||
}
|
||||
|
||||
export global LocalLibraryActions {
|
||||
@@ -388,6 +399,13 @@ export global LocalLibraryActions {
|
||||
// Per-card action (play / queue / play-next) — lands with the playback
|
||||
// slice. Wired now so the shared AlbumCollectionView contract is honored.
|
||||
callback album-action(string /* album id */, string /* action */);
|
||||
|
||||
// --- Tracks tab ---
|
||||
callback tracks-search(string /* query */);
|
||||
callback tracks-load-more();
|
||||
callback tracks-retry();
|
||||
// Per-row play/queue — local playback lands with the source-aware slice.
|
||||
callback track-action(string /* track id */, string /* action */);
|
||||
}
|
||||
|
||||
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
||||
|
||||
Reference in new issue
Block a user