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 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 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 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 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 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 assert!(fb.create_dir("..").is_err());
149 assert!(fb.create_dir(".").is_err());
150 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}