From a525bc92ec318d7809aeb7f23d6b044d16d56e71 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 8 May 2026 14:40:47 -0600 Subject: [PATCH] feat(library-prefs): add folders_view_mode for tree/flat selection New persisted field on LibraryPreferences gating the upcoming Tree sub-mode in the LocalLibrary Folders tab. Default flat preserves existing behaviour for current installs. Migration adds the column non-destructively. Setter v2_set_library_folders_view_mode exposes the toggle to the frontend; the existing v2_get_library_preferences returns the new field automatically via serde. cc --- src-tauri/src/commands_v2/settings.rs | 16 +- src-tauri/src/config/library_preferences.rs | 183 +++++++++++++++++++- src-tauri/src/lib.rs | 1 + 3 files changed, 195 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands_v2/settings.rs b/src-tauri/src/commands_v2/settings.rs index b6dbf33ce..9df35caf9 100644 --- a/src-tauri/src/commands_v2/settings.rs +++ b/src-tauri/src/commands_v2/settings.rs @@ -3,7 +3,7 @@ use tauri::State; use crate::audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager}; use crate::cache::CacheStats; use crate::config::favorites_preferences::FavoritesPreferences; -use crate::config::library_preferences::LibraryPreferences; +use crate::config::library_preferences::{FoldersViewMode, LibraryPreferences}; use crate::config::playback_preferences::{ AutoplayMode, PlaybackPreferences, PlaybackPreferencesState, }; @@ -211,6 +211,20 @@ pub fn v2_save_library_preferences( crate::config::library_preferences::save_library_preferences(prefs, state) } +#[tauri::command] +pub fn v2_set_library_folders_view_mode( + mode: String, + state: State<'_, crate::config::library_preferences::LibraryPreferencesState>, +) -> Result<(), String> { + let parsed = FoldersViewMode::from_str(&mode); + let guard = state + .store + .lock() + .map_err(|_| "Failed to lock library preferences store".to_string())?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_folders_view_mode(parsed) +} + #[tauri::command] pub fn v2_get_cache_stats(state: State<'_, AppState>) -> CacheStats { state.audio_cache.stats() diff --git a/src-tauri/src/config/library_preferences.rs b/src-tauri/src/config/library_preferences.rs index ac80cfaf2..a888b6227 100644 --- a/src-tauri/src/config/library_preferences.rs +++ b/src-tauri/src/config/library_preferences.rs @@ -3,10 +3,41 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::{Arc, Mutex}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FoldersViewMode { + Flat, + Tree, +} + +impl Default for FoldersViewMode { + fn default() -> Self { + FoldersViewMode::Flat + } +} + +impl FoldersViewMode { + pub fn as_str(&self) -> &'static str { + match self { + FoldersViewMode::Flat => "flat", + FoldersViewMode::Tree => "tree", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "tree" => FoldersViewMode::Tree, + _ => FoldersViewMode::Flat, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LibraryPreferences { pub tab_order: Vec, pub hidden_tabs: Vec, + #[serde(default)] + pub folders_view_mode: FoldersViewMode, } impl Default for LibraryPreferences { @@ -23,6 +54,7 @@ impl Default for LibraryPreferences { "artists".to_string(), ], hidden_tabs: vec![], + folders_view_mode: FoldersViewMode::Flat, } } } @@ -52,6 +84,14 @@ impl LibraryPreferencesStore { ) .map_err(|e| format!("Failed to create library preferences table: {}", e))?; + // Idempotent migrations for columns added after the initial schema. + // These ALTERs fail when the column already exists; that failure is + // expected and ignored. + let _ = conn.execute( + "ALTER TABLE library_preferences ADD COLUMN folders_view_mode TEXT DEFAULT 'flat'", + [], + ); + Ok(Self { conn }) } @@ -69,21 +109,29 @@ impl LibraryPreferencesStore { pub fn get_preferences(&self) -> Result { let mut stmt = self .conn - .prepare("SELECT tab_order, hidden_tabs FROM library_preferences WHERE id = 1") + .prepare( + "SELECT tab_order, hidden_tabs, folders_view_mode \ + FROM library_preferences WHERE id = 1", + ) .map_err(|e| format!("Failed to prepare select: {}", e))?; let result = stmt.query_row([], |row| { let tab_order_str: String = row.get(0)?; let hidden_tabs_str: String = row.get(1)?; + let folders_view_mode_str: Option = row.get(2)?; let tab_order: Vec = serde_json::from_str(&tab_order_str) .unwrap_or_else(|_| LibraryPreferences::default().tab_order); let hidden_tabs: Vec = serde_json::from_str(&hidden_tabs_str).unwrap_or_default(); + let folders_view_mode = folders_view_mode_str + .map(|s| FoldersViewMode::from_str(&s)) + .unwrap_or_default(); Ok(LibraryPreferences { tab_order, hidden_tabs, + folders_view_mode, }) }); @@ -105,13 +153,39 @@ impl LibraryPreferencesStore { self.conn .execute( - "INSERT OR REPLACE INTO library_preferences (id, tab_order, hidden_tabs) - VALUES (1, ?1, ?2)", - params![tab_order_str, hidden_tabs_str], + "INSERT OR REPLACE INTO library_preferences \ + (id, tab_order, hidden_tabs, folders_view_mode) \ + VALUES (1, ?1, ?2, ?3)", + params![ + tab_order_str, + hidden_tabs_str, + prefs.folders_view_mode.as_str() + ], ) .map_err(|e| format!("Failed to save library preferences: {}", e))?; Ok(prefs) } + + pub fn set_folders_view_mode(&self, mode: FoldersViewMode) -> Result<(), String> { + // Ensure the row exists before updating; if no preferences have been + // saved yet, fall back to inserting a default row carrying the new + // folders_view_mode value. + let updated = self + .conn + .execute( + "UPDATE library_preferences SET folders_view_mode = ?1 WHERE id = 1", + params![mode.as_str()], + ) + .map_err(|e| format!("Failed to set folders_view_mode: {}", e))?; + + if updated == 0 { + let mut prefs = LibraryPreferences::default(); + prefs.folders_view_mode = mode; + self.save_preferences(prefs)?; + } + + Ok(()) + } } pub struct LibraryPreferencesState { @@ -176,3 +250,104 @@ pub fn save_library_preferences( let store = guard.as_ref().ok_or("No active session - please log in")?; store.save_preferences(prefs) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn fresh_store() -> (tempfile::TempDir, LibraryPreferencesStore) { + let dir = tempdir().expect("tempdir"); + let store = + LibraryPreferencesStore::new_at(dir.path()).expect("open store in temp dir"); + (dir, store) + } + + #[test] + fn default_is_flat() { + let prefs = LibraryPreferences::default(); + assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat); + } + + #[test] + fn legacy_json_without_folders_view_mode_defaults_to_flat() { + let legacy = r#"{ + "tab_order": ["tracks", "folders", "albums", "artists"], + "hidden_tabs": [] + }"#; + let prefs: LibraryPreferences = + serde_json::from_str(legacy).expect("parse legacy json"); + assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat); + } + + #[test] + fn round_trip_with_tree_set() { + let mut prefs = LibraryPreferences::default(); + prefs.folders_view_mode = FoldersViewMode::Tree; + let json = serde_json::to_string(&prefs).expect("serialize"); + let parsed: LibraryPreferences = + serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.folders_view_mode, FoldersViewMode::Tree); + } + + #[test] + fn folders_view_mode_str_round_trip() { + assert_eq!(FoldersViewMode::Flat.as_str(), "flat"); + assert_eq!(FoldersViewMode::Tree.as_str(), "tree"); + assert_eq!(FoldersViewMode::from_str("flat"), FoldersViewMode::Flat); + assert_eq!(FoldersViewMode::from_str("tree"), FoldersViewMode::Tree); + assert_eq!( + FoldersViewMode::from_str("garbage"), + FoldersViewMode::Flat + ); + } + + #[test] + fn fresh_store_returns_default_folders_view_mode() { + let (_dir, store) = fresh_store(); + let prefs = store.get_preferences().expect("get prefs"); + assert_eq!(prefs.folders_view_mode, FoldersViewMode::Flat); + } + + #[test] + fn set_folders_view_mode_persists_on_empty_table() { + let (_dir, store) = fresh_store(); + store + .set_folders_view_mode(FoldersViewMode::Tree) + .expect("set mode"); + let prefs = store.get_preferences().expect("get prefs"); + assert_eq!(prefs.folders_view_mode, FoldersViewMode::Tree); + } + + #[test] + fn set_folders_view_mode_updates_existing_row() { + let (_dir, store) = fresh_store(); + + // Seed a row with the default save path so the UPDATE branch runs. + let mut seeded = LibraryPreferences::default(); + seeded.tab_order = vec!["tracks".into(), "albums".into()]; + store.save_preferences(seeded).expect("save seed"); + + store + .set_folders_view_mode(FoldersViewMode::Tree) + .expect("set mode"); + let after = store.get_preferences().expect("get prefs"); + assert_eq!(after.folders_view_mode, FoldersViewMode::Tree); + // Sibling fields preserved. + assert_eq!( + after.tab_order, + vec!["tracks".to_string(), "albums".to_string()] + ); + } + + #[test] + fn save_preferences_persists_folders_view_mode() { + let (_dir, store) = fresh_store(); + let mut prefs = LibraryPreferences::default(); + prefs.folders_view_mode = FoldersViewMode::Tree; + store.save_preferences(prefs).expect("save"); + + let loaded = store.get_preferences().expect("load"); + assert_eq!(loaded.folders_view_mode, FoldersViewMode::Tree); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c583c6189..75650bd03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1586,6 +1586,7 @@ pub fn run(qconnect_cli_override: Option) { commands_v2::v2_save_favorites_preferences, commands_v2::v2_get_library_preferences, commands_v2::v2_save_library_preferences, + commands_v2::v2_set_library_folders_view_mode, commands_v2::v2_get_cache_stats, commands_v2::v2_get_available_backends, commands_v2::v2_get_devices_for_backend,