feat(locallibrary): Settings > Local Library — folder management (Slice A)

Port the Tauri folder-management surface to a new Settings > Local Library
sub-section (index 4); the browse-view gear routes here and pre-selects it.

UI:
- LocalLibrarySettings.slint: folder list (icon, display name, disabled/
  unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/
  remove icon buttons) + filter + Maintenance (cleanup missing) + two-step
  Danger Zone clear. ADR-008: plain bordered icon buttons + square badges.
- LibFolderEditModal.slint: alias, enabled, network override + fs-type,
  change-path, accessibility status, last-scanned, scan-this-folder. Mounted
  at the AppShell root (overlays the window, ADR-009 by mount order).
- SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the
  new globals so the Rust layer can populate them.

Controller (local_library_settings.rs): load/add/remove/edit/save/change-path,
cleanup-missing (stats outside the DB lock), two-step clear, filter, select.
All DB access via qbz-library through library_db::with_db on spawn_blocking;
native dir picker + confirms via rfd. Selection preserved across reloads;
network folders get an async 6s-timeout accessibility check (exists() fallback
on timeout, mirroring Tauri).

Core (qbz-library): network_fs_label() for fs-type parity; separator-safe
delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the
prefix-collision bug without changing the legacy fn). Scan engine lands in
Slice B.

cc
This commit is contained in:
vicrodh committed 2026-05-29 13:07:19 -06:00
1 parent 5986779d85
commit 3826298f77
11 files changed
+1527 -6

No files matched your search

+25
View File
@@ -1168,6 +1168,31 @@ impl LibraryDatabase {
Ok(count)
}
/// Delete all tracks under a folder, matching a path prefix terminated by
/// the separator so a sibling like `/music/jazz2` is NOT removed when
/// deleting `/music/jazz`. Use this for folder removal — the older
/// `delete_tracks_in_folder` (kept for backward behavior compatibility with
/// the Tauri command) has a prefix-collision bug (`{}%`, no separator).
pub fn delete_tracks_in_folder_prefixed(&self, folder: &str) -> Result<usize, LibraryError> {
let pattern = format!("{}/%", folder.trim_end_matches('/'));
let count = self
.conn
.execute(
"DELETE FROM local_tracks WHERE file_path LIKE ?",
params![pattern],
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
Ok(count)
}
/// Remove a folder and its indexed tracks (separator-safe cascade). Mirrors
/// the Tauri remove-folder command order: drop the folder row, then the
/// tracks under it. Returns the number of tracks removed.
pub fn remove_folder_with_tracks(&self, path: &str) -> Result<usize, LibraryError> {
self.remove_folder(path)?;
self.delete_tracks_in_folder_prefixed(path)
}
/// Clear all LOCAL library tracks (preserves Qobuz downloads)
pub fn clear_all_tracks(&self) -> Result<(), LibraryError> {
self.conn
+1 -1
View File
@@ -48,7 +48,7 @@ pub use database::{
pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*;
pub use mount_info::is_network_path;
pub use mount_info::{is_network_path, network_fs_label};
pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
+57
View File
@@ -81,6 +81,63 @@ pub fn is_network_path(_path: &Path) -> bool {
false
}
/// Return the normalized network-filesystem label (`cifs` / `nfs` / `sshfs` /
/// `rclone` / `webdav` / `glusterfs` / `ceph` / `other`) for `path` when it
/// lives on a network-backed filesystem, else `None`. Mirrors the fs-type
/// classification the Tauri side persisted via `crate::network::is_network_path`,
/// so the Slint folder-settings modal can show + store the same auto-detected
/// type. (`is_network_path` returns only the bool; this adds the label.)
#[cfg(target_os = "linux")]
pub fn network_fs_label(path: &Path) -> Option<String> {
let mounts = read_mounts();
if mounts.is_empty() {
return None;
}
let target = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf());
let target_str = target.to_string_lossy();
let mut best: Option<(&str, usize)> = None;
for (mount_point, fs_type) in &mounts {
if target_str.starts_with(mount_point) {
let len = mount_point.len();
if best.map(|(_, l)| l < len).unwrap_or(true) {
best = Some((fs_type.as_str(), len));
}
}
}
let fs_type = best.map(|(t, _)| t)?;
if !is_network_fs(fs_type) {
return None;
}
Some(normalize_network_label(fs_type))
}
#[cfg(not(target_os = "linux"))]
pub fn network_fs_label(_path: &Path) -> Option<String> {
None
}
/// Collapse the raw /proc/mounts fs type to the label set the folder-settings
/// modal exposes. Unknown network types fall through to `other`.
#[cfg(target_os = "linux")]
fn normalize_network_label(fs_type: &str) -> String {
let lower = fs_type.to_lowercase();
let base = lower.strip_prefix("fuse.").unwrap_or(&lower);
match base {
"nfs" | "nfs4" => "nfs",
"cifs" | "smb" | "smbfs" | "smb3" => "cifs",
"sshfs" => "sshfs",
"rclone" | "rclonefs" => "rclone",
"davfs" | "webdav" => "webdav",
"glusterfs" => "glusterfs",
"ceph" => "ceph",
_ => "other",
}
.to_string()
}
#[cfg(target_os = "linux")]
fn read_mounts() -> Vec<(String, String)> {
// Inside Flatpak the sandbox's own /proc/mounts reflects the