Skip to main content

kimun_notes/components/
dir_browser.rs

1//! Directory-only browser state shared by the Preferences screen and the
2//! Onboarding screen. Pure navigation state — each host renders it and
3//! routes keys itself.
4
5use std::path::PathBuf;
6
7use ratatui::widgets::ListState;
8
9pub struct FileBrowserState {
10    pub current_path: PathBuf,
11    pub entries: Vec<PathBuf>,
12    pub list_state: ListState,
13    pub has_parent: bool,
14    last_jump_char: Option<char>,
15}
16
17impl FileBrowserState {
18    pub fn load(path: PathBuf) -> Self {
19        let has_parent = path.parent().is_some();
20        let mut entries: Vec<PathBuf> = std::fs::read_dir(&path)
21            .into_iter()
22            .flatten()
23            .flatten()
24            .map(|e| e.path())
25            .filter(|p| p.is_dir())
26            .collect();
27        entries.sort();
28        let total = entries.len() + if has_parent { 1 } else { 0 };
29        let mut list_state = ListState::default();
30        if total > 0 {
31            list_state.select(Some(0));
32        }
33        Self {
34            current_path: path,
35            entries,
36            list_state,
37            has_parent,
38            last_jump_char: None,
39        }
40    }
41
42    pub fn navigate_into(&mut self, entry: PathBuf) {
43        *self = Self::load(entry);
44    }
45
46    pub fn go_up(&mut self) {
47        if let Some(parent) = self.current_path.parent() {
48            *self = Self::load(parent.to_path_buf());
49        }
50    }
51
52    pub fn jump_to_char(&mut self, c: char) {
53        let c_lower = c.to_lowercase().next().unwrap_or(c);
54        let offset = if self.has_parent { 1 } else { 0 };
55        let total = self.entries.len();
56        if total == 0 {
57            return;
58        }
59
60        // If same char as last jump, cycle to next match.
61        let start = if self.last_jump_char == Some(c_lower) {
62            let cur = self.list_state.selected().unwrap_or(0);
63            if cur >= offset { cur - offset + 1 } else { 0 }
64        } else {
65            0
66        };
67
68        // Search from start, wrapping around.
69        for i in 0..total {
70            let idx = (start + i) % total;
71            if let Some(name) = self.entries[idx].file_name().and_then(|n| n.to_str())
72                && name.to_lowercase().starts_with(c_lower)
73            {
74                self.list_state.select(Some(idx + offset));
75                self.last_jump_char = Some(c_lower);
76                return;
77            }
78        }
79        self.last_jump_char = None;
80    }
81
82    /// Create `name` as a subdirectory of `current_path` and navigate into it.
83    /// Returns the created path. The directory is created immediately (the
84    /// browser must be able to enter it) — the only place onboarding touches
85    /// the filesystem before Finish.
86    pub fn create_dir(&mut self, name: &str) -> Result<PathBuf, String> {
87        let name = name.trim();
88        if name.is_empty() {
89            return Err("directory name is empty".to_string());
90        }
91        // Same cross-platform rules as workspace and note names — rejects
92        // separators, '..', Windows-reserved names, trailing dots, etc.
93        kimun_core::nfs::filename::validate_filename(name).map_err(|e| e.to_string())?;
94        let target = self.current_path.join(name);
95        std::fs::create_dir_all(&target).map_err(|e| e.to_string())?;
96        self.navigate_into(target.clone());
97        Ok(target)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn create_dir_creates_enters_and_lists_in_parent() {
107        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_{}", std::process::id()));
108        std::fs::create_dir_all(&tmp).unwrap();
109        let mut fb = FileBrowserState::load(tmp.clone());
110
111        let created = fb.create_dir("my-notes").unwrap();
112        assert_eq!(created, tmp.join("my-notes"));
113        assert!(created.is_dir());
114        assert_eq!(fb.current_path, created);
115
116        fb.go_up();
117        assert!(fb.entries.iter().any(|e| e == &created));
118
119        std::fs::remove_dir_all(&tmp).ok();
120    }
121
122    #[test]
123    fn create_dir_rejects_empty_and_separator_names() {
124        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_e_{}", std::process::id()));
125        std::fs::create_dir_all(&tmp).unwrap();
126        let mut fb = FileBrowserState::load(tmp.clone());
127        assert!(fb.create_dir("").is_err());
128        assert!(fb.create_dir("   ").is_err());
129        assert!(fb.create_dir("a/b").is_err());
130        assert!(fb.create_dir("a\\b").is_err());
131        std::fs::remove_dir_all(&tmp).ok();
132    }
133
134    #[test]
135    fn create_dir_rejects_cross_platform_invalid_names() {
136        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_x_{}", std::process::id()));
137        std::fs::create_dir_all(&tmp).unwrap();
138        let mut fb = FileBrowserState::load(tmp.clone());
139        // '..' would "create" the parent and navigate up a level.
140        assert!(fb.create_dir("..").is_err());
141        assert!(fb.create_dir(".").is_err());
142        // Windows-invalid even though Linux would accept them.
143        assert!(fb.create_dir("notes:").is_err());
144        assert!(fb.create_dir("con").is_err());
145        assert!(fb.create_dir("notes.").is_err());
146        assert_eq!(fb.current_path, tmp, "rejected names must not navigate");
147        std::fs::remove_dir_all(&tmp).ok();
148    }
149}