Skip to main content

kimun_notes/components/
file_list.rs

1use kimun_core::nfs::VaultPath;
2use kimun_core::{ResultType, SearchResult};
3use ratatui::style::{Modifier, Style};
4use ratatui::widgets::ListItem;
5
6use crate::components::rich_row::RichRow;
7use crate::settings::icons::Icons;
8use crate::settings::themes::Theme;
9use crate::settings::{SortFieldSetting, SortOrderSetting};
10
11// ---------------------------------------------------------------------------
12// Sort options
13// ---------------------------------------------------------------------------
14
15#[derive(Clone, Copy, PartialEq, Debug)]
16pub enum SortField {
17    Name,
18    Title,
19}
20
21#[derive(Clone, Copy, PartialEq, Debug)]
22pub enum SortOrder {
23    Ascending,
24    Descending,
25}
26
27impl From<SortFieldSetting> for SortField {
28    fn from(s: SortFieldSetting) -> Self {
29        match s {
30            SortFieldSetting::Name => Self::Name,
31            SortFieldSetting::Title => Self::Title,
32        }
33    }
34}
35
36impl From<SortOrderSetting> for SortOrder {
37    fn from(s: SortOrderSetting) -> Self {
38        match s {
39            SortOrderSetting::Ascending => Self::Ascending,
40            SortOrderSetting::Descending => Self::Descending,
41        }
42    }
43}
44
45impl From<SortField> for SortFieldSetting {
46    fn from(s: SortField) -> Self {
47        match s {
48            SortField::Name => Self::Name,
49            SortField::Title => Self::Title,
50        }
51    }
52}
53
54impl From<SortOrder> for SortOrderSetting {
55    fn from(s: SortOrder) -> Self {
56        match s {
57            SortOrder::Ascending => Self::Ascending,
58            SortOrder::Descending => Self::Descending,
59        }
60    }
61}
62
63impl SortField {
64    pub fn label(self) -> char {
65        match self {
66            Self::Name => 'N',
67            Self::Title => 'T',
68        }
69    }
70
71    pub fn cycle(self) -> Self {
72        match self {
73            Self::Name => Self::Title,
74            Self::Title => Self::Name,
75        }
76    }
77}
78
79impl SortOrder {
80    pub fn label(self) -> char {
81        match self {
82            Self::Ascending => '↑',
83            Self::Descending => '↓',
84        }
85    }
86
87    pub fn toggle(self) -> Self {
88        match self {
89            Self::Ascending => Self::Descending,
90            Self::Descending => Self::Ascending,
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// FileListEntry
97// ---------------------------------------------------------------------------
98
99#[derive(Clone)]
100pub enum FileListEntry {
101    Up {
102        parent: VaultPath,
103    },
104    Note {
105        path: VaultPath,
106        title: String,
107        filename: String,
108        journal_date: Option<String>,
109        /// `true` when this is the note currently open in the editor. Drives the
110        /// open-note marker (accent glyph). Stamped by the sidebar after each
111        /// load; always `false` from the row source and on non-sidebar surfaces.
112        is_open: bool,
113    },
114    Directory {
115        path: VaultPath,
116        name: String,
117    },
118    Attachment {
119        path: VaultPath,
120        filename: String,
121    },
122    CreateNote {
123        filename: String,
124        path: VaultPath,
125    },
126}
127
128impl FileListEntry {
129    pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
130        let filename = result.path.get_parent_path().1;
131        match result.rtype {
132            ResultType::Note(data) => Self::Note {
133                path: result.path,
134                title: Self::display_title(data.title),
135                filename,
136                journal_date,
137                is_open: false,
138            },
139            ResultType::Directory => Self::Directory {
140                path: result.path,
141                name: filename,
142            },
143            ResultType::Attachment => Self::Attachment {
144                path: result.path,
145                filename,
146            },
147        }
148    }
149
150    /// Map a raw note title to its display form, substituting a placeholder
151    /// for an empty/whitespace title. Shared by listing construction and the
152    /// sidebar's live title updates so they never diverge.
153    pub fn display_title(raw: String) -> String {
154        if raw.trim().is_empty() {
155            "<no title>".to_string()
156        } else {
157            raw
158        }
159    }
160
161    pub fn path(&self) -> &VaultPath {
162        match self {
163            Self::Up { parent } => parent,
164            Self::Note { path, .. } => path,
165            Self::Directory { path, .. } => path,
166            Self::Attachment { path, .. } => path,
167            Self::CreateNote { path, .. } => path,
168        }
169    }
170
171    /// Sort key for the given field.
172    pub(crate) fn sort_key(&self, field: SortField) -> String {
173        match self {
174            Self::Up { .. } => String::new(),
175            Self::Note {
176                title, filename, ..
177            } => match field {
178                SortField::Title => title.to_lowercase(),
179                SortField::Name => filename.to_lowercase(),
180            },
181            Self::Directory { name, .. } => name.to_lowercase(),
182            Self::Attachment { filename, .. } => filename.to_lowercase(),
183            Self::CreateNote { filename, .. } => filename.to_lowercase(),
184        }
185    }
186
187    /// Terminal rows this entry occupies when rendered.
188    pub fn visual_height(&self) -> u16 {
189        match self {
190            Self::Note { journal_date, .. } => {
191                if journal_date.is_some() {
192                    3
193                } else {
194                    2
195                }
196            }
197            _ => 1,
198        }
199    }
200
201    pub fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
202        match self {
203            Self::Up { .. } => RichRow::new(icons.directory_up, "[UP] ..")
204                .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
205                .title_style(Style::default().fg(theme.gray.to_ratatui()))
206                .into_list_item(theme),
207            Self::Note {
208                title,
209                filename,
210                journal_date,
211                is_open,
212                ..
213            } => {
214                let glyph = if journal_date.is_some() {
215                    icons.journal
216                } else {
217                    icons.note
218                };
219                let mut row = RichRow::new(glyph, title.clone()).filename(filename.clone());
220                if *is_open {
221                    // Open-note marker: accent the type glyph (see CONTEXT.md).
222                    row = row.glyph_style(Style::default().fg(theme.accent.to_ratatui()));
223                }
224                if let Some(date) = journal_date {
225                    row = row.secondary(
226                        date.clone(),
227                        Some(Style::default().fg(theme.color_journal_date.to_ratatui())),
228                    );
229                }
230                row.into_list_item(theme)
231            }
232            Self::Directory { name, .. } => {
233                let dir_style = Style::default().fg(theme.color_directory.to_ratatui());
234                RichRow::new(icons.directory, name.clone())
235                    .glyph_style(dir_style)
236                    .title_style(dir_style)
237                    .into_list_item(theme)
238            }
239            Self::Attachment { filename, .. } => {
240                let style = Style::default()
241                    .add_modifier(Modifier::ITALIC)
242                    .fg(theme.fg_secondary.to_ratatui());
243                RichRow::new(icons.attachment, filename.clone())
244                    .glyph_style(style)
245                    .title_style(style)
246                    .into_list_item(theme)
247            }
248            Self::CreateNote { filename, .. } => {
249                let style = Style::default().fg(theme.accent.to_ratatui());
250                RichRow::new("+", format!("Create: {}", filename))
251                    .glyph_style(style)
252                    .title_style(style)
253                    .into_list_item(theme)
254            }
255        }
256    }
257}
258
259impl crate::components::search_list::SearchRow for FileListEntry {
260    fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
261        // Delegate to inherent method; engine applies selection highlight via `highlight_style`.
262        FileListEntry::to_list_item(self, theme, icons)
263    }
264
265    fn visual_height(&self) -> u16 {
266        FileListEntry::visual_height(self)
267    }
268
269    fn match_text(&self) -> Option<&str> {
270        match self {
271            Self::Note { filename, .. } | Self::CreateNote { filename, .. } => Some(filename),
272            // Directories participate in the fuzzy filter (matched on their
273            // name). `Up` stays exempt.
274            Self::Directory { name, .. } => Some(name),
275            _ => None,
276        }
277    }
278
279    fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
280        use crate::components::search_list::YankTarget;
281        match self {
282            // `Up` navigates to the parent and `CreateNote` names a note that
283            // does not exist yet — neither is a path worth putting on the
284            // clipboard.
285            Self::Up { .. } | Self::CreateNote { .. } => None,
286            Self::Note { path, .. }
287            | Self::Directory { path, .. }
288            | Self::Attachment { path, .. } => Some(YankTarget::path(path.to_string())),
289        }
290    }
291}
292
293#[cfg(test)]
294mod open_marker_tests {
295    use super::*;
296    use ratatui::style::Style;
297    use ratatui::text::{Line, Span, Text};
298    use ratatui::widgets::ListItem;
299
300    #[test]
301    fn display_title_substitutes_placeholder_for_empty() {
302        assert_eq!(
303            FileListEntry::display_title("   ".to_string()),
304            "<no title>"
305        );
306        assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
307    }
308
309    /// Build a `FileListEntry::Note` with the given `is_open` flag and call
310    /// `to_list_item`, then compare the resulting `ListItem` against one whose
311    /// first line's glyph span carries the expected fg color.
312    ///
313    /// `ListItem` derives `PartialEq` (comparing the inner `Text` and item-level
314    /// `Style`).  `Text` / `Line` / `Span` all have public fields, so the
315    /// comparison reaches down to `span.style.fg` without needing private access
316    /// to `ListItem::content`.
317    fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
318        let theme = Theme::default();
319        let icons = Icons::new(false);
320        let note = FileListEntry::Note {
321            path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
322            title: "A".to_string(),
323            filename: "a.md".to_string(),
324            journal_date: None,
325            is_open,
326        };
327        // Build the expected glyph span using the same logic to_list_item uses,
328        // then verify by comparing the whole ListItem via PartialEq.
329        let fg = theme.fg.to_ratatui();
330        let accent = theme.accent.to_ratatui();
331        let glyph_style = if is_open {
332            Style::default().fg(accent)
333        } else {
334            Style::default().fg(fg)
335        };
336        let title_style = Style::default().fg(fg);
337        let secondary_style = Style::default()
338            .fg(theme.fg_secondary.to_ratatui())
339            .add_modifier(ratatui::style::Modifier::ITALIC);
340
341        let expected_lines = vec![
342            Line::from(vec![
343                Span::styled(format!("{} ", icons.note), glyph_style),
344                Span::styled("A", title_style),
345            ]),
346            Line::from(Span::styled("  a.md", secondary_style)),
347        ];
348        let expected = ListItem::new(Text::from(expected_lines));
349        let actual = note.to_list_item(&theme, &icons);
350        assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
351        // Return the color for the simpler assertions below.
352        glyph_style.fg.expect("glyph style must have an fg color")
353    }
354
355    #[test]
356    fn open_note_glyph_is_accent_colored() {
357        let theme = Theme::default();
358        let accent = theme.accent.to_ratatui();
359        let actual_fg = glyph_fg_of_note(true);
360        assert_eq!(
361            actual_fg, accent,
362            "is_open=true: glyph span fg should be theme.accent"
363        );
364    }
365
366    #[test]
367    fn closed_note_glyph_is_not_accent_colored() {
368        let theme = Theme::default();
369        let accent = theme.accent.to_ratatui();
370        let actual_fg = glyph_fg_of_note(false);
371        assert_ne!(
372            actual_fg, accent,
373            "is_open=false: glyph span fg should NOT be theme.accent"
374        );
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::components::search_list::SearchRow;
382
383    #[test]
384    fn directory_match_text_is_some_name() {
385        let dir = FileListEntry::Directory {
386            path: VaultPath::note_path_from("projects"),
387            name: "projects".to_string(),
388        };
389        assert_eq!(SearchRow::match_text(&dir), Some("projects"));
390    }
391
392    #[test]
393    fn up_match_text_is_none() {
394        let up = FileListEntry::Up {
395            parent: VaultPath::root(),
396        };
397        assert_eq!(SearchRow::match_text(&up), None);
398    }
399
400    #[test]
401    fn sort_field_setting_roundtrip() {
402        use crate::settings::SortFieldSetting;
403        assert_eq!(
404            SortFieldSetting::from(SortField::Name),
405            SortFieldSetting::Name
406        );
407        assert_eq!(
408            SortFieldSetting::from(SortField::Title),
409            SortFieldSetting::Title
410        );
411        assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
412    }
413
414    #[test]
415    fn sort_order_setting_roundtrip() {
416        use crate::settings::SortOrderSetting;
417        assert_eq!(
418            SortOrderSetting::from(SortOrder::Ascending),
419            SortOrderSetting::Ascending
420        );
421        assert_eq!(
422            SortOrderSetting::from(SortOrder::Descending),
423            SortOrderSetting::Descending
424        );
425    }
426}