//! Left sidebar: VFS roots and tag tree. use std::collections::BTreeMap; use egui; use crate::state::BrowserState; use super::theme; use super::widgets; /// A node in the tag tree built from dot-separated tag names. struct TagNode { children: BTreeMap, is_leaf: bool, } impl TagNode { fn new() -> Self { Self { children: BTreeMap::new(), is_leaf: false, } } /// Insert a tag into the tree by splitting on `.`. fn insert(&mut self, tag: &str) { let mut current = self; let segments: Vec<&str> = tag.split('.').collect(); for (i, seg) in segments.iter().enumerate() { current = current.children.entry((*seg).to_string()).or_insert_with(TagNode::new); if i == segments.len() - 1 { current.is_leaf = true; } } } } /// Build a tag tree from a sorted list of dotted tag strings. fn build_tag_tree(tags: &[String]) -> BTreeMap { let mut root = TagNode::new(); for tag in tags { root.insert(tag); } root.children } /// Check if this path or any descendant is active in required_tags. fn any_descendant_active(prefix: &str, required_tags: &[String]) -> bool { required_tags.iter().any(|t| t == prefix || t.starts_with(&format!("{prefix}."))) } /// Wire the per-tag right-click menu: Filter / Rename / Remove from every /// sample. Destructive removal routes through `pending_confirm`; rename opens /// an inline edit row (`tag_rename_target`). fn tag_context_menu(response: egui::Response, tag: &str, state: &mut BrowserState) { response.context_menu(|ui| { if ui.button("Rename tag…").clicked() { state.tag_rename_target = Some((tag.to_string(), tag.to_string())); state.focus_inline_editor = true; // M-12: compute affected-sample count + descendant tags now so the // modal can show the consequences before the user commits. Descendants // are not propagated by `rename_tag_globally` (exact-match-only). let count = state.backend.count_samples_with_tag(tag).unwrap_or(0); let prefix = format!("{tag}."); let descendants: Vec = state .all_tags .iter() .filter(|t| t.starts_with(&prefix)) .cloned() .collect(); state.tag_rename_preview = Some((count, descendants)); ui.close(); } if widgets::danger_button(ui, "Remove from all samples…").clicked() { state.pending_confirm = Some(crate::state::ConfirmAction::RemoveTagGlobally { tag: tag.to_string(), }); ui.close(); } }); } /// Draw a single tag tree node recursively. fn draw_tag_node( ui: &mut egui::Ui, prefix: &str, segment: &str, node: &TagNode, state: &mut BrowserState, ) { let full_path = if prefix.is_empty() { segment.to_string() } else { format!("{prefix}.{segment}") }; let is_active = state.search_filter.required_tags.contains(&full_path); let has_active_descendant = any_descendant_active(&full_path, &state.search_filter.required_tags); if node.children.is_empty() { // Pure leaf — no children, no disclosure widget. Whole row toggles filter. let hover = if is_active { format!("Remove \"{full_path}\" filter") } else { format!("Filter by \"{full_path}\"") }; let resp = widgets::selectable_tag(ui, is_active, segment).on_hover_text(hover); if resp.clicked() { if is_active { state.search_filter.required_tags.retain(|t| t != &full_path); } else { state.search_filter.required_tags.push(full_path.clone()); } state.apply_search(); } tag_context_menu(resp, &full_path, state); } else { // Parent node — render the disclosure chevron as a distinct hit target // from the label, so the user can expand the tree without committing // to a filter (and vice versa). Parents that are themselves tagged // (`is_leaf == true`) render the label as a clickable filter; parents // that are purely organizational render the label as a plain marker // (filtering by them would match zero samples). let id = ui.make_persistent_id(&full_path); // M-5: top-level tag nodes default to open so the user sees the // taxonomy they've already built without click-by-click expansion. // Deeper nodes still default closed to keep deep trees scannable. // egui's persistent state means user toggles override this anyway. let cstate_default_open = prefix.is_empty(); let mut cstate = egui::collapsing_header::CollapsingState::load_with_default_open( ui.ctx(), id, cstate_default_open, ); let header_resp = ui .horizontal(|ui| { cstate.show_toggle_button(ui, egui::collapsing_header::paint_default_icon); if node.is_leaf { let hover = if is_active { format!("Remove \"{full_path}\" filter") } else { format!("Filter by \"{full_path}\" (exact)") }; let resp = widgets::selectable_tag(ui, is_active, segment).on_hover_text(hover); if resp.clicked() { if is_active { state.search_filter.required_tags.retain(|t| t != &full_path); } else { state.search_filter.required_tags.push(full_path.clone()); } state.apply_search(); } tag_context_menu(resp, &full_path, state); } else { // Organizational parent: colour by descendant-active state, // but the label is not interactive — there are no samples // tagged at this exact path to filter to. let color = if has_active_descendant { theme::accent_blue() } else { theme::text_secondary() }; ui.label(egui::RichText::new(segment).color(color)); } }) .response; cstate.show_body_indented(&header_resp, ui, |ui| { for (child_seg, child_node) in &node.children { draw_tag_node(ui, &full_path, child_seg, child_node, state); } }); } } /// Draw the sidebar panel content: vault picker, VFS list, tags section. pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) { // Library selector — switches between top-level libraries (separate // databases). Only shown when more than one library is registered; // single-library installs see only the inner "Vaults" list below. if state.settings.list.len() > 1 { ui.horizontal(|ui| { ui.label(egui::RichText::new("Library").small().color(theme::text_muted())); egui::ComboBox::from_id_salt("library_picker") .selected_text(&state.settings.name) .width(ui.available_width() - 8.0) .show_ui(ui, |ui| { let mut switch_to: Option<(std::path::PathBuf, String)> = None; for (name, path, reachable) in &state.settings.list { let is_active = path == &state.data_dir; let label = if *reachable { name.clone() } else { format!("{name} (offline)") }; if ui.selectable_label(is_active, &label).clicked() && !is_active && *reachable { switch_to = Some((path.clone(), name.clone())); } } if let Some((path, name)) = switch_to { // Guard against accidentally cancelling in-flight work. if state.has_in_flight_work() { state.pending_confirm = Some( crate::state::ConfirmAction::SwitchLibrary { path, library_name: name, }, ); } else { state.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path)); } } ui.separator(); if ui.button("Settings...").clicked() { state.settings.show_manager = true; } }); }); ui.add_space(theme::space::SM); ui.separator(); } else if !state.settings.list.is_empty() { // Single library — just show a "Settings..." link ui.horizontal(|ui| { ui.label(egui::RichText::new(&state.settings.name).small().color(theme::text_muted())); if ui.small_button("Settings").on_hover_text("Open library settings").clicked() { state.settings.show_manager = true; } }); ui.add_space(theme::space::SM); ui.separator(); } let vfs_list = state.vfs_list.clone(); let vfs_count = vfs_list.len(); // The "Vaults" section header carries weight only when there are multiple // VFS roots to navigate between. A single-row "Vaults" section is just // padding — drop the header in that case and let the row speak for itself. if vfs_count > 1 { widgets::section_header(ui, "Vaults"); } else { ui.add_space(theme::space::SM); } if state.show_vfs_banner { widgets::info_banner( ui, "A vault is your sample collection. Files stay where they are \u{2014} audiofiles just indexes them.", ); if ui.small_button("Got it").clicked() { state.show_vfs_banner = false; let _ = state.backend.set_config("vfs_explained", "1"); } ui.add_space(theme::space::SM); } // "+ New Vault" pinned above the list so it's reachable without scrolling // when the list gets long. if ui.button("+ New Vault").on_hover_text("Create a new vault to organize samples").clicked() { state.show_vfs_create = true; state.vfs_create_input.clear(); } ui.add_space(theme::space::SM); // VFS roots as vertical list for (i, vfs) in vfs_list.iter().enumerate() { let active = i == state.current_vfs_idx; let resp = widgets::selectable_row(ui, active, &vfs.name) .on_hover_text(format!("Switch to {} vault", vfs.name)); if resp.clicked() && !active { // Active-row re-click would silently reset navigation (clears // current_dir, breadcrumb, selection). Make it a no-op so the click // matches user expectation; a dedicated "Go to root" path can still // reset if needed. if i != state.current_vfs_idx { state.select_vfs(i); } } let vfs_id = vfs.id; let vfs_name = vfs.name.clone(); resp.context_menu(|ui| { if ui.button("Rename").clicked() { state.vfs_rename_target = Some((vfs_id, vfs_name.clone())); ui.close(); } // Always render Delete so the user can see the capability exists; // disable when removing it would leave zero vaults. Routed through // the shared danger affordance for consistency with collection/tag // deletes (P2). let delete_enabled = vfs_count > 1; let delete_resp = widgets::danger_button_enabled(ui, "Delete", delete_enabled); let delete_resp = if !delete_enabled { delete_resp.on_disabled_hover_text( "Create another vault first — audiofiles needs at least one.", ) } else { delete_resp }; if delete_resp.clicked() { state.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id, vfs_name }); ui.close(); } }); } ui.add_space(theme::space::LG); ui.separator(); // Collections section (manual + dynamic/saved-search) ui.collapsing("Collections", |ui| { if state.collections.is_empty() && !state.show_collection_create { ui.horizontal(|ui| { ui.label(egui::RichText::new("No collections yet.").color(theme::text_muted())); if ui.link(egui::RichText::new("Create one").color(theme::accent_blue())).clicked() { state.show_collection_create = true; state.collection_create_input.clear(); state.focus_inline_editor = true; } }); } else { let collections = state.collections.clone(); let active_id = state.active_collection; let mut delete_id: Option<(audiofiles_core::CollectionId, String)> = None; for coll in &collections { let is_active = active_id == Some(coll.id); // Dynamic collections re-apply their saved filter; manual collections // hold a fixed sample set. Distinguish with a text suffix instead of a // glyph (per the no-emoji brand rule, and for accessibility). let suffix = if coll.is_dynamic() { " (auto)".to_string() } else { format!(" ({})", coll.member_count) }; let label_text = format!("{}{}", coll.name, suffix); let hover = if coll.is_dynamic() { format!("Apply \"{}\" saved search (auto-updates when samples match)", coll.name) } else { format!("Show \"{}\" contents", coll.name) }; let resp = widgets::selectable_row_secondary(ui, is_active, label_text) .on_hover_text(hover); if resp.clicked() { if is_active { state.deactivate_collection(); } else if let Some(ref filter) = coll.filter { state.activate_dynamic_collection(coll.id, filter); } else { state.activate_collection(coll.id); } } let coll_id = coll.id; let coll_name = coll.name.clone(); resp.context_menu(|ui| { if ui.button("Rename").clicked() { state.collection_rename_target = Some((coll_id, coll_name.clone())); state.focus_inline_editor = true; ui.close(); } if widgets::danger_button(ui, "Delete").clicked() { delete_id = Some((coll_id, coll_name.clone())); ui.close(); } }); } if let Some((id, name)) = delete_id { state.pending_confirm = Some( crate::state::ConfirmAction::DeleteCollection { coll_id: id, coll_name: name }, ); } } // Inline rename modal if let Some((rename_id, _)) = state.collection_rename_target.clone() { // Show the original name as context so the user retains the reference // even if they clear the input to type a fresh value. let original = state.collections.iter() .find(|c| c.id == rename_id) .map(|c| c.name.clone()); if let Some(orig) = original { ui.label( egui::RichText::new(format!("Renaming: {orig}")) .small() .color(theme::text_muted()), ); } let want_focus = std::mem::take(&mut state.focus_inline_editor); ui.horizontal(|ui| { let Some((_, buf)) = state.collection_rename_target.as_mut() else { return; }; let resp = ui.text_edit_singleline(buf); if want_focus { resp.request_focus(); } let mut commit = false; if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { commit = true; } if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { state.collection_rename_target = None; return; } if ui.button("Rename").clicked() { commit = true; } if commit { let new_name = buf.trim().to_string(); if !new_name.is_empty() { let _ = state.backend.rename_collection(rename_id, &new_name); state.refresh_collections(); } state.collection_rename_target = None; } }); } // Inline create input if state.show_collection_create { let want_focus = std::mem::take(&mut state.focus_inline_editor); ui.horizontal(|ui| { let resp = ui.text_edit_singleline(&mut state.collection_create_input); if want_focus { resp.request_focus(); } if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { let name = state.collection_create_input.trim().to_string(); if !name.is_empty() { match state.backend.create_collection(&name, None) { Ok(_) => { state.status = format!("Created collection: {name}"); } Err(e) => { state.status = format!("Failed to create collection: {e}"); } } state.refresh_collections(); } state.collection_create_input.clear(); state.show_collection_create = false; } if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { state.collection_create_input.clear(); state.show_collection_create = false; } }); } else if ui.small_button("+").on_hover_text("Create a new collection").clicked() { state.show_collection_create = true; state.collection_create_input.clear(); state.focus_inline_editor = true; } }); ui.add_space(theme::space::SM); // Tags section — tree view for dot-separated tags ui.collapsing("Tags", |ui| { if state.all_tags.is_empty() { ui.label(egui::RichText::new("No tags yet").color(theme::text_muted())); } else { // Compute the filtered set up front so the count indicator can // render alongside the filter input. let total = state.all_tags.len(); let query = state.tag_search.to_lowercase(); let filtered_tags: Vec = if query.is_empty() { state.all_tags.as_ref().clone() } else { state.all_tags.iter() .filter(|t| t.to_lowercase().contains(&query)) .cloned() .collect() }; // Tag filter input — pair with a Clear button when populated so the // user doesn't have to select-all-and-delete. Mirrors the sample // search bar's Clear affordance in the toolbar. ui.horizontal(|ui| { let has_query = !state.tag_search.is_empty(); let reserved = if has_query { 56.0 } else { 4.0 }; ui.add( egui::TextEdit::singleline(&mut state.tag_search) .hint_text("Filter tags...") .desired_width(ui.available_width() - reserved), ); if has_query && ui.small_button("Clear").on_hover_text("Clear tag filter").clicked() { state.tag_search.clear(); } }); // Result count, only shown while a filter is active. if !state.tag_search.is_empty() { ui.label( egui::RichText::new(format!("{} of {} tags", filtered_tags.len(), total)) .small() .color(theme::text_muted()), ); } ui.add_space(theme::space::SM); // Inline tag rename. Above the tree so the user sees both the // original tag they're renaming and the tree it sits in. if let Some((old_tag, _)) = state.tag_rename_target.clone() { let mut commit: Option<(String, String)> = None; let mut cancel = false; let want_focus = std::mem::take(&mut state.focus_inline_editor); ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Renaming tag: {old_tag} \u{2192}")) .small() .color(theme::text_muted()), ); let Some((_, buf)) = state.tag_rename_target.as_mut() else { return }; let resp = ui.add( egui::TextEdit::singleline(buf).hint_text(old_tag.as_str()), ); if want_focus { resp.request_focus(); } if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { let new_name = buf.trim().to_string(); if !new_name.is_empty() && new_name != old_tag { commit = Some((old_tag.clone(), new_name)); } else { cancel = true; } } if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { cancel = true; } if ui.button("Rename").clicked() { let new_name = buf.trim().to_string(); if !new_name.is_empty() && new_name != old_tag { commit = Some((old_tag.clone(), new_name)); } } }); // M-12: preview the consequences before commit. Exact-match // semantics mean descendants like `drums.kick` are NOT renamed // when the user renames `drums`; surface that warning so the // user can choose to rename each descendant individually if // they want the whole subtree to move. if let Some((count, descendants)) = state.tag_rename_preview.clone() { let summary = format!( "Affects {} sample{}.", count, if count == 1 { "" } else { "s" }, ); ui.label( egui::RichText::new(summary) .small() .color(theme::text_muted()), ); if !descendants.is_empty() { let preview: Vec<&str> = descendants .iter() .take(3) .map(|s| s.as_str()) .collect(); let extra = descendants.len().saturating_sub(preview.len()); let list = if extra == 0 { preview.join(", ") } else { format!("{}, +{} more", preview.join(", "), extra) }; ui.label( egui::RichText::new(format!( "Descendant tags will not be renamed: {list}" )) .small() .color(theme::accent_yellow()), ); } } if let Some((old, new)) = commit { state.rename_tag_globally(&old, &new); state.tag_rename_target = None; state.tag_rename_preview = None; } else if cancel { state.tag_rename_target = None; state.tag_rename_preview = None; } ui.add_space(theme::space::SM); } if filtered_tags.is_empty() { ui.label(egui::RichText::new("No matching tags").color(theme::text_muted())); } else { let tree = build_tag_tree(&filtered_tags); for (segment, node) in &tree { draw_tag_node(ui, "", segment, node, state); } } } }); }