1use std::{
2 cmp::Ordering,
3 path::{Path, PathBuf},
4};
5
6use basalt_core::obsidian::{Note, VaultEntry};
7use ratatui::widgets::ListState;
8
9use crate::config::{Symbols, Theme};
10
11use super::Item;
12
13#[derive(Debug, Default, Copy, Clone, PartialEq)]
14pub enum Sort {
15 #[default]
16 Asc,
17 Desc,
18}
19
20#[derive(Debug, Default, Copy, Clone, PartialEq)]
21pub enum Visibility {
22 Hidden,
23 #[default]
24 Visible,
25 FullWidth,
26}
27
28#[derive(Debug, Default, Clone, PartialEq)]
29pub struct ExplorerState {
30 pub(crate) title: String,
31 pub(crate) selected_note: Option<Note>,
32 pub(crate) selected_item_index: Option<usize>,
33 pub(crate) selected_item_path: Option<PathBuf>,
34 pub(crate) items: Vec<Item>,
35 pub(crate) flat_items: Vec<(Item, usize)>,
36 pub(crate) visibility: Visibility,
37 pub(crate) active: bool,
38 pub(crate) sort: Sort,
39 pub(crate) list_state: ListState,
40
41 pub(crate) symbols: Symbols,
42 pub(crate) theme: Theme,
43
44 pub(crate) editing: bool,
45}
46
47impl ExplorerState {
48 pub fn set_theme(&mut self, theme: &Theme) {
49 self.theme = *theme;
50 }
51}
52
53fn calculate_offset(row: usize, items_count: usize, window_height: usize) -> usize {
80 let half = window_height / 2;
81
82 if row + half > items_count.saturating_sub(1) {
83 items_count.saturating_sub(window_height)
84 } else {
85 row.saturating_sub(half)
86 }
87}
88
89pub fn flatten(sort: Sort, depth: usize) -> impl Fn(&Item) -> Vec<(Item, usize)> {
90 move |item| match item {
91 Item::File { .. } => vec![(item.clone(), depth)],
92 Item::Directory {
93 expanded: true,
94 items,
95 ..
96 } => [(item.clone(), depth)]
97 .into_iter()
98 .chain({
99 let mut items = items.clone();
100 items.sort_by(sort_items_by(sort));
101 items
102 .iter()
103 .flat_map(flatten(sort, depth + 1))
104 .collect::<Vec<_>>()
105 })
106 .collect(),
107 Item::Directory {
108 expanded: false, ..
109 } => [(item.clone(), depth)].to_vec(),
110 }
111}
112
113fn sort_items_by(sort: Sort) -> impl Fn(&Item, &Item) -> Ordering {
114 move |a, b| match (a.is_dir(), b.is_dir()) {
115 (true, false) => Ordering::Less,
116 (false, true) => Ordering::Greater,
117 (true, true) => natord::compare(a.name(), b.name()),
118 _ => {
119 let a = a.name().to_lowercase();
120 let b = b.name().to_lowercase();
121 match sort {
122 Sort::Asc => natord::compare(&a, &b),
123 Sort::Desc => natord::compare(&b, &a),
124 }
125 }
126 }
127}
128
129impl ExplorerState {
130 pub fn new(title: &str, items: Vec<VaultEntry>, symbols: &Symbols) -> Self {
131 let items: Vec<Item> = items.into_iter().map(|entry| entry.into()).collect();
132 let sort = Sort::default();
133
134 let mut state = ExplorerState {
135 title: title.to_string(),
136 sort,
137 active: true,
138 visibility: Visibility::Visible,
139 selected_item_index: None,
140 selected_item_path: None,
141 selected_note: None,
142 symbols: symbols.clone(),
143 list_state: ListState::default().with_selected(Some(0)),
144 ..Default::default()
145 };
146
147 state.flatten_with_items(&items);
148 state
149 }
150
151 pub fn set_active(&mut self, active: bool) {
152 self.active = active;
153 }
154
155 fn map_to_item(&self, depth: usize, entry: VaultEntry) -> Item {
156 match entry {
157 VaultEntry::Directory {
158 name,
159 path,
160 entries,
161 } => {
162 let expanded = self
163 .flat_items
164 .iter()
165 .find_map(|(item, _)| match item {
166 Item::Directory {
167 path: item_path,
168 expanded,
169 ..
170 } if &path == item_path => Some(*expanded),
171 _ => None,
172 })
173 .unwrap_or(false);
174
175 Item::Directory {
176 name,
177 path,
178 expanded,
179 depth,
180 items: entries
181 .into_iter()
182 .map(|entry| self.map_to_item(depth + 1, entry))
183 .collect(),
184 }
185 }
186 VaultEntry::File(note) => Item::File { note, depth },
187 }
188 }
189
190 pub fn with_entries(&mut self, entries: Vec<VaultEntry>, select: Option<PathBuf>) {
191 self.rebuild(entries);
192
193 if let Some(index) = select.as_deref().and_then(|path| self.index_of(path)) {
194 self.list_state.select(Some(index));
195 self.selected_item_index = Some(index);
196 self.selected_item_path = select;
197 }
198 }
199
200 pub fn refresh_entries(&mut self, entries: Vec<VaultEntry>) {
203 let cursor = self.current_item().map(|item| item.path().to_path_buf());
204 let picked = self.selected_item_path.clone();
205
206 self.rebuild(entries);
207
208 if let Some(index) = cursor.as_deref().and_then(|path| self.index_of(path)) {
209 self.list_state.select(Some(index));
210 }
211 self.selected_item_index = picked.as_deref().and_then(|path| self.index_of(path));
212 self.selected_item_path = picked;
213 }
214
215 fn rebuild(&mut self, entries: Vec<VaultEntry>) {
216 let items: Vec<Item> = entries
217 .into_iter()
218 .map(|entry| self.map_to_item(0, entry))
219 .collect();
220 self.flatten_with_items(&items);
221 }
222
223 fn index_of(&self, path: &Path) -> Option<usize> {
224 self.flat_items
225 .iter()
226 .position(|(item, _)| item.path() == path)
227 }
228
229 pub fn reveal_path(&mut self, path: &Path) {
230 if self.select_path(path) {
231 return;
232 }
233
234 let items: Vec<Item> = path
235 .ancestors()
236 .skip(1)
237 .fold(self.items.clone(), |items, dir| {
238 items
239 .iter()
240 .map(|item| Self::toggle_item_in_tree(item, dir, true))
241 .collect()
242 });
243
244 self.flatten_with_items(&items);
245 self.select_path(path);
246 }
247
248 fn select_path(&mut self, path: &Path) -> bool {
249 if let Some(index) = self
250 .flat_items
251 .iter()
252 .position(|(item, _)| item.path() == path)
253 {
254 self.list_state.select(Some(index));
255 self.selected_item_index.replace(index);
256 self.selected_item_path.replace(path.to_path_buf());
257 true
258 } else {
259 false
260 }
261 }
262
263 pub fn hide_pane(&mut self) {
264 match self.visibility {
265 Visibility::FullWidth => self.visibility = Visibility::Visible,
266 Visibility::Visible => self.visibility = Visibility::Hidden,
267 _ => {}
268 }
269 }
270
271 pub fn expand_pane(&mut self) {
272 match self.visibility {
273 Visibility::Hidden => self.visibility = Visibility::Visible,
274 Visibility::Visible => self.visibility = Visibility::FullWidth,
275 _ => {}
276 }
277 }
278
279 pub fn toggle(&mut self) {
280 if self.is_open() {
281 self.visibility = Visibility::Hidden;
282 } else {
283 self.visibility = Visibility::Visible;
284 }
285 }
286
287 pub fn flatten_with_sort(&mut self, sort: Sort) {
288 let mut items = self.items.clone();
289 items.sort_by(sort_items_by(sort));
290
291 self.flat_items = items.iter().flat_map(flatten(sort, 0)).collect();
292 self.items = items;
293 self.sort = sort;
294 }
295
296 pub fn flatten_with_items(&mut self, items: &[Item]) {
297 let mut items = items.to_vec();
298 items.sort_by(sort_items_by(self.sort));
299
300 self.flat_items = items.iter().flat_map(flatten(self.sort, 0)).collect();
301 self.items = items.to_vec();
302 }
303
304 pub fn sort(&mut self) {
305 let sort = match self.sort {
306 Sort::Asc => Sort::Desc,
307 Sort::Desc => Sort::Asc,
308 };
309
310 self.flatten_with_sort(sort)
311 }
312
313 pub fn update_offset_mut(&mut self, window_height: usize) -> &Self {
314 if !self.items.is_empty() {
315 let idx = self.list_state.selected().unwrap_or_default();
316 let items_count = self.items.len();
317
318 let offset = calculate_offset(idx, items_count, window_height);
319
320 let list_state = &mut self.list_state;
321 *list_state.offset_mut() = offset;
322 }
323
324 self
325 }
326
327 fn toggle_item_in_tree(item: &Item, identifier: &Path, always_open: bool) -> Item {
328 let item = item.clone();
329
330 match item {
331 Item::Directory {
332 expanded,
333 path,
334 name,
335 items,
336 depth,
337 } => {
338 let expanded = if path == identifier {
339 if always_open {
340 true
341 } else {
342 !expanded
343 }
344 } else {
345 expanded
346 };
347
348 Item::Directory {
349 name,
350 path,
351 expanded,
352 depth,
353 items: items
354 .iter()
355 .map(|child| Self::toggle_item_in_tree(child, identifier, always_open))
356 .collect(),
357 }
358 }
359 _ => item,
360 }
361 }
362
363 pub fn open(&mut self) -> Option<bool> {
366 let selected_item_index = self.list_state.selected()?;
367 let current_item = self.flat_items.get(selected_item_index)?;
368
369 match current_item {
370 (Item::Directory { path, .. }, _) => {
371 let items: Vec<Item> = self
372 .items
373 .iter()
374 .map(|item| Self::toggle_item_in_tree(item, path, true))
375 .collect();
376
377 self.flatten_with_items(&items);
378 Some(false)
379 }
380 (Item::File { note, .. }, _) => {
381 self.selected_note = Some(note.clone());
382 self.selected_item_index = Some(selected_item_index);
383 self.selected_item_path = Some(note.path().to_path_buf());
384 Some(true)
385 }
386 }
387 }
388
389 pub fn select(&mut self) -> bool {
392 let Some(selected_item_index) = self.list_state.selected() else {
393 return false;
394 };
395
396 let Some(current_item) = self.flat_items.get(selected_item_index) else {
397 return false;
398 };
399
400 match current_item {
401 (Item::Directory { path, .. }, _) => {
402 let items: Vec<Item> = self
403 .items
404 .clone()
405 .iter()
406 .map(|item| Self::toggle_item_in_tree(item, path, false))
407 .collect();
408
409 self.flatten_with_items(&items);
410 false
411 }
412 (Item::File { note, .. }, _) => {
413 self.selected_note = Some(note.clone());
414 self.selected_item_index = Some(selected_item_index);
415 self.selected_item_path = Some(note.path().to_path_buf());
416 true
417 }
418 }
419 }
420
421 pub fn current_item(&self) -> Option<&Item> {
422 let selected_item_index = self.list_state.selected()?;
423 self.flat_items
424 .get(selected_item_index)
425 .map(|(item, _)| item)
426 }
427
428 pub fn selected_path(&self) -> Option<PathBuf> {
429 self.selected_item_path.clone()
430 }
431
432 pub fn is_open(&self) -> bool {
433 matches!(self.visibility, Visibility::Visible | Visibility::FullWidth)
434 }
435
436 pub fn next(&mut self, amount: usize) {
437 let index = self.list_state.selected().map(|i| {
438 i.saturating_add(amount)
439 .min(self.flat_items.len().saturating_sub(1))
440 });
441
442 self.list_state.select(index);
443 }
444
445 pub fn previous(&mut self, amount: usize) {
446 let index = self.list_state.selected().map(|i| i.saturating_sub(amount));
447
448 self.list_state.select(index);
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn with_entries_preserves_nested_file_depth() {
458 let mut state = ExplorerState::default();
459
460 let entries = vec![VaultEntry::Directory {
461 name: "dir".into(),
462 path: PathBuf::from("dir"),
463 entries: vec![VaultEntry::File(Note::new_unchecked(
464 "nested",
465 &PathBuf::from("dir/nested"),
466 ))],
467 }];
468
469 state.with_entries(entries, None);
470
471 let Item::Directory { items, depth, .. } = &state.items[0] else {
472 panic!("expected directory");
473 };
474 assert_eq!(*depth, 0);
475 assert_eq!(items[0].depth(), 1, "nested file should keep its depth");
476 }
477
478 #[test]
479 fn reveal_path_moves_selection_to_matching_note() {
480 let mut state = ExplorerState::default();
481 let entries = vec![
482 VaultEntry::File(Note::new_unchecked("first", &PathBuf::from("first.md"))),
483 VaultEntry::File(Note::new_unchecked("second", &PathBuf::from("second.md"))),
484 ];
485 state.with_entries(entries, None);
486
487 state.reveal_path(Path::new("second.md"));
488 assert_eq!(state.list_state.selected(), Some(1));
489 assert_eq!(
490 state.selected_path().as_deref(),
491 Some(Path::new("second.md"))
492 );
493
494 state.reveal_path(Path::new("missing.md"));
496 assert_eq!(state.list_state.selected(), Some(1));
497 }
498
499 #[test]
500 fn reveal_path_expands_collapsed_ancestor_folder() {
501 let mut state = ExplorerState::default();
502 let entries = vec![VaultEntry::Directory {
503 name: "dir".into(),
504 path: PathBuf::from("dir"),
505 entries: vec![VaultEntry::File(Note::new_unchecked(
506 "nested",
507 &PathBuf::from("dir/nested.md"),
508 ))],
509 }];
510 state.with_entries(entries, None);
511
512 assert!(!state
514 .flat_items
515 .iter()
516 .any(|(item, _)| item.path() == Path::new("dir/nested.md")));
517
518 state.reveal_path(Path::new("dir/nested.md"));
520 assert_eq!(
521 state.selected_path().as_deref(),
522 Some(Path::new("dir/nested.md"))
523 );
524 assert!(state
525 .flat_items
526 .iter()
527 .any(|(item, _)| item.path() == Path::new("dir/nested.md")));
528 }
529}