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
280#[cfg(test)]
281mod open_marker_tests {
282    use super::*;
283    use ratatui::style::Style;
284    use ratatui::text::{Line, Span, Text};
285    use ratatui::widgets::ListItem;
286
287    #[test]
288    fn display_title_substitutes_placeholder_for_empty() {
289        assert_eq!(
290            FileListEntry::display_title("   ".to_string()),
291            "<no title>"
292        );
293        assert_eq!(FileListEntry::display_title("Real".to_string()), "Real");
294    }
295
296    /// Build a `FileListEntry::Note` with the given `is_open` flag and call
297    /// `to_list_item`, then compare the resulting `ListItem` against one whose
298    /// first line's glyph span carries the expected fg color.
299    ///
300    /// `ListItem` derives `PartialEq` (comparing the inner `Text` and item-level
301    /// `Style`).  `Text` / `Line` / `Span` all have public fields, so the
302    /// comparison reaches down to `span.style.fg` without needing private access
303    /// to `ListItem::content`.
304    fn glyph_fg_of_note(is_open: bool) -> ratatui::style::Color {
305        let theme = Theme::default();
306        let icons = Icons::new(false);
307        let note = FileListEntry::Note {
308            path: kimun_core::nfs::VaultPath::note_path_from("a.md"),
309            title: "A".to_string(),
310            filename: "a.md".to_string(),
311            journal_date: None,
312            is_open,
313        };
314        // Build the expected glyph span using the same logic to_list_item uses,
315        // then verify by comparing the whole ListItem via PartialEq.
316        let fg = theme.fg.to_ratatui();
317        let accent = theme.accent.to_ratatui();
318        let glyph_style = if is_open {
319            Style::default().fg(accent)
320        } else {
321            Style::default().fg(fg)
322        };
323        let title_style = Style::default().fg(fg);
324        let secondary_style = Style::default()
325            .fg(theme.fg_secondary.to_ratatui())
326            .add_modifier(ratatui::style::Modifier::ITALIC);
327
328        let expected_lines = vec![
329            Line::from(vec![
330                Span::styled(format!("{} ", icons.note), glyph_style),
331                Span::styled("A", title_style),
332            ]),
333            Line::from(Span::styled("  a.md", secondary_style)),
334        ];
335        let expected = ListItem::new(Text::from(expected_lines));
336        let actual = note.to_list_item(&theme, &icons);
337        assert_eq!(actual, expected, "ListItem mismatch for is_open={is_open}");
338        // Return the color for the simpler assertions below.
339        glyph_style.fg.expect("glyph style must have an fg color")
340    }
341
342    #[test]
343    fn open_note_glyph_is_accent_colored() {
344        let theme = Theme::default();
345        let accent = theme.accent.to_ratatui();
346        let actual_fg = glyph_fg_of_note(true);
347        assert_eq!(
348            actual_fg, accent,
349            "is_open=true: glyph span fg should be theme.accent"
350        );
351    }
352
353    #[test]
354    fn closed_note_glyph_is_not_accent_colored() {
355        let theme = Theme::default();
356        let accent = theme.accent.to_ratatui();
357        let actual_fg = glyph_fg_of_note(false);
358        assert_ne!(
359            actual_fg, accent,
360            "is_open=false: glyph span fg should NOT be theme.accent"
361        );
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::components::search_list::SearchRow;
369
370    #[test]
371    fn directory_match_text_is_some_name() {
372        let dir = FileListEntry::Directory {
373            path: VaultPath::note_path_from("projects"),
374            name: "projects".to_string(),
375        };
376        assert_eq!(SearchRow::match_text(&dir), Some("projects"));
377    }
378
379    #[test]
380    fn up_match_text_is_none() {
381        let up = FileListEntry::Up {
382            parent: VaultPath::root(),
383        };
384        assert_eq!(SearchRow::match_text(&up), None);
385    }
386
387    #[test]
388    fn sort_field_setting_roundtrip() {
389        use crate::settings::SortFieldSetting;
390        assert_eq!(
391            SortFieldSetting::from(SortField::Name),
392            SortFieldSetting::Name
393        );
394        assert_eq!(
395            SortFieldSetting::from(SortField::Title),
396            SortFieldSetting::Title
397        );
398        assert_eq!(SortField::from(SortFieldSetting::Title), SortField::Title);
399    }
400
401    #[test]
402    fn sort_order_setting_roundtrip() {
403        use crate::settings::SortOrderSetting;
404        assert_eq!(
405            SortOrderSetting::from(SortOrder::Ascending),
406            SortOrderSetting::Ascending
407        );
408        assert_eq!(
409            SortOrderSetting::from(SortOrder::Descending),
410            SortOrderSetting::Descending
411        );
412    }
413}