Files
brittle/brittle-ui/src/lib_tab.rs

688 lines
26 KiB
Rust

//! 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::<crate::KeymapAction>()
.expect("KeymapAction context missing")
.0;
let search_query = use_context::<SearchQuery>()
.map(|sq| sq.0)
.unwrap_or_else(|| RwSignal::new(String::new()));
let layout_config = use_context::<crate::LayoutContext>()
.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::<Library>::new());
let children_cache = RwSignal::new(HashMap::<String, Vec<Library>>::new());
let expanded = RwSignal::new(HashSet::<String>::new());
let tree_cursor = RwSignal::new(0usize);
// List state
let list_items = RwSignal::new(Vec::<ReferenceSummary>::new());
let list_cursor = RwSignal::new(0usize);
// Detail state
let detail_ref = RwSignal::new(Option::<Reference>::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::<crate::ReloadTrigger>().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<ReferenceId> = 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::<crate::OpenPdfContext>() {
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! {
<div class="lib-tab">
<div
class="pane pane-left"
style=move || format!("width: {}px", left_width.get())
>
<LibraryTree
root_libs=root_libs
children_cache=children_cache
expanded=expanded
cursor=tree_cursor
focused=focused
/>
</div>
<div
class="resizer"
class:resizing=move || resizing_left.get()
on:mousedown=move |ev| {
ev.prevent_default();
resizing_left.set(true);
}
/>
<div class="pane pane-center">
<PubList
items=list_items
cursor=list_cursor
focused=focused
/>
</div>
<div
class="resizer"
class:resizing=move || resizing_right.get()
on:mousedown=move |ev| {
ev.prevent_default();
resizing_right.set(true);
}
/>
<div
class="pane pane-right"
class:pane-focused=move || focused.get() == Pane::Detail
style=move || format!("width: {}px", right_width.get())
>
<PubDetail reference=detail_ref />
</div>
</div>
}
}
// ── Action handler ────────────────────────────────────────────────────────────
struct TreeState {
cursor: RwSignal<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
}
struct ListState {
cursor: RwSignal<usize>,
items: RwSignal<Vec<ReferenceSummary>>,
}
fn handle_action(
ev: &ActionEvent,
focused: RwSignal<Pane>,
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<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
) {
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<usize>,
rows: Memo<Vec<TreeRow>>,
expanded: RwSignal<HashSet<String>>,
) {
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<TreeRow> {
(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<ReferenceSummary> {
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<F>(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F)
where
F: FnOnce(RwSignal<Pane>, RwSignal<usize>, RwSignal<usize>, RwSignal<HashSet<String>>),
{
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::<String>::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::<String>::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::<String>::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::<String>::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()));
});
}
}