kimun_notes/components/
dir_browser.rs1use 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 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 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 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 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 assert!(fb.create_dir("..").is_err());
141 assert!(fb.create_dir(".").is_err());
142 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}