fix(library): preserve explorer column choices across restarts

Store browser searches and facet selections per profile with atomic writes. Prefer navigation history when available and restore saved choices before QML can publish construction defaults. Keep the startup crash guard and unrelated preferences intact.

Validate with six QML audits and two passing profile-isolation and persistence regressions.

cx
This commit is contained in:
vicrodh committed 2026-09-06 06:06:41 -06:00
1 parent fd421044bc
commit df8e1c89c1
4 files changed
+197 -5

No files matched your search

+8 -4
View File
@@ -165,6 +165,7 @@ Rectangle {
// this document restores the controls that determine what that offset // this document restores the controls that determine what that offset
// means before the tab asks for data. // means before the tab asks for data.
property bool _restoringNavigationState: false property bool _restoringNavigationState: false
property bool _navigationReady: false
readonly property string navigationStateJson: JSON.stringify({ readonly property string navigationStateJson: JSON.stringify({
activeTab: root.activeTab, activeTab: root.activeTab,
albumsSearch: root.albumsSearch, albumsSearch: root.albumsSearch,
@@ -203,14 +204,16 @@ Rectangle {
genresSort: root.genresSort genresSort: root.genresSort
}) })
onNavigationStateJsonChanged: { onNavigationStateJsonChanged: {
if (!root._restoringNavigationState && QbzShell.currentView === "local") if (root._navigationReady && !root._restoringNavigationState
&& QbzShell.currentView === "local")
QbzShell.reportNavState("local", root.navigationStateJson) QbzShell.reportNavState("local", root.navigationStateJson)
} }
function restoreNavigationState() { function restoreNavigationState() {
if (QbzShell.restoreStateScope !== "local" || QbzShell.stateRestore === "") var state = QbzShell.restoreStateScope === "local" && QbzShell.stateRestore !== ""
return ? QbzShell.stateRestore : QbzShell.localNavigationState()
if (state === "") return
var saved var saved
try { saved = JSON.parse(QbzShell.stateRestore) } try { saved = JSON.parse(state) }
catch (e) { QbzShell.restoreStateScope = ""; return } catch (e) { QbzShell.restoreStateScope = ""; return }
root._restoringNavigationState = true root._restoringNavigationState = true
function text(name, fallback) { function text(name, fallback) {
@@ -654,6 +657,7 @@ Rectangle {
// one query; the Albums/Folders/Artists sets are bounded). // one query; the Albums/Folders/Artists sets are bounded).
Component.onCompleted: { Component.onCompleted: {
restoreNavigationState() restoreNavigationState()
root._navigationReady = true
// Initial bindings are not a reliable change notification contract: // Initial bindings are not a reliable change notification contract:
// explicitly seed history even when this is a fresh entry whose state // explicitly seed history even when this is a fresh entry whose state
// happens to equal every default. // happens to equal every default.
+176
View File
@@ -0,0 +1,176 @@
//! Per-profile Local Library navigation preferences. The in-memory history
//! still owns Back/Forward; this small document carries browser choices over
//! a process restart without persisting the rest of the navigation stack.
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static WRITE: Mutex<()> = Mutex::new(());
const MAX_STATE_BYTES: usize = 64 * 1024;
fn path() -> Option<PathBuf> {
use qbz_app::user_data::UserDataPaths;
// Same identity as local_state::db_path, including the guest profile.
let user = UserDataPaths::load_last_user_id().unwrap_or(0);
Some(
UserDataPaths::data_dir_for(user)
.ok()?
.join("local_navigation_qt.json"),
)
}
fn browser_state(json: &str) -> Option<Value> {
if json.len() > MAX_STATE_BYTES {
return None;
}
let value: Value = serde_json::from_str(json).ok()?;
let input = value.as_object()?;
let mut state = Map::new();
for key in [
"activeTab",
"genresSearch",
"genreYearsSearch",
"genreArtistsSearch",
"genreAlbumsSearch",
"genresView",
"genresSort",
"explorerColumns",
] {
if let Some(Value::String(text)) = input.get(key) {
state.insert(key.into(), Value::String(text.clone()));
}
}
for key in [
"selectedGenres",
"selectedGenreYears",
"selectedGenreArtists",
"selectedGenreAlbums",
] {
if let Some(Value::Object(selections)) = input.get(key) {
state.insert(
key.into(),
Value::Object(
selections
.iter()
.filter(|(_, selected)| selected.as_bool() == Some(true))
.map(|(key, selected)| (key.clone(), selected.clone()))
.collect(),
),
);
}
}
if let Some(Value::Bool(collapsed)) = input.get("genresBrowserCollapsed") {
state.insert("genresBrowserCollapsed".into(), Value::Bool(*collapsed));
}
if state
.get("activeTab")
.and_then(Value::as_str)
.is_some_and(|tab| !["genres", "albums", "artists", "folders", "tracks"].contains(&tab))
{
state.remove("activeTab");
}
Some(Value::Object(state))
}
fn save_browser_at(path: &Path, json: &str) {
let Some(state) = browser_state(json) else {
return;
};
let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut doc) = crate::settings_qt::read_json_object(path) else {
return;
};
if doc.get("browser") != Some(&state) {
doc.insert("browser".into(), state);
crate::settings_qt::write_json_object_atomic(path, &doc);
}
}
pub fn save_browser(json: &str) {
if let Some(path) = path() {
save_browser_at(&path, json);
}
}
fn browser_at(path: &Path) -> Option<String> {
let doc = crate::settings_qt::read_json_object(path)?;
browser_state(&doc.get("browser")?.to_string()).map(|state| state.to_string())
}
pub fn browser_json() -> String {
// A saved UI state must not trap the next boot after a crash. The file
// remains intact; ordinary navigation can overwrite it with fresh choices.
if crate::nav_qt::crash_level() >= 2 {
return String::new();
}
path()
.and_then(|path| browser_at(&path))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn browser_choices_survive_reopen_and_stay_in_their_profile() {
let temp = tempfile::tempdir().unwrap();
let first = temp.path().join("users/1/local_navigation_qt.json");
let second = temp.path().join("users/2/local_navigation_qt.json");
let choices = json!({"activeTab":"genres", "genresSearch":"rock",
"genreYearsSearch":"199", "genreArtistsSearch":"band", "genreAlbumsSearch":"live",
"selectedGenres":{"rock":true}, "selectedGenreYears":{"1994":true},
"selectedGenreArtists":{"band":true}, "selectedGenreAlbums":{"local:a":true},
"explorerColumns":"both", "genresView":"grid"});
save_browser_at(&first, &choices.to_string());
assert_eq!(
serde_json::from_str::<Value>(&browser_at(&first).unwrap()).unwrap(),
choices
);
assert!(browser_at(&second).is_none());
save_browser_at(&second, r#"{"activeTab":"albums","selectedGenres":{}}"#);
assert_eq!(
serde_json::from_str::<Value>(&browser_at(&first).unwrap()).unwrap(),
choices
);
save_browser_at(
&first,
r#"{"activeTab":"genres","selectedGenres":{},"genresSearch":""}"#,
);
let reopened: Value = serde_json::from_str(&browser_at(&first).unwrap()).unwrap();
assert_eq!(reopened["selectedGenres"], json!({}));
assert_eq!(reopened["genresSearch"], "");
}
#[test]
fn bad_state_cannot_replace_choices_or_other_document_fields() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("local_navigation_qt.json");
std::fs::write(&file, r#"{"future":42,"browser":{"activeTab":"genres"}}"#).unwrap();
for invalid in [
"null".to_string(),
"[1]".into(),
"{".into(),
"x".repeat(MAX_STATE_BYTES + 1),
] {
save_browser_at(&file, &invalid);
}
assert_eq!(
browser_at(&file).as_deref(),
Some(r#"{"activeTab":"genres"}"#)
);
save_browser_at(
&file,
r#"{"activeTab":"bad-route","selectedGenres":{"ok":true,"bad":"true"},"unrelated":7}"#,
);
let doc: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
assert_eq!(doc["future"], 42);
assert_eq!(doc["browser"], json!({"selectedGenres":{"ok":true}}));
std::fs::write(&file, "broken document").unwrap();
assert!(browser_at(&file).is_none());
save_browser_at(&file, r#"{"activeTab":"albums"}"#);
assert_eq!(std::fs::read_to_string(file).unwrap(), "broken document");
}
}
+1
View File
@@ -230,6 +230,7 @@ mod local_bulk;
mod local_ephemeral; mod local_ephemeral;
mod local_playback; mod local_playback;
mod local_playlist_qt; mod local_playlist_qt;
mod local_restore_qt;
mod local_tree; mod local_tree;
mod plex_pin_qt; mod plex_pin_qt;
// MyQBZ domain controllers. One module per concern, all driven by the three // MyQBZ domain controllers. One module per concern, all driven by the three
+12 -1
View File
@@ -518,6 +518,8 @@ pub mod qbz_shell {
fn report_scroll(self: Pin<&mut QbzShell>, scope: QString, y: f32); fn report_scroll(self: Pin<&mut QbzShell>, scope: QString, y: f32);
#[qinvokable] #[qinvokable]
fn report_nav_state(self: Pin<&mut QbzShell>, scope: QString, state: QString); fn report_nav_state(self: Pin<&mut QbzShell>, scope: QString, state: QString);
#[qinvokable]
fn local_navigation_state(self: &QbzShell) -> QString;
/// Sidebar navigation: record a content view ("home" | "library") /// Sidebar navigation: record a content view ("home" | "library")
/// and lazy-load its data on first visit. /// and lazy-load its data on first visit.
@@ -1293,7 +1295,16 @@ impl qbz_shell::QbzShell {
} }
pub fn report_nav_state(self: Pin<&mut Self>, scope: QString, state: QString) { pub fn report_nav_state(self: Pin<&mut Self>, scope: QString, state: QString) {
crate::nav_qt::set_live_state(&scope.to_string(), &state.to_string()); let scope = scope.to_string();
let state = state.to_string();
crate::nav_qt::set_live_state(&scope, &state);
if scope == "local" {
crate::local_restore_qt::save_browser(&state);
}
}
pub fn local_navigation_state(&self) -> QString {
QString::from(crate::local_restore_qt::browser_json())
} }
pub fn navigate_to(self: Pin<&mut Self>, view: QString) { pub fn navigate_to(self: Pin<&mut Self>, view: QString) {