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        // An unreadable — or unusable — directory renders as an empty one, so
21        // the browser stays navigable. It is logged rather than swallowed: a
22        // path Windows rejects as non-absolute (`/`, no drive prefix) looks
23        // exactly like a directory that happens to hold no subdirectories.
24        let listing = kimun_core::SystemPath::try_absolute(&path)
25            .and_then(|dir| kimun_core::system::read_dir(&dir))
26            .unwrap_or_else(|e| {
27                tracing::warn!("cannot browse {}: {e}", path.display());
28                Vec::new()
29            });
30        let mut entries: Vec<PathBuf> = listing
31            .into_iter()
32            .map(|p| p.into_path_buf())
33            .filter(|p| p.is_dir())
34            .collect();
35        entries.sort();
36        let total = entries.len() + if has_parent { 1 } else { 0 };
37        let mut list_state = ListState::default();
38        if total > 0 {
39            list_state.select(Some(0));
40        }
41        Self {
42            current_path: path,
43            entries,
44            list_state,
45            has_parent,
46            last_jump_char: None,
47        }
48    }
49
50    pub fn navigate_into(&mut self, entry: PathBuf) {
51        *self = Self::load(entry);
52    }
53
54    pub fn go_up(&mut self) {
55        if let Some(parent) = self.current_path.parent() {
56            *self = Self::load(parent.to_path_buf());
57        }
58    }
59
60    pub fn jump_to_char(&mut self, c: char) {
61        let c_lower = c.to_lowercase().next().unwrap_or(c);
62        let offset = if self.has_parent { 1 } else { 0 };
63        let total = self.entries.len();
64        if total == 0 {
65            return;
66        }
67
68        // If same char as last jump, cycle to next match.
69        let start = if self.last_jump_char == Some(c_lower) {
70            let cur = self.list_state.selected().unwrap_or(0);
71            if cur >= offset { cur - offset + 1 } else { 0 }
72        } else {
73            0
74        };
75
76        // Search from start, wrapping around.
77        for i in 0..total {
78            let idx = (start + i) % total;
79            if let Some(name) = self.entries[idx].file_name().and_then(|n| n.to_str())
80                && name.to_lowercase().starts_with(c_lower)
81            {
82                self.list_state.select(Some(idx + offset));
83                self.last_jump_char = Some(c_lower);
84                return;
85            }
86        }
87        self.last_jump_char = None;
88    }
89
90    /// Create `name` as a subdirectory of `current_path` and navigate into it.
91    /// Returns the created path. The directory is created immediately (the
92    /// browser must be able to enter it) — the only place onboarding touches
93    /// the filesystem before Finish.
94    pub fn create_dir(&mut self, name: &str) -> Result<PathBuf, String> {
95        let name = name.trim();
96        if name.is_empty() {
97            return Err("directory name is empty".to_string());
98        }
99        // Same cross-platform rules as workspace and note names — rejects
100        // separators, '..', Windows-reserved names, trailing dots, etc.
101        kimun_core::nfs::filename::validate_filename(name).map_err(|e| e.to_string())?;
102        let target = self.current_path.join(name);
103        kimun_core::system::create_dir(&target).map_err(|e| e.to_string())?;
104        self.navigate_into(target.clone());
105        Ok(target)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn create_dir_creates_enters_and_lists_in_parent() {
115        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_{}", std::process::id()));
116        std::fs::create_dir_all(&tmp).unwrap();
117        let mut fb = FileBrowserState::load(tmp.clone());
118
119        let created = fb.create_dir("my-notes").unwrap();
120        assert_eq!(created, tmp.join("my-notes"));
121        assert!(created.is_dir());
122        assert_eq!(fb.current_path, created);
123
124        fb.go_up();
125        assert!(fb.entries.iter().any(|e| e == &created));
126
127        std::fs::remove_dir_all(&tmp).ok();
128    }
129
130    #[test]
131    fn create_dir_rejects_empty_and_separator_names() {
132        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_e_{}", std::process::id()));
133        std::fs::create_dir_all(&tmp).unwrap();
134        let mut fb = FileBrowserState::load(tmp.clone());
135        assert!(fb.create_dir("").is_err());
136        assert!(fb.create_dir("   ").is_err());
137        assert!(fb.create_dir("a/b").is_err());
138        assert!(fb.create_dir("a\\b").is_err());
139        std::fs::remove_dir_all(&tmp).ok();
140    }
141
142    #[test]
143    fn create_dir_rejects_cross_platform_invalid_names() {
144        let tmp = std::env::temp_dir().join(format!("kimun_dirbrowser_x_{}", std::process::id()));
145        std::fs::create_dir_all(&tmp).unwrap();
146        let mut fb = FileBrowserState::load(tmp.clone());
147        // '..' would "create" the parent and navigate up a level.
148        assert!(fb.create_dir("..").is_err());
149        assert!(fb.create_dir(".").is_err());
150        // Windows-invalid even though Linux would accept them.
151        assert!(fb.create_dir("notes:").is_err());
152        assert!(fb.create_dir("con").is_err());
153        assert!(fb.create_dir("notes.").is_err());
154        assert_eq!(fb.current_path, tmp, "rejected names must not navigate");
155        std::fs::remove_dir_all(&tmp).ok();
156    }
157}