//! The Library tab: three-pane layout with tree, list, and detail panes. use std::collections::{HashMap, HashSet}; use brittle_keymap::actions; use leptos::prelude::*; use leptos::task::spawn_local; use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary}; use crate::{ command_bar::SearchQuery, lib_tree::{flatten_tree, LibraryTree, TreeRow}, pub_detail::PubDetail, pub_list::PubList, ActionEvent, }; // ── Pane enum ───────────────────────────────────────────────────────────────── /// Which of the three panes currently has keyboard focus. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Pane { Tree, List, Detail, } impl Pane { pub fn next(self) -> Self { match self { Pane::Tree => Pane::List, Pane::List => Pane::Detail, Pane::Detail => Pane::Tree, } } pub fn prev(self) -> Self { match self { Pane::Tree => Pane::Detail, Pane::List => Pane::Tree, Pane::Detail => Pane::List, } } } // ── Component ───────────────────────────────────────────────────────────────── /// Root component for the Library tab. /// /// Owns all UI state, wires keymap actions, and drives async data loading. #[component] pub fn LibTab() -> impl IntoView { // ── Context signals ─────────────────────────────────────────────────────── let keymap_action = use_context::() .expect("KeymapAction context missing") .0; let search_query = use_context::() .map(|sq| sq.0) .unwrap_or_else(|| RwSignal::new(String::new())); let layout_config = use_context::() .expect("LayoutContext missing") .0; // ── UI state ────────────────────────────────────────────────────────────── let focused = RwSignal::new(Pane::Tree); // Resizing state let left_width = RwSignal::new(220); let right_width = RwSignal::new(300); let resizing_left = RwSignal::new(false); let resizing_right = RwSignal::new(false); // Initial widths from layout config (fractions) Effect::new(move |_| { let config = layout_config.get(); if let Some(win) = web_sys::window() { if let Ok(Some(total_width)) = win.inner_width().map(|v| v.as_f64()) { left_width.set((total_width * config.left_pane_fraction as f64) as i32); right_width.set((total_width * config.right_pane_fraction as f64) as i32); } } }); // Tree state let root_libs = RwSignal::new(Vec::::new()); let children_cache = RwSignal::new(HashMap::>::new()); let expanded = RwSignal::new(HashSet::::new()); let tree_cursor = RwSignal::new(0usize); // List state let list_items = RwSignal::new(Vec::::new()); let list_cursor = RwSignal::new(0usize); // Detail state let detail_ref = RwSignal::new(Option::::None); // ── Resizing Logic ──────────────────────────────────────────────────────── let on_mousemove = window_event_listener(leptos::ev::mousemove, move |ev| { if let Some(win) = web_sys::window() { if let Ok(Some(total_width)) = win.inner_width().map(|v| v.as_f64()) { let total_width = total_width as i32; let config = layout_config.get_untracked(); if resizing_left.get_untracked() { let new_width = ev.client_x(); // Constraints: min, max, AND leave enough space for center let max_allowed = (total_width - right_width.get_untracked() - config.center_pane_min).max(config.left_pane_min); left_width.set(new_width.max(config.left_pane_min).min(config.left_pane_max).min(max_allowed)); } else if resizing_right.get_untracked() { let new_width = total_width - ev.client_x(); // Constraints: min, max, AND leave enough space for center let max_allowed = (total_width - left_width.get_untracked() - config.center_pane_min).max(config.right_pane_min); right_width.set(new_width.max(config.right_pane_min).min(config.right_pane_max).min(max_allowed)); } } } }); let on_mouseup = window_event_listener(leptos::ev::mouseup, move |_| { resizing_left.set(false); resizing_right.set(false); }); on_cleanup(move || { on_mousemove.remove(); on_mouseup.remove(); }); // ── Derived / computed ──────────────────────────────────────────────────── // Flattened visible tree rows (recomputed when tree data or expand set changes). let tree_rows = Memo::new(move |_| { flatten_tree(&root_libs.get(), &children_cache.get(), &expanded.get(), 0) }); // The library selected by the tree cursor. let selected_library = Memo::new(move |_| { let pos = tree_cursor.get(); tree_rows.with(|rows| { rows.get(pos).and_then(|r| { uuid::Uuid::parse_str(&r.id).ok().map(LibraryId) }) }) }); // The reference ID selected by the list cursor. let selected_ref_id = Memo::new(move |_| { let pos = list_cursor.get(); list_items.with(|items| items.get(pos).map(|r| r.id.clone())) }); // ── Initial data load (and reload on repository change) ─────────────────── let reload_trigger = use_context::().map(|r| r.0); Effect::new(move |_| { let trigger = reload_trigger.map(|t| t.get()).unwrap_or(0); if trigger == 0 { return; } // no repository open yet spawn_local(async move { match crate::tauri::list_root_libraries().await { Ok(libs) => root_libs.set(libs), Err(e) => leptos::logging::log!("load libraries: {e}"), } }); }); // ── Reactive data loads ─────────────────────────────────────────────────── // Reload list whenever the selected library or search query changes. Effect::new(move |_| { let lib_id = selected_library.get(); let query = search_query.get(); let trigger = reload_trigger.map(|t| t.get_untracked()).unwrap_or(0); if trigger == 0 { return; } // no repository open yet spawn_local(async move { let result = match (&lib_id, query.is_empty()) { (Some(id), true) => crate::tauri::list_library_references_recursive(id).await, (Some(id), false) => crate::tauri::search_library_references(id, &query).await, (None, true) => crate::tauri::list_references().await, (None, false) => crate::tauri::search_references(&query).await, }; match result { Ok(items) => list_items.set(items), Err(e) => { leptos::logging::error!("load publications: {e}"); list_items.set(vec![]); } } list_cursor.set(0); }); }); // Load full reference when list cursor moves. Effect::new(move |_| { let ref_id: Option = selected_ref_id.get(); spawn_local(async move { let loaded = match ref_id { Some(id) => crate::tauri::get_reference(&id).await.ok(), None => None, }; detail_ref.set(loaded); }); }); // ── Keymap wiring ───────────────────────────────────────────────────────── Effect::new(move |_| { let Some(ev) = keymap_action.get() else { return }; // ACTION_OPEN in the List or Detail pane opens a PDF tab for the selected reference. if ev.name == actions::ACTION_OPEN && matches!(focused.get_untracked(), Pane::List | Pane::Detail) { if let Some(ref_id) = selected_ref_id.get_untracked() { let title = detail_ref.with_untracked(|r| { r.as_ref() .map(|r| r.cite_key.clone()) .unwrap_or_else(|| ref_id.to_string()) }); if let Some(ctx) = use_context::() { ctx.0.set(Some(crate::PdfOpenRequest { ref_id: ref_id.to_string(), title, })); } } return; } handle_action( &ev, focused, TreeState { cursor: tree_cursor, rows: tree_rows, expanded }, ListState { cursor: list_cursor, items: list_items }, ); }); // ── View ────────────────────────────────────────────────────────────────── view! {
} } // ── Action handler ──────────────────────────────────────────────────────────── struct TreeState { cursor: RwSignal, rows: Memo>, expanded: RwSignal>, } struct ListState { cursor: RwSignal, items: RwSignal>, } fn handle_action( ev: &ActionEvent, focused: RwSignal, tree: TreeState, list: ListState, ) { let count = (ev.count as usize).max(1); let cur = focused.get_untracked(); match ev.name.as_str() { // ── Focus switching ─────────────────────────────────────────────────── actions::FOCUS_LEFT => focused.set(Pane::Tree), actions::FOCUS_CENTER => focused.set(Pane::List), actions::FOCUS_RIGHT => focused.set(Pane::Detail), actions::FOCUS_NEXT => focused.update(|p| *p = p.next()), actions::FOCUS_PREV => focused.update(|p| *p = p.prev()), // ── Tree navigation ─────────────────────────────────────────────────── actions::NAV_DOWN if cur == Pane::Tree => { let len = tree.rows.with_untracked(Vec::len); if len > 0 { tree.cursor.update(|c| *c = (*c + count).min(len - 1)); } } actions::NAV_UP if cur == Pane::Tree => { tree.cursor.update(|c| *c = c.saturating_sub(count)); } actions::NAV_TOP if cur == Pane::Tree => tree.cursor.set(0), actions::NAV_BOTTOM if cur == Pane::Tree => { let len = tree.rows.with_untracked(Vec::len); if len > 0 { tree.cursor.set(len - 1); } } actions::NAV_PAGE_DOWN if cur == Pane::Tree => { let len = tree.rows.with_untracked(Vec::len); if len > 0 { tree.cursor.update(|c| *c = (*c + 10 * count).min(len - 1)); } } actions::NAV_PAGE_UP if cur == Pane::Tree => { tree.cursor.update(|c| *c = c.saturating_sub(10 * count)); } // ── Tree expand / collapse ──────────────────────────────────────────── n if (n == actions::TREE_EXPAND || n == actions::ACTION_OPEN) && cur == Pane::Tree => { tree_expand(tree.cursor, tree.rows, tree.expanded); } actions::TREE_COLLAPSE if cur == Pane::Tree => { tree_collapse(tree.cursor, tree.rows, tree.expanded); } actions::TREE_TOGGLE if cur == Pane::Tree => { let id = tree.rows.with_untracked(|rows| { rows.get(tree.cursor.get_untracked()).map(|r| r.id.clone()) }); if let Some(id) = id { let is_open = tree.expanded.with_untracked(|e| e.contains(&id)); if is_open { tree.expanded.update(|e| { e.remove(&id); }); } else { tree.expanded.update(|e| { e.insert(id.clone()); }); // Child loading is handled reactively by LibraryTree's Effect. } } } // ── List navigation ─────────────────────────────────────────────────── actions::NAV_DOWN if cur == Pane::List => { let len = list.items.with_untracked(Vec::len); if len > 0 { list.cursor.update(|c| *c = (*c + count).min(len - 1)); } } actions::NAV_UP if cur == Pane::List => { list.cursor.update(|c| *c = c.saturating_sub(count)); } actions::NAV_TOP if cur == Pane::List => list.cursor.set(0), actions::NAV_BOTTOM if cur == Pane::List => { let len = list.items.with_untracked(Vec::len); if len > 0 { list.cursor.set(len - 1); } } actions::NAV_PAGE_DOWN if cur == Pane::List => { let len = list.items.with_untracked(Vec::len); if len > 0 { list.cursor.update(|c| *c = (*c + 10 * count).min(len - 1)); } } actions::NAV_PAGE_UP if cur == Pane::List => { list.cursor.update(|c| *c = c.saturating_sub(10 * count)); } _ => {} } } // ── Tree helpers ────────────────────────────────────────────────────────────── fn tree_expand( cursor: RwSignal, rows: Memo>, expanded: RwSignal>, ) { let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone())); if let Some(id) = id { expanded.update(|e| { e.insert(id.clone()); }); // Child loading is handled reactively by LibraryTree's Effect. } } fn tree_collapse( cursor: RwSignal, rows: Memo>, expanded: RwSignal>, ) { let id = rows.with_untracked(|r| r.get(cursor.get_untracked()).map(|row| row.id.clone())); if let Some(id) = id { expanded.update(|e| { e.remove(&id); }); } } // ── Tests ────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use brittle_keymap::actions; use leptos::prelude::*; fn ev(name: &str) -> ActionEvent { ActionEvent { name: name.to_owned(), count: 1, seq: 0 } } fn ev_count(name: &str, count: u32) -> ActionEvent { ActionEvent { name: name.to_owned(), count, seq: 0 } } fn make_tree_rows(n: usize) -> Vec { (0..n) .map(|i| TreeRow { id: format!("row-{i}"), name: format!("Row {i}"), depth: 0, expanded: false, may_have_children: false, }) .collect() } fn make_ref_summaries(n: usize) -> Vec { use brittle_model::{EntryType, ReferenceId}; (0..n) .map(|_| ReferenceSummary { id: ReferenceId::new(), cite_key: "x".into(), entry_type: EntryType::Misc, title: None, authors: vec![], year: None, }) .collect() } // Set up signals and call handle_action in a Leptos reactive owner. // Returns the signal values after the action. fn run(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F) where F: FnOnce(RwSignal, RwSignal, RwSignal, RwSignal>), { let owner = Owner::new(); owner.with(|| { let focused = RwSignal::new(focused_init); let tree_cursor = RwSignal::new(0usize); let list_cursor = RwSignal::new(0usize); let expanded = RwSignal::new(HashSet::::new()); let rows_data = make_tree_rows(tree_len); let rows = Memo::new(move |_| rows_data.clone()); let list_items = RwSignal::new(make_ref_summaries(list_len)); handle_action( ev, focused, TreeState { cursor: tree_cursor, rows, expanded }, ListState { cursor: list_cursor, items: list_items }, ); check(focused, tree_cursor, list_cursor, expanded); }); } // ── Focus switching ──────────────────────────────────────────────────────── #[test] fn focus_left_sets_tree() { run(Pane::List, 0, 0, &ev(actions::FOCUS_LEFT), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::Tree); }); } #[test] fn focus_center_sets_list() { run(Pane::Tree, 0, 0, &ev(actions::FOCUS_CENTER), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::List); }); } #[test] fn focus_right_sets_detail() { run(Pane::Tree, 0, 0, &ev(actions::FOCUS_RIGHT), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::Detail); }); } #[test] fn focus_next_cycles_forward() { run(Pane::Tree, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::List); }); run(Pane::List, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::Detail); }); run(Pane::Detail, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::Tree); }); } #[test] fn focus_prev_cycles_backward() { run(Pane::Tree, 0, 0, &ev(actions::FOCUS_PREV), |f, _, _, _| { assert_eq!(f.get_untracked(), Pane::Detail); }); } // ── Tree navigation ──────────────────────────────────────────────────────── #[test] fn nav_down_advances_tree_cursor() { run(Pane::Tree, 5, 0, &ev(actions::NAV_DOWN), |_, tc, _, _| { assert_eq!(tc.get_untracked(), 1); }); } #[test] fn nav_down_clamps_at_tree_end() { let owner = Owner::new(); owner.with(|| { let focused = RwSignal::new(Pane::Tree); let tree_cursor = RwSignal::new(4usize); // already at last row let expanded = RwSignal::new(HashSet::::new()); let rows_data = make_tree_rows(5); let rows = Memo::new(move |_| rows_data.clone()); let list_cursor = RwSignal::new(0usize); let list_items = RwSignal::new(vec![]); handle_action( &ev(actions::NAV_DOWN), focused, TreeState { cursor: tree_cursor, rows, expanded }, ListState { cursor: list_cursor, items: list_items }, ); assert_eq!(tree_cursor.get_untracked(), 4); // stayed at 4 }); } #[test] fn nav_up_decrements_tree_cursor() { let owner = Owner::new(); owner.with(|| { let focused = RwSignal::new(Pane::Tree); let tree_cursor = RwSignal::new(3usize); let expanded = RwSignal::new(HashSet::::new()); let rows_data = make_tree_rows(5); let rows = Memo::new(move |_| rows_data.clone()); handle_action( &ev(actions::NAV_UP), focused, TreeState { cursor: tree_cursor, rows, expanded }, ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, ); assert_eq!(tree_cursor.get_untracked(), 2); }); } #[test] fn nav_up_clamps_at_zero() { run(Pane::Tree, 5, 0, &ev(actions::NAV_UP), |_, tc, _, _| { assert_eq!(tc.get_untracked(), 0); // already 0, stays 0 }); } #[test] fn nav_top_jumps_tree_to_start() { let owner = Owner::new(); owner.with(|| { let focused = RwSignal::new(Pane::Tree); let tree_cursor = RwSignal::new(4usize); let expanded = RwSignal::new(HashSet::::new()); let rows_data = make_tree_rows(5); let rows = Memo::new(move |_| rows_data.clone()); handle_action( &ev(actions::NAV_TOP), focused, TreeState { cursor: tree_cursor, rows, expanded }, ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, ); assert_eq!(tree_cursor.get_untracked(), 0); }); } #[test] fn nav_bottom_jumps_tree_to_end() { run(Pane::Tree, 5, 0, &ev(actions::NAV_BOTTOM), |_, tc, _, _| { assert_eq!(tc.get_untracked(), 4); }); } #[test] fn nav_down_with_count_advances_multiple() { run(Pane::Tree, 10, 0, &ev_count(actions::NAV_DOWN, 3), |_, tc, _, _| { assert_eq!(tc.get_untracked(), 3); }); } // ── List navigation ──────────────────────────────────────────────────────── #[test] fn nav_down_advances_list_cursor_when_focused() { run(Pane::List, 0, 5, &ev(actions::NAV_DOWN), |_, _, lc, _| { assert_eq!(lc.get_untracked(), 1); }); } #[test] fn nav_down_on_tree_does_not_advance_list_cursor() { run(Pane::Tree, 3, 3, &ev(actions::NAV_DOWN), |_, tc, lc, _| { assert_eq!(tc.get_untracked(), 1); // tree moved assert_eq!(lc.get_untracked(), 0); // list unchanged }); } #[test] fn nav_bottom_jumps_list_to_last_item() { run(Pane::List, 0, 5, &ev(actions::NAV_BOTTOM), |_, _, lc, _| { assert_eq!(lc.get_untracked(), 4); }); } // ── Tree expand/collapse ─────────────────────────────────────────────────── #[test] fn tree_expand_action_inserts_id_into_expanded() { run(Pane::Tree, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| { assert!(exp.with_untracked(|e| e.contains("row-0"))); }); } #[test] fn tree_collapse_action_removes_id_from_expanded() { let owner = Owner::new(); owner.with(|| { let focused = RwSignal::new(Pane::Tree); let tree_cursor = RwSignal::new(0usize); let expanded = RwSignal::new(HashSet::from_iter(["row-0".to_string()])); let rows_data = make_tree_rows(3); let rows = Memo::new(move |_| rows_data.clone()); handle_action( &ev(actions::TREE_COLLAPSE), focused, TreeState { cursor: tree_cursor, rows, expanded }, ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) }, ); assert!(!expanded.with_untracked(|e| e.contains("row-0"))); }); } #[test] fn tree_actions_are_no_ops_when_list_focused() { run(Pane::List, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| { assert!(exp.with_untracked(|e| e.is_empty())); }); } }