Skip to main content

kimun_notes/components/
file_list.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use kimun_core::nfs::VaultPath;
6use kimun_core::{PropertySortValue, ResultType, SearchResult};
7use ratatui::style::{Modifier, Style};
8use ratatui::widgets::ListItem;
9
10use crate::components::rich_row::RichRow;
11use crate::components::search_list::OrderFn;
12use crate::settings::icons::Icons;
13use crate::settings::themes::Theme;
14use crate::settings::{SortFieldSetting, SortOrderSetting};
15
16// ---------------------------------------------------------------------------
17// Sort options
18// ---------------------------------------------------------------------------
19
20#[derive(Clone, PartialEq, Debug)]
21pub enum SortField {
22    Name,
23    Title,
24    /// Sort by this property key (search form). Query-backed lists put it
25    /// in the query; listings order by values fetched from the index (see
26    /// [`entry_order`]).
27    Property(String),
28}
29
30#[derive(Clone, Copy, PartialEq, Debug)]
31pub enum SortOrder {
32    Ascending,
33    Descending,
34}
35
36impl From<SortFieldSetting> for SortField {
37    fn from(s: SortFieldSetting) -> Self {
38        match s {
39            SortFieldSetting::Name => Self::Name,
40            SortFieldSetting::Title => Self::Title,
41        }
42    }
43}
44
45impl From<SortOrderSetting> for SortOrder {
46    fn from(s: SortOrderSetting) -> Self {
47        match s {
48            SortOrderSetting::Ascending => Self::Ascending,
49            SortOrderSetting::Descending => Self::Descending,
50        }
51    }
52}
53
54impl From<SortField> for SortFieldSetting {
55    fn from(s: SortField) -> Self {
56        match s {
57            SortField::Name | SortField::Property(_) => Self::Name,
58            SortField::Title => Self::Title,
59        }
60    }
61}
62
63impl From<SortOrder> for SortOrderSetting {
64    fn from(s: SortOrder) -> Self {
65        match s {
66            SortOrder::Ascending => Self::Ascending,
67            SortOrder::Descending => Self::Descending,
68        }
69    }
70}
71
72impl SortField {
73    pub fn label(&self) -> String {
74        match self {
75            Self::Name => "N".to_string(),
76            Self::Title => "T".to_string(),
77            Self::Property(key) => key.clone(),
78        }
79    }
80
81    /// Next field in the dialog's cycle. `allow_property` comes from the
82    /// list's `SortableList::allows_property`.
83    pub fn cycle(&self, allow_property: bool) -> Self {
84        match self {
85            Self::Name => Self::Title,
86            Self::Title if allow_property => Self::Property(String::new()),
87            Self::Title | Self::Property(_) => Self::Name,
88        }
89    }
90}
91
92impl SortOrder {
93    pub fn label(self) -> char {
94        match self {
95            Self::Ascending => '↑',
96            Self::Descending => '↓',
97        }
98    }
99
100    pub fn toggle(self) -> Self {
101        match self {
102            Self::Ascending => Self::Descending,
103            Self::Descending => Self::Ascending,
104        }
105    }
106}
107
108/// Each note's value for a property sort, as fetched from the index by
109/// `NoteVault::property_sort_values`. Notes without the key are absent.
110pub type PropertyValues = Arc<HashMap<VaultPath, PropertySortValue>>;
111
112/// The row order for a listing sorted by `field` / `order`. `Up` always comes
113/// first, then directories when `group_dirs` is on. A property sort puts the
114/// rows with a value (from `values`) first, ordered by it, and everything
115/// without one — directories, attachments, notes missing the key or not yet
116/// indexed, or every row while `values` is `None` — after them in name order,
117/// in both directions. Grouped directories sort among themselves by name.
118pub fn entry_order(
119    field: SortField,
120    order: SortOrder,
121    group_dirs: bool,
122    values: Option<PropertyValues>,
123) -> OrderFn<FileListEntry> {
124    let directed = move |ord: Ordering| match order {
125        SortOrder::Ascending => ord,
126        SortOrder::Descending => ord.reverse(),
127    };
128    Arc::new(move |a: &FileListEntry, b: &FileListEntry| {
129        let rank = |e: &FileListEntry| match e {
130            FileListEntry::Up { .. } => 0,
131            FileListEntry::Directory { .. } if group_dirs => 1,
132            _ => 2,
133        };
134        rank(a).cmp(&rank(b)).then_with(|| {
135            let by_name = || a.sort_key(&field).cmp(&b.sort_key(&field));
136            if !matches!(field, SortField::Property(_)) {
137                return directed(by_name());
138            }
139            let value = |e: &FileListEntry| match e {
140                FileListEntry::Note { path, .. } => values.as_ref().and_then(|v| v.get(path)),
141                _ => None,
142            };
143            match (value(a), value(b)) {
144                (Some(x), Some(y)) => directed(x.cmp(y)).then_with(by_name),
145                (Some(_), None) => Ordering::Less,
146                (None, Some(_)) => Ordering::Greater,
147                (None, None) => by_name(),
148            }
149        })
150    })
151}
152
153// ---------------------------------------------------------------------------
154// FileListEntry
155// ---------------------------------------------------------------------------
156
157#[derive(Clone)]
158pub enum FileListEntry {
159    Up {
160        parent: VaultPath,
161    },
162    Note {
163        path: VaultPath,
164        title: String,
165        filename: String,
166        journal_date: Option<String>,
167        /// `true` when this is the note currently open in the editor. Drives the
168        /// open-note marker (accent glyph). Stamped by the sidebar after each
169        /// load; always `false` from the row source and on non-sidebar surfaces.
170        is_open: bool,
171    },
172    Directory {
173        path: VaultPath,
174        name: String,
175    },
176    Attachment {
177        path: VaultPath,
178        filename: String,
179    },
180    CreateNote {
181        filename: String,
182        path: VaultPath,
183    },
184}
185
186impl FileListEntry {
187    pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
188        let filename = result.path.get_parent_path().1;
189        match result.rtype {
190            ResultType::Note(data) => Self::Note {
191                path: result.path,
192                title: Self::display_title(data.title),
193                filename,
194                journal_date,
195                is_open: false,
196            },
197            ResultType::Directory => Self::Directory {
198                path: result.path,
199                name: filename,
200            },
201            ResultType::Attachment => Self::Attachment {
202                path: result.path,
203                filename,
204            },
205        }
206    }
207
208    /// Map a raw note title to its display form, substituting a placeholder
209    /// for an empty/whitespace title. Shared by listing construction and the
210    /// sidebar's live title updates so they never diverge.
211    pub fn display_title(raw: String) -> String {
212        if raw.trim().is_empty() {
213            "<no title>".to_string()
214        } else {
215            raw
216        }
217    }
218
219    pub fn path(&self) -> &VaultPath {
220        match self {
221            Self::Up { parent } => parent,
222            Self::Note { path, .. } => path,
223            Self::Directory { path, .. } => path,
224            Self::Attachment { path, .. } => path,
225            Self::CreateNote { path, .. } => path,
226        }
227    }
228
229    /// Sort key for the given field.
230    pub(crate) fn sort_key(&self, field: &SortField) -> String {
231        match self {
232            Self::Up { .. } => String::new(),
233            Self::Note {
234                title, filename, ..
235            } => match field {
236                SortField::Title => title.to_lowercase(),
237                // A property sort's rows without a value fall back to the
238                // file name (see `entry_order`).
239                SortField::Name | SortField::Property(_) => filename.to_lowercase(),
240            },
241            Self::Directory { name, .. } => name.to_lowercase(),
242            Self::Attachment { filename, .. } => filename.to_lowercase(),
243            Self::CreateNote { filename, .. } => filename.to_lowercase(),
244        }
245    }
246
247    /// Terminal rows this entry occupies when rendered.
248    pub fn visual_height(&self) -> u16 {
249        match self {
250            Self::Note { journal_date, .. } => {
251                if journal_date.is_some() {
252                    3
253                } else {
254                    2
255                }
256            }
257            _ => 1,
258        }
259    }
260
261    pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
262        match self {
263            Self::Up { .. } => RichRow::new(icons.directory_up, "[UP] ..")
264                .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
265                .title_style(Style::default().fg(theme.gray.to_ratatui()))
266                .into_list_item(theme),
267            Self::Note {
268                title,
269                filename,
270                journal_date,
271                is_open,
272                ..
273            } => {
274                let glyph = if journal_date.is_some() {
275                    icons.journal
276                } else {
277                    icons.note
278                };
279                let mut row = RichRow::new(glyph, title.clone()).filename(filename.clone());
280                if *is_open {
281                    // Open-note marker: accent the type glyph (see CONTEXT.md).
282                    row = row.glyph_style(Style::default().fg(theme.accent.to_ratatui()));
283                }
284                if let Some(date) = journal_date {
285                    row = row.secondary(
286                        date.clone(),
287                        Some(Style::default().fg(theme.color_journal_date.to_ratatui())),
288                    );
289                }
290                row.into_list_item(theme)
291            }
292            Self::Directory { name, .. } => {
293                let dir_style = Style::default().fg(theme.color_directory.to_ratatui());
294                RichRow::new(icons.directory, name.clone())
295                    .glyph_style(dir_style)
296                    .title_style(dir_style)
297                    .into_list_item(theme)
298            }
299            Self::Attachment { filename, .. } => {
300                let style = Style::default()
301                    .add_modifier(Modifier::ITALIC)
302                    .fg(theme.fg_secondary.to_ratatui());
303                RichRow::new(icons.attachment, filename.clone())
304                    .glyph_style(style)
305                    .title_style(style)
306                    .into_list_item(theme)
307            }
308            Self::CreateNote { filename, .. } => {
309                let style = Style::default().fg(theme.accent.to_ratatui());
310                RichRow::new("+", format!("Create: {}", filename))
311                    .glyph_style(style)
312                    .title_style(style)
313                    .into_list_item(theme)
314            }
315        }
316    }
317}
318
319impl crate::components::search_list::SearchRow for FileListEntry {
320    fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
321        // Delegate to inherent method; engine applies selection highlight via `highlight_style`.
322        FileListEntry::to_list_item(self, theme, icons)
323    }
324
325    fn visual_height(&self) -> u16 {
326        FileListEntry::visual_height(self)
327    }
328
329    fn match_text(&self) -> Option<&str> {
330        match self {
331            Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
332            // Directories participate in the fuzzy filter (matched on their
333            // name). `Up` stays exempt.
334            Self::Directory { name, .. } => Some(name),
335            _ => None,
336        }
337    }
338
339    fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
340        use crate::components::search_list::YankTarget;
341        match self {
342            // `Up` navigates to the parent and `CreateNote` names a note that
343            // does not exist yet — neither is a path worth putting on the
344            // clipboard.
345            Self::Up { .. } | Self::CreateNote { .. } => None,
346            Self::Note { path, .. }
347            | Self::Directory { path, .. }
348            | Self::Attachment { path, .. } => Some(YankTarget::path(path.to_string())),
349        }
350    }
351}
352
353#[cfg(test)]
354mod open_marker_tests {
355    use super::*;
356    use ratatui::style::Style;
357    use ratatui::text::{Line, Span, Text};
358    use ratatui::widgets::ListItem;
359
360    #[test]
361    fn display_title_substitutes_placeholder_for_empty() {
362        assert_eq!(
363            FileListEntry::display_title("   ".to_string()),
364            "<no title>"
365        );
366        assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
367    }
368
369    /// Build a `FileListEntry::Note` with the given `is_open` flag and call
370    /// `to_list_item`, then compare the resulting `ListItem` against one whose
371    /// first line's glyph span carries the expected fg color.
372    ///
373    /// `ListItem` derives `PartialEq` (comparing the inner `Text` and item-level
374    /// `Style`).  `Text` / `Line` / `Span` all have public fields, so the
375    /// comparison reaches down to `span.style.fg` without needing private access
376    /// to `ListItem::content`.
377    fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
378        let theme = Theme::default();
379        let icons = Icons::new(false);
380        let note = FileListEntry::Note {
381            path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
382            title: "A".to_string(),
383            filename: "a.md".to_string(),
384            journal_date: None,
385            is_open,
386        };
387        // Build the expected glyph span using the same logic to_list_item uses,
388        // then verify by comparing the whole ListItem via PartialEq.
389        let fg = theme.fg.to_ratatui();
390        let accent = theme.accent.to_ratatui();
391        let glyph_style = if is_open {
392            Style::default().fg(accent)
393        } else {
394            Style::default().fg(fg)
395        };
396        let title_style = Style::default().fg(fg);
397        let secondary_style = Style::default()
398            .fg(theme.fg_secondary.to_ratatui())
399            .add_modifier(ratatui::style::Modifier::ITALIC);
400
401        let expected_lines = vec![
402            Line::from(vec![
403                Span::styled(format!("{} ", icons.note), glyph_style),
404                Span::styled("A", title_style),
405            ]),
406            Line::from(Span::styled("  a.md", secondary_style)),
407        ];
408        let expected = ListItem::new(Text::from(expected_lines));
409        let actual = note.to_list_item(&theme, &icons);
410        assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
411        // Return the color for the simpler assertions below.
412        glyph_style.fg.expect("glyph style must have an fg color")
413    }
414
415    #[test]
416    fn open_note_glyph_is_accent_colored() {
417        let theme = Theme::default();
418        let accent = theme.accent.to_ratatui();
419        let actual_fg = glyph_fg_of_note(true);
420        assert_eq!(
421            actual_fg, accent,
422            "is_open=true: glyph span fg should be theme.accent"
423        );
424    }
425
426    #[test]
427    fn closed_note_glyph_is_not_accent_colored() {
428        let theme = Theme::default();
429        let accent = theme.accent.to_ratatui();
430        let actual_fg = glyph_fg_of_note(false);
431        assert_ne!(
432            actual_fg, accent,
433            "is_open=false: glyph span fg should NOT be theme.accent"
434        );
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::components::search_list::SearchRow;
442    use std::collections::HashMap;
443    use std::sync::Arc;
444
445    #[test]
446    fn sort_field_cycle_gates_property() {
447        assert_eq!(SortField::Name.cycle(false), SortField::Title);
448        assert_eq!(SortField::Title.cycle(false), SortField::Name);
449        assert_eq!(
450            SortField::Property(String::new()).cycle(false),
451            SortField::Name
452        );
453        let p = SortField::Name.cycle(true).cycle(true);
454        assert_eq!(p, SortField::Property(String::new()));
455        assert_eq!(p.cycle(true), SortField::Name);
456    }
457
458    fn note(name: &str) -> FileListEntry {
459        FileListEntry::Note {
460            path: VaultPath::note_path_from(name),
461            title: name.to_string(),
462            filename: format!("{name}.md"),
463            journal_date: None,
464            is_open: false,
465        }
466    }
467
468    fn dir(name: &str) -> FileListEntry {
469        FileListEntry::Directory {
470            path: VaultPath::new(name),
471            name: name.to_string(),
472        }
473    }
474
475    fn names(rows: &[FileListEntry]) -> Vec<String> {
476        rows.iter()
477            .map(|r| match r {
478                FileListEntry::Up { .. } => "..".to_string(),
479                FileListEntry::Note { title, .. } => title.clone(),
480                FileListEntry::Directory { name, .. } => format!("{name}/"),
481                FileListEntry::Attachment { filename, .. } => filename.clone(),
482                FileListEntry::CreateNote { filename, .. } => filename.clone(),
483            })
484            .collect()
485    }
486
487    /// `rows` sorted by a property sort on `values`.
488    fn sorted(order: SortOrder, group_dirs: bool, values: Option<PropertyValues>) -> Vec<String> {
489        let mut rows = vec![
490            note("delta"),
491            dir("zdir"),
492            note("alpha"),
493            FileListEntry::Attachment {
494                path: VaultPath::new("pic.png"),
495                filename: "pic.png".into(),
496            },
497            note("charlie"),
498            FileListEntry::Up {
499                parent: VaultPath::root(),
500            },
501            dir("adir"),
502            note("bravo"),
503        ];
504        let cmp = entry_order(
505            SortField::Property("rank".into()),
506            order,
507            group_dirs,
508            values,
509        );
510        rows.sort_by(|a, b| cmp(a, b));
511        names(&rows)
512    }
513
514    fn rank_values() -> PropertyValues {
515        use kimun_core::PropertySortValue::{Number, Text};
516        Arc::new(HashMap::from([
517            (VaultPath::note_path_from("delta"), Number(1.0)),
518            (VaultPath::note_path_from("bravo"), Number(5.0)),
519            (VaultPath::note_path_from("charlie"), Text("x".into())),
520        ]))
521    }
522
523    #[test]
524    fn property_order_ascending_puts_missing_last_in_name_order() {
525        assert_eq!(
526            sorted(SortOrder::Ascending, false, Some(rank_values())),
527            [
528                "..", "delta", "bravo", "charlie", "adir/", "alpha", "pic.png", "zdir/"
529            ]
530        );
531    }
532
533    #[test]
534    fn property_order_descending_still_puts_missing_last() {
535        assert_eq!(
536            sorted(SortOrder::Descending, false, Some(rank_values())),
537            [
538                "..", "charlie", "bravo", "delta", "adir/", "alpha", "pic.png", "zdir/"
539            ]
540        );
541    }
542
543    #[test]
544    fn property_order_groups_directories_first_by_name() {
545        assert_eq!(
546            sorted(SortOrder::Descending, true, Some(rank_values())),
547            [
548                "..", "adir/", "zdir/", "charlie", "bravo", "delta", "alpha", "pic.png"
549            ]
550        );
551    }
552
553    #[test]
554    fn property_order_without_values_is_name_order() {
555        assert_eq!(
556            sorted(SortOrder::Descending, false, None),
557            [
558                "..", "adir/", "alpha", "bravo", "charlie", "delta", "pic.png", "zdir/"
559            ]
560        );
561    }
562
563    #[test]
564    fn name_order_still_follows_the_direction() {
565        let mut rows = vec![note("alpha"), dir("bdir"), note("charlie")];
566        let cmp = entry_order(SortField::Name, SortOrder::Descending, false, None);
567        rows.sort_by(|a, b| cmp(a, b));
568        assert_eq!(names(&rows), ["charlie", "bdir/", "alpha"]);
569    }
570
571    #[test]
572    fn directory_match_text_is_some_name() {
573        let dir = FileListEntry::Directory {
574            path: VaultPath::note_path_from("projects"),
575            name: "projects".to_string(),
576        };
577        assert_eq!(SearchRow::match_text(&dir), Some("projects"));
578    }
579
580    #[test]
581    fn up_match_text_is_none() {
582        let up = FileListEntry::Up {
583            parent: VaultPath::root(),
584        };
585        assert_eq!(SearchRow::match_text(&up), None);
586    }
587
588    #[test]
589    fn sort_field_setting_roundtrip() {
590        use crate::settings::SortFieldSetting;
591        assert_eq!(
592            SortFieldSetting::from(SortField::Name),
593            SortFieldSetting::Name
594        );
595        assert_eq!(
596            SortFieldSetting::from(SortField::Title),
597            SortFieldSetting::Title
598        );
599        assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
600    }
601
602    #[test]
603    fn sort_order_setting_roundtrip() {
604        use crate::settings::SortOrderSetting;
605        assert_eq!(
606            SortOrderSetting::from(SortOrder::Ascending),
607            SortOrderSetting::Ascending
608        );
609        assert_eq!(
610            SortOrderSetting::from(SortOrder::Descending),
611            SortOrderSetting::Descending
612        );
613    }
614}