git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

src/pages/browse.rs (39.5K)

  1 use std::path::{Path, PathBuf};
  2 
  3 use crate::pages::PageContent;
  4 use cce_ui::context::UiContext;
  5 use cce_ui::widget::Handle;
  6 use cce_ui::widget::{Adapted, WidgetHost, Breadcrumb, PathController};
  7 use cce_ui::scene::arena::Arena;
  8 use cce_ui::scene::layout::{compute_layout, CrossAlign, LayoutBox, Length, Size, Style};
  9 
 10 // ── Data ────────────────────────────────────────────────────────────
 11 
 12 #[derive(Debug, Clone)]
 13 pub struct DirEntry {
 14     pub name: String,
 15     pub path: PathBuf,
 16     pub is_dir: bool,
 17     pub size: u64,
 18     pub permissions: u32,
 19     pub modified: String,
 20     /// For trash listings only: the path this item restores to, read from its
 21     /// .trashinfo by `read_directory_internal`. None everywhere else.
 22     pub origin: Option<String>,
 23 }
 24 
 25 #[derive(Debug)]
 26 pub struct BrowseState {
 27     pub current_dir: PathBuf,
 28     pub all_entries: Vec<DirEntry>,
 29     pub entries: Vec<DirEntry>,
 30     pub show_hidden: bool,
 31     pub list: crate::row_list::RowList,
 32     pub search_visible: bool,
 33     pub search_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
 34     pub selected: Option<usize>,
 35     /// The selection the list was last auto-scrolled to. The layout pass runs
 36     /// every frame, and an unconditional scroll_into_view there UNDID every
 37     /// wheel scroll on the next frame whenever a selection existed — which in
 38     /// the chooser is always, since row 0 starts selected. Auto-scroll fires
 39     /// on selection CHANGE only.
 40     pub autoscrolled_to: Option<usize>,
 41     /// A just-created folder to select once a refresh lists it.
 42     pub pending_select: Option<PathBuf>,
 43     pub breadcrumb: Handle<Adapted<Breadcrumb>>,
 44     pub save_name_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
 45 }
 46 
 47 impl BrowseState {
 48     /// The page's state, its widgets inserted into `ctx`.
 49     pub fn new(ctx: &mut UiContext) -> Self {
 50         let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
 51         let initial_dir = PathBuf::from(home);
 52         let mut breadcrumb = Breadcrumb::new();
 53         breadcrumb.set_network_opacity(0.95);
 54         let mut state = Self {
 55             current_dir: initial_dir,
 56             all_entries: Vec::new(),
 57             entries: Vec::new(),
 58             show_hidden: false,
 59             list: crate::row_list::RowList::new(cce_ui::layout::button_height(), 2.0),
 60             search_visible: false,
 61             search_box: ctx.insert(cce_ui::widget::TextBox::new(String::new())
 62                 .with_placeholder("Search...")
 63                 .with_update_on_type(true)),
 64             selected: None,
 65             autoscrolled_to: None,
 66             pending_select: None,
 67             breadcrumb: ctx.insert(breadcrumb),
 68             save_name_box: ctx.insert(cce_ui::widget::TextBox::new(String::new()).with_max_width(None)),
 69         };
 70         state.update_breadcrumb(ctx);
 71         state
 72     }
 73 }
 74 
 75 impl BrowseState {
 76     /// Path of the currently selected entry, if any.
 77     pub fn selected_path(&self) -> Option<PathBuf> {
 78         self.selected.and_then(|idx| self.entries.get(idx).map(|e| e.path.clone()))
 79     }
 80 
 81     pub fn update_breadcrumb(&mut self, ctx: &mut UiContext) {
 82         let mut segments = Vec::new();
 83         for component in self.current_dir.components() {
 84             let s = component.as_os_str().to_string_lossy().to_string();
 85             if s != "/" && !s.is_empty() {
 86                 segments.push(s);
 87             }
 88         }
 89         ctx[self.breadcrumb].set_path(&segments);
 90     }
 91 }
 92 
 93 #[derive(Debug, Clone)]
 94 pub enum BrowseMessage {
 95     SearchChanged(String),
 96     SelectEntry(usize),
 97     NavigateTo(usize),
 98     NavigateToPath(PathBuf),
 99     DirectoryLoaded(PathBuf, Vec<DirEntry>),
100     DirectoryRefreshed(PathBuf, Vec<DirEntry>),
101     ToggleHidden,
102     /// Delete = move to trash — except inside the trash itself, where the
103     /// update arm degrades it to a permanent delete (re-trashing a trashed
104     /// row would orphan its .trashinfo).
105     DeleteEntry(usize),
106     DeleteEntryPermanent(usize),
107     RestoreEntry(usize),
108     EmptyTrash,
109     /// Create a new folder in the current directory.
110     NewFolder,
111     /// (parent dir, the created folder or the error).
112     FolderCreated(PathBuf, Result<PathBuf, String>),
113     /// Rename the entry at this path to the given bare name, in place.
114     RenameEntry(PathBuf, String),
115     /// (old path, the new path or the error).
116     Renamed(PathBuf, Result<PathBuf, String>),
117     Deleted(PathBuf, Result<(), String>),
118     TrashEmptied(Result<(), String>),
119     LastDirLoaded(Option<PathBuf>),
120 }
121 
122 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123 pub enum BrowseNavigation {
124     Up,
125     Down,
126 }
127 
128 /// Whether `path` is a cce-designer project — a directory holding a `state.json`, which is
129 /// the only shape the designer writes (and the only one it loads).
130 ///
131 /// A project dir is deliberately NOT navigable: it opens in the designer instead. So this
132 /// must not match ordinary directories — it used to accept a `state.kdl` too, which made
133 /// `~/.config/cce/cce-designer/` (the designer's own settings live there, in a state.kdl)
134 /// impossible to enter. No project format ever wrote that file.
135 pub fn is_project_dir(path: &Path) -> bool {
136     path.is_dir() && path.join("state.json").exists()
137 }
138 
139 /// Reconstruct the ancestor path for a clicked breadcrumb segment index.
140 /// `seg == 0` is the root `/`; each subsequent index adds one path component.
141 pub fn path_to_segment(current_dir: &Path, seg: usize) -> PathBuf {
142     let mut target_path = PathBuf::new();
143     let mut current_idx = 0;
144     for component in current_dir.components() {
145         target_path.push(component);
146         if component == std::path::Component::RootDir {
147             if seg == 0 {
148                 break;
149             }
150         } else {
151             current_idx += 1;
152             if current_idx == seg {
153                 break;
154             }
155         }
156     }
157     target_path
158 }
159 
160 // ── Helpers ─────────────────────────────────────────────────────────
161 
162 /// The target path for renaming `path` to `new_name`, or `Ok(None)` when the
163 /// name is unchanged. Only bare names are accepted — a rename never moves an
164 /// entry to another directory.
165 pub fn validate_new_name(path: &Path, new_name: &str) -> Result<Option<PathBuf>, String> {
166     if new_name.is_empty() || new_name == "." || new_name == ".." {
167         return Err(format!("\"{new_name}\" is not a valid name"));
168     }
169     if new_name.contains('/') || new_name.contains('\0') {
170         return Err("names cannot contain '/'".to_string());
171     }
172     if path.file_name().is_some_and(|n| n == new_name) {
173         return Ok(None);
174     }
175     let parent = path.parent().ok_or_else(|| "cannot rename the root".to_string())?;
176     Ok(Some(parent.join(new_name)))
177 }
178 
179 pub fn read_directory(path: &Path) -> Vec<DirEntry> {
180     crate::services::fs::read_directory_internal(path)
181 }
182 
183 use crate::util::{format_size, format_permissions};
184 
185 /// The cce-icons glyph a row wears for its entry, by kind and extension.
186 fn entry_icon(is_dir: bool, name: &str) -> &'static str {
187     if is_dir {
188         return "folder";
189     }
190     let ext = match name.rsplit_once('.') {
191         Some((_, ext)) => ext.to_ascii_lowercase(),
192         None => return "file",
193     };
194     match ext.as_str() {
195         "png" | "jpg" | "jpeg" | "svg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" => "file-image",
196         "mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" => "file-audio",
197         "mp4" | "mkv" | "avi" | "webm" | "mov" => "file-video",
198         "zip" | "tar" | "gz" | "bz2" | "xz" | "zst" | "7z" | "rar" | "tgz" => "file-archive",
199         "rs" | "py" | "sh" | "bash" | "zsh" | "c" | "h" | "cpp" | "hpp" | "js" | "ts" | "go" | "lua" | "wgsl" => "file-code",
200         _ => "file",
201     }
202 }
203 
204 pub fn next_selection_index(state: &BrowseState, direction: BrowseNavigation) -> Option<usize> {
205     let len = state.entries.len();
206     if len == 0 {
207         return None;
208     }
209     match (state.selected, direction) {
210         (Some(index), BrowseNavigation::Up) => Some(index.saturating_sub(1)),
211         (Some(index), BrowseNavigation::Down) => Some((index + 1).min(len - 1)),
212         (None, BrowseNavigation::Up) => Some(len - 1),
213         (None, BrowseNavigation::Down) => Some(0),
214     }
215 }
216 
217 // ── View ────────────────────────────────────────────────────────────
218 
219 pub fn view(state: &mut BrowseState, cx: f32, cy: f32, cw: f32, ch: f32, select_mode: bool, ctx: &mut cce_ui::context::UiContext) -> PageContent {
220     let mut pc = PageContent::new();
221     let text_dim = cce_ui::color::TEXT_DIM;
222 
223 
224     // The pane rect already sits root_plate_inset off the window edge — no
225     // second inset here, or the list lands 16+12 from the edge while apps
226     // that place content at the pane rect (cce-data-editor's tree) sit at 16.
227     // The page is a column on the root plate: the breadcrumb, the list taking what is
228     // left, and in select mode the name box under it, a root gap between each.
229     let breadcrumb_h = crate::pages::breadcrumb_h();
230     let textbox_h = cce_ui::layout::textbox_height();
231     let mut arena: Arena<LayoutBox> = Arena::new();
232     let column = arena.insert(LayoutBox::container(
233         Style::column().gap(cce_ui::layout::root_plate_gap()).cross_align(CrossAlign::Stretch),
234     ));
235     let fixed = |h: f32| LayoutBox::leaf(Style { height: Length::Fixed(h), ..Style::default() }, Size::new(0.0, h));
236     let crumb = arena.insert(fixed(breadcrumb_h));
237     let list = arena.insert(LayoutBox::leaf(Style::default().grow(1.0), Size::ZERO));
238     arena.append_child(column, crumb);
239     arena.append_child(column, list);
240     let name_box = select_mode.then(|| {
241         let b = arena.insert(fixed(textbox_h));
242         arena.append_child(column, b);
243         b
244     });
245     compute_layout(&mut arena, column, Size::new(cw, ch));
246     let at = |id| {
247         let r = arena.value(id).expect("laid out").rect;
248         (cx + r.x, cy + r.y, r.width, r.height)
249     };
250 
251     // 1. The Breadcrumb, where every page puts it.
252     let (bx, by, bw, _) = at(crumb);
253     crate::pages::breadcrumb_header(&mut pc, state.breadcrumb, bx, by, bw, ctx);
254 
255     // 2. The list (ScrollBox).
256     let (list_x, list_y, list_w, list_h) = at(list);
257 
258     // Update List columns dynamically based on list width
259     let in_trash = crate::services::trash::is_trash_files_dir(&state.current_dir);
260     let show_size = !in_trash && list_w > 400.0;
261     let show_perm = !in_trash && list_w > 480.0;
262     let show_modified = !in_trash && list_w > 280.0;
263     // Trash listing: the metadata columns yield to where the item restores to.
264     let origin_col_w = if in_trash { (list_w * 0.55).min(460.0).max(160.0) } else { 0.0 };
265 
266     let mut cols = vec![
267         crate::row_list::ListColumn {
268             name: "Name".to_string(),
269             width: crate::row_list::ColumnWidth::Flex,
270             justification: cce_ui::widget::Justification::Left,
271         }
272     ];
273     if in_trash {
274         cols.push(crate::row_list::ListColumn {
275             name: "Original Location".to_string(),
276             width: crate::row_list::ColumnWidth::RightOffset(origin_col_w),
277             justification: cce_ui::widget::Justification::Left,
278         });
279     }
280     if show_size {
281         cols.push(crate::row_list::ListColumn {
282             name: "Size".to_string(),
283             width: crate::row_list::ColumnWidth::RightOffset(290.0),
284             justification: cce_ui::widget::Justification::Left,
285         });
286     }
287     if show_perm {
288         cols.push(crate::row_list::ListColumn {
289             name: "Permissions".to_string(),
290             width: crate::row_list::ColumnWidth::RightOffset(210.0),
291             justification: cce_ui::widget::Justification::Left,
292         });
293     }
294     if show_modified {
295         cols.push(crate::row_list::ListColumn {
296             name: "Modified".to_string(),
297             width: crate::row_list::ColumnWidth::RightOffset(120.0),
298             justification: cce_ui::widget::Justification::Left,
299         });
300     }
301     state.list.columns = cols;
302 
303     // Populate rows
304     state.list.rows = state.entries.iter().enumerate().map(|(idx, entry)| {
305         let size_str = if entry.is_dir {
306             "—".to_string()
307         } else {
308             format_size(entry.size)
309         };
310         let perm_str = format_permissions(entry.permissions);
311 
312         let mut cells = vec![entry.name.clone()];
313         if in_trash {
314             // Front-truncate: the deep end of the path is the informative end,
315             // and the list's own truncation cuts the tail. Undershoot the
316             // list's secondary-cell char estimate so it never re-truncates.
317             let origin = entry.origin.as_deref().unwrap_or("—");
318             let cell_size = (cce_ui::layout::list_font_parsed().1 - 1.0).max(8.0);
319             let budget = (((origin_col_w - 8.0) / (cell_size * 0.65)) as usize).saturating_sub(1).max(4);
320             let n = origin.chars().count();
321             let cell = if n > budget {
322                 let tail: String = origin.chars().skip(n - budget.saturating_sub(1)).collect();
323                 format!("…{tail}")
324             } else {
325                 origin.to_string()
326             };
327             cells.push(cell);
328         }
329         if show_size {
330             cells.push(size_str);
331         }
332         if show_perm {
333             cells.push(perm_str);
334         }
335         if show_modified {
336             cells.push(entry.modified.clone());
337         }
338 
339         crate::row_list::Row {
340             cells,
341             icon: Some(entry_icon(entry.is_dir, &entry.name)),
342             selected: state.selected == Some(idx),
343         }
344     }).collect();
345 
346     // Dissolved List (Phase 6z): scroll state, rows, and frame prims are app-owned.
347     // When the search strip is open it reserves the bottom of the frame, exactly as
348     // the legacy List::set_rect carved its scroll frame. The search field is
349     // the toolkit's textbox height, like the chooser's name box below.
350     let search_h = textbox_h;
351     // The strip stands off the well's rim (and the rows above it) by the
352     // pane rung, on both axes.
353     let search_pad = cce_ui::layout::plate_padding();
354     let search_margin_y = search_pad;
355     let search_offset = if state.search_visible { search_h + 2.0 * search_margin_y } else { 0.0 };
356     state.list.set_rect(list_x, list_y, list_w, list_h, search_offset);
357     state.list.update_bounds_from_rows();
358 
359     // Auto-scroll to keep a NEWLY selected row in view (keyboard nav, click).
360     // Not every frame: this pass runs per frame, and re-asserting the scroll
361     // for an unchanged selection reverted every wheel scroll immediately.
362     if state.selected != state.autoscrolled_to {
363         if let Some(selected_idx) = state.selected {
364             state.list.scroll_into_view(selected_idx);
365         }
366         state.autoscrolled_to = state.selected;
367     }
368 
369     state.list.push_prims(&mut pc);
370     if state.search_visible {
371         let (sx, sy, sw, sh) = (
372             list_x + search_pad,
373             list_y + list_h - search_offset + search_margin_y,
374             list_w - 2.0 * search_pad,
375             search_h,
376         );
377         cce_ui::layout::render_widget_h(&mut pc, state.search_box, sx, sy, sw, sh, ctx);
378         // The TextBox's recessed well lives in its modern paint(); the flat view
379         // this host renders through loses it, so carve it here.
380         pc.relief_recessed(sx, sy, sw, sh, cce_ui::layout::textbox_corner_radius());
381     }
382 
383     // Count label in the bottom right corner of the scrolling list
384     let count_str = format!(
385         "{} items{}",
386         state.entries.len(),
387         if state.show_hidden { " (.)" } else { "" }
388     );
389     // TODO(style): the 24 keeps the estimated-width label clear of the
390     // list's scrollbar as well as the rim; the 18 is a text baseline drop.
391     let count_text_w = count_str.len() as f32 * 6.0;
392     let count_x = list_x + list_w - count_text_w - 24.0;
393     let count_y = list_y + list_h - 18.0;
394     pc.text(&count_str, count_x, count_y, 11.0, text_dim);
395 
396     // 3. Allocate and render Textbox(es)
397     if let Some(name_box) = name_box {
398         let (tx, ty, tw, th) = at(name_box);
399         ctx[state.save_name_box].set_row_rect(tx, tw);
400         cce_ui::layout::render_widget_h(&mut pc, state.save_name_box, tx, ty, tw, th, ctx);
401         pc.relief_recessed(tx, ty, tw, th, cce_ui::layout::textbox_corner_radius());
402     }
403 
404     pc
405 }
406 
407 // ── Update ──────────────────────────────────────────────────────────
408 
409 fn apply_filters(state: &mut BrowseState, ctx: &UiContext) {
410     let search_box = &ctx[state.search_box];
411     let search_query = if search_box.editing {
412         search_box.edit_buffer.to_lowercase()
413     } else {
414         search_box.text.to_lowercase()
415     };
416     state.entries = state
417         .all_entries
418         .iter()
419         .filter(|e| state.show_hidden || !e.name.starts_with('.'))
420         .filter(|e| {
421             if search_query.is_empty() {
422                 return true;
423             }
424             e.name.to_lowercase().contains(&search_query)
425         })
426         .cloned()
427         .collect();
428     state.selected = if state.entries.is_empty() { None } else { Some(0) };
429 }
430 
431 pub fn update(state: &mut BrowseState, msg: BrowseMessage, ctx: &mut UiContext) -> Option<crate::services::fs::FsRequest> {
432     match msg {
433         BrowseMessage::SearchChanged(q) => {
434             ctx[state.search_box].text = q;
435             apply_filters(state, ctx);
436             None
437         }
438         BrowseMessage::SelectEntry(i) => {
439             state.selected = Some(i);
440             None
441         }
442         BrowseMessage::NavigateTo(idx) => {
443             if let Some(entry) = state.entries.get(idx) {
444                 if entry.is_dir && !is_project_dir(&entry.path) {
445                     return Some(crate::services::fs::FsRequest::ReadDirectory(entry.path.clone()));
446                 }
447             }
448             None
449         }
450         BrowseMessage::NavigateToPath(path) => {
451             Some(crate::services::fs::FsRequest::ReadDirectory(path))
452         }
453         BrowseMessage::DirectoryLoaded(path, entries) => {
454             state.current_dir = path.clone();
455             state.all_entries = entries;
456             state.pending_select = None;
457             ctx[state.search_box].text.clear();
458             ctx[state.search_box].edit_buffer.clear();
459             state.search_visible = false;
460             apply_filters(state, ctx);
461             state.update_breadcrumb(ctx);
462             Some(crate::services::fs::FsRequest::SaveLastDir(path))
463         }
464         BrowseMessage::DirectoryRefreshed(path, entries) => {
465             if state.current_dir == path {
466                 let selected_path = state
467                     .pending_select
468                     .take()
469                     .or_else(|| state.selected.and_then(|idx| state.entries.get(idx).map(|e| e.path.clone())));
470                 state.all_entries = entries;
471                 apply_filters(state, ctx);
472                 if let Some(path) = selected_path {
473                     state.selected = state.entries.iter().position(|e| e.path == path);
474                 }
475             }
476             None
477         }
478         BrowseMessage::ToggleHidden => {
479             state.show_hidden = !state.show_hidden;
480             apply_filters(state, ctx);
481             None
482         }
483         BrowseMessage::DeleteEntry(idx) => {
484             if let Some(entry) = state.entries.get(idx) {
485                 if crate::services::trash::is_trash_files_dir(&state.current_dir) {
486                     Some(crate::services::fs::FsRequest::DeletePath(entry.path.clone(), entry.is_dir))
487                 } else {
488                     Some(crate::services::fs::FsRequest::TrashPath(entry.path.clone()))
489                 }
490             } else {
491                 None
492             }
493         }
494         BrowseMessage::DeleteEntryPermanent(idx) => {
495             if let Some(entry) = state.entries.get(idx) {
496                 Some(crate::services::fs::FsRequest::DeletePath(entry.path.clone(), entry.is_dir))
497             } else {
498                 None
499             }
500         }
501         BrowseMessage::RestoreEntry(idx) => {
502             if let Some(entry) = state.entries.get(idx) {
503                 Some(crate::services::fs::FsRequest::RestorePath(entry.path.clone()))
504             } else {
505                 None
506             }
507         }
508         BrowseMessage::EmptyTrash => Some(crate::services::fs::FsRequest::EmptyTrash),
509         BrowseMessage::NewFolder => Some(crate::services::fs::FsRequest::CreateDir(state.current_dir.clone())),
510         BrowseMessage::FolderCreated(parent, result) => {
511             match result {
512                 Ok(path) => state.pending_select = Some(path),
513                 Err(e) => log::error!("Failed to create folder in {}: {}", parent.display(), e),
514             }
515             if state.current_dir == parent {
516                 Some(crate::services::fs::FsRequest::RefreshDirectory(parent))
517             } else {
518                 None
519             }
520         }
521         BrowseMessage::TrashEmptied(result) => {
522             if let Err(e) = result {
523                 log::error!("Failed to empty trash: {}", e);
524             }
525             Some(crate::services::fs::FsRequest::ReadDirectory(state.current_dir.clone()))
526         }
527         BrowseMessage::RenameEntry(path, new_name) => {
528             match validate_new_name(&path, &new_name) {
529                 Ok(Some(to)) => Some(crate::services::fs::FsRequest::RenamePath(path, to)),
530                 Ok(None) => None,
531                 Err(e) => {
532                     log::error!("Cannot rename {}: {}", path.display(), e);
533                     None
534                 }
535             }
536         }
537         BrowseMessage::Renamed(from, result) => {
538             match result {
539                 Ok(to) => state.pending_select = Some(to),
540                 Err(e) => log::error!("Failed to rename {}: {}", from.display(), e),
541             }
542             Some(crate::services::fs::FsRequest::RefreshDirectory(state.current_dir.clone()))
543         }
544         BrowseMessage::Deleted(path, result) => {
545             match result {
546                 Ok(_) => {
547                     state.all_entries.retain(|e| e.path != path);
548                     state.entries.retain(|e| e.path != path);
549                     if state.entries.is_empty() {
550                         state.selected = None;
551                     } else if let Some(idx) = state.selected {
552                         state.selected = Some(idx.min(state.entries.len() - 1));
553                     }
554                 }
555                 Err(e) => {
556                     log::error!("Failed to delete {}: {}", path.display(), e);
557                 }
558             }
559             Some(crate::services::fs::FsRequest::ReadDirectory(state.current_dir.clone()))
560         }
561         BrowseMessage::LastDirLoaded(last_dir) => {
562             let path = last_dir.unwrap_or_else(|| {
563                 let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
564                 PathBuf::from(home)
565             });
566             Some(crate::services::fs::FsRequest::ReadDirectory(path))
567         }
568     }
569 }
570 
571 #[cfg(test)]
572 mod tests {
573     use super::*;
574 
575     /// Every row wears a cce-icons glyph by NAME — never a character — and
576     /// the kind decides which.
577     #[test]
578     fn entry_icon_names_a_glyph_by_kind() {
579         let mut ui = UiContext::new();
580         assert_eq!(entry_icon(true, "photos.png"), "folder");
581         assert_eq!(entry_icon(false, "Shot.PNG"), "file-image");
582         assert_eq!(entry_icon(false, "song.flac"), "file-audio");
583         assert_eq!(entry_icon(false, "clip.webm"), "file-video");
584         assert_eq!(entry_icon(false, "src.tar.gz"), "file-archive");
585         assert_eq!(entry_icon(false, "main.rs"), "file-code");
586         assert_eq!(entry_icon(false, "Cargo.toml"), "file");
587         assert_eq!(entry_icon(false, "README"), "file");
588         for (dir, name) in [(true, "a"), (false, "a.png"), (false, "a.mp3"), (false, "a.mp4"), (false, "a.zip"), (false, "a.py"), (false, "a")] {
589             let glyph = entry_icon(dir, name);
590             assert!(glyph.chars().all(|c| c.is_ascii_lowercase() || c == '-'), "{glyph:?} is not a glyph name");
591         }
592     }
593 
594     fn state_with_count(ui: &mut UiContext, count: usize) -> BrowseState {
595         BrowseState {
596             entries: (0..count)
597                 .map(|i| DirEntry {
598                     name: format!("file_{i}"),
599                     path: PathBuf::from(format!("/tmp/file_{i}")),
600                     is_dir: i % 3 == 0,
601                     size: 1024 * i as u64,
602                     permissions: 0o644,
603                     modified: String::new(),
604                     origin: None,
605                 })
606                 .collect(),
607             ..BrowseState::new(ui)
608         }
609     }
610 
611     #[test]
612     fn down_from_none_selects_first_entry() {
613         let mut ui = UiContext::new();
614         let state = state_with_count(&mut ui, 3);
615         assert_eq!(next_selection_index(&state, BrowseNavigation::Down), Some(0));
616     }
617 
618     #[test]
619     fn up_from_none_selects_last_entry() {
620         let mut ui = UiContext::new();
621         let state = state_with_count(&mut ui, 3);
622         assert_eq!(next_selection_index(&state, BrowseNavigation::Up), Some(2));
623     }
624 
625     #[test]
626     fn navigation_stays_in_bounds() {
627         let mut ui = UiContext::new();
628         let mut state = state_with_count(&mut ui, 3);
629 
630         state.selected = Some(0);
631         assert_eq!(next_selection_index(&state, BrowseNavigation::Up), Some(0));
632 
633         state.selected = Some(2);
634         assert_eq!(next_selection_index(&state, BrowseNavigation::Down), Some(2));
635     }
636 
637     #[test]
638     fn apply_filters_hides_dotfiles() {
639         let mut ui = UiContext::new();
640         let mut state = BrowseState::new(&mut ui);
641         state.all_entries = vec![
642             DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
643             DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
644         ];
645         state.show_hidden = false;
646         apply_filters(&mut state, &ui);
647         assert_eq!(state.entries.len(), 1);
648         assert_eq!(state.entries[0].name, "visible");
649     }
650 
651     #[test]
652     fn apply_filters_shows_dotfiles_when_enabled() {
653         let mut ui = UiContext::new();
654         let mut state = BrowseState::new(&mut ui);
655         state.all_entries = vec![
656             DirEntry { name: ".hidden".into(), path: PathBuf::from("/a/.hidden"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
657             DirEntry { name: "visible".into(), path: PathBuf::from("/a/visible"), is_dir: false, size: 0, permissions: 0o644, modified: String::new(), origin: None },
658         ];
659         state.show_hidden = true;
660         apply_filters(&mut state, &ui);
661         assert_eq!(state.entries.len(), 2);
662     }
663 
664     fn entry(name: &str, path: &str, is_dir: bool) -> DirEntry {
665         DirEntry {
666             name: name.to_string(),
667             path: PathBuf::from(path),
668             is_dir,
669             size: 0,
670             permissions: 0o644,
671             modified: String::new(),
672             origin: None,
673         }
674     }
675 
676     #[test]
677     fn search_changed_filters_entries() {
678         let mut ui = UiContext::new();
679         let mut state = BrowseState::new(&mut ui);
680         state.all_entries = vec![entry("apple", "/a/apple", false), entry("banana", "/a/banana", false)];
681         let req = update(&mut state, BrowseMessage::SearchChanged("ban".to_string()), &mut ui);
682         assert!(req.is_none());
683         assert_eq!(state.entries.len(), 1);
684         assert_eq!(state.entries[0].name, "banana");
685         assert_eq!(state.selected, Some(0));
686     }
687 
688     #[test]
689     fn navigate_to_path_reads_directory() {
690         let mut ui = UiContext::new();
691         let mut state = BrowseState::new(&mut ui);
692         let req = update(&mut state, BrowseMessage::NavigateToPath(PathBuf::from("/tmp")), &mut ui);
693         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/tmp")));
694     }
695 
696     #[test]
697     fn navigate_to_plain_directory_reads_it() {
698         let mut ui = UiContext::new();
699         let dir = std::env::temp_dir().join(format!("cce_nav_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
700         std::fs::create_dir_all(&dir).unwrap();
701         let mut state = BrowseState::new(&mut ui);
702         state.entries = vec![entry("sub", dir.to_str().unwrap(), true)];
703         let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
704         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
705         let _ = std::fs::remove_dir_all(&dir);
706     }
707 
708     #[test]
709     fn navigate_to_project_directory_does_not_enter() {
710         let mut ui = UiContext::new();
711         let dir = std::env::temp_dir().join(format!("cce_proj_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
712         std::fs::create_dir_all(&dir).unwrap();
713         std::fs::write(dir.join("state.json"), "{}").unwrap();
714         let mut state = BrowseState::new(&mut ui);
715         state.entries = vec![entry("proj", dir.to_str().unwrap(), true)];
716         let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
717         assert!(req.is_none());
718         let _ = std::fs::remove_dir_all(&dir);
719     }
720 
721     #[test]
722     fn navigate_to_file_does_nothing() {
723         let mut ui = UiContext::new();
724         let mut state = BrowseState::new(&mut ui);
725         state.entries = vec![entry("f.txt", "/a/f.txt", false)];
726         let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
727         assert!(req.is_none());
728     }
729 
730     #[test]
731     fn directory_loaded_populates_and_saves() {
732         let mut ui = UiContext::new();
733         let mut state = BrowseState::new(&mut ui);
734         let entries = vec![entry("x", "/d/x", false), entry("y", "/d/y", true)];
735         let req = update(&mut state, BrowseMessage::DirectoryLoaded(PathBuf::from("/d"), entries), &mut ui);
736         assert_eq!(state.current_dir, PathBuf::from("/d"));
737         assert_eq!(state.entries.len(), 2);
738         assert!(matches!(req, Some(crate::services::fs::FsRequest::SaveLastDir(p)) if p == PathBuf::from("/d")));
739     }
740 
741     #[test]
742     fn directory_refreshed_preserves_selection_by_path() {
743         let mut ui = UiContext::new();
744         let mut state = BrowseState::new(&mut ui);
745         state.current_dir = PathBuf::from("/d");
746         state.all_entries = vec![entry("a", "/d/a", false), entry("b", "/d/b", false)];
747         apply_filters(&mut state, &ui);
748         state.selected = Some(1); // "b"
749         let new_entries = vec![entry("b", "/d/b", false), entry("a", "/d/a", false), entry("c", "/d/c", false)];
750         let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/d"), new_entries), &mut ui);
751         assert!(req.is_none());
752         assert_eq!(
753             state.selected.and_then(|i| state.entries.get(i)).map(|e| e.name.as_str()),
754             Some("b")
755         );
756     }
757 
758     #[test]
759     fn directory_refreshed_ignores_other_dir() {
760         let mut ui = UiContext::new();
761         let mut state = BrowseState::new(&mut ui);
762         state.current_dir = PathBuf::from("/d");
763         state.all_entries = vec![entry("a", "/d/a", false)];
764         apply_filters(&mut state, &ui);
765         let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/other"), vec![entry("z", "/other/z", false)]), &mut ui);
766         assert!(req.is_none());
767         assert_eq!(state.entries.len(), 1);
768         assert_eq!(state.entries[0].name, "a");
769     }
770 
771     #[test]
772     fn toggle_hidden_shows_dotfiles() {
773         let mut ui = UiContext::new();
774         let mut state = BrowseState::new(&mut ui);
775         state.all_entries = vec![entry(".hidden", "/a/.hidden", false), entry("visible", "/a/visible", false)];
776         apply_filters(&mut state, &ui);
777         assert_eq!(state.entries.len(), 1);
778         let req = update(&mut state, BrowseMessage::ToggleHidden, &mut ui);
779         assert!(req.is_none());
780         assert!(state.show_hidden);
781         assert_eq!(state.entries.len(), 2);
782     }
783 
784     #[test]
785     fn navigate_enters_a_dir_holding_a_settings_state_kdl() {
786         let mut ui = UiContext::new();
787         // The shape of ~/.config/cce/cce-designer: a plain directory whose only content is
788         // the designer's own state.kdl. Treating that as a project made it un-enterable.
789         let dir = std::env::temp_dir().join(format!(
790             "clear_test_navdir_{}",
791             chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
792         ));
793         std::fs::create_dir_all(&dir).unwrap();
794         std::fs::write(dir.join("state.kdl"), "graph {\n    grid_size_x (f64)71.0\n}").unwrap();
795 
796         let mut state = BrowseState::new(&mut ui);
797         state.all_entries = vec![entry("cce-designer", dir.to_str().unwrap(), true)];
798         apply_filters(&mut state, &ui);
799         let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
800         assert!(
801             matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(ref p)) if *p == dir),
802             "navigating into it should read the directory, got {req:?}",
803         );
804 
805         // A real project (state.json) still opens instead of being entered.
806         std::fs::write(dir.join("state.json"), "{}").unwrap();
807         let req = update(&mut state, BrowseMessage::NavigateTo(0), &mut ui);
808         assert!(req.is_none(), "a project dir is not navigable");
809 
810         let _ = std::fs::remove_file(dir.join("state.kdl"));
811         let _ = std::fs::remove_file(dir.join("state.json"));
812         let _ = std::fs::remove_dir(&dir);
813     }
814 
815     #[test]
816     fn last_dir_loaded_some_reads_that_dir() {
817         let mut ui = UiContext::new();
818         let mut state = BrowseState::new(&mut ui);
819         let req = update(&mut state, BrowseMessage::LastDirLoaded(Some(PathBuf::from("/some/dir"))), &mut ui);
820         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/some/dir")));
821     }
822 
823     #[test]
824     fn last_dir_loaded_none_falls_back() {
825         let mut ui = UiContext::new();
826         let mut state = BrowseState::new(&mut ui);
827         let req = update(&mut state, BrowseMessage::LastDirLoaded(None), &mut ui);
828         assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
829     }
830 
831     #[test]
832     fn test_is_project_dir_detection() {
833         let unique_dir = std::env::temp_dir().join(format!("clear_test_dir_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
834         std::fs::create_dir_all(&unique_dir).unwrap();
835         
836         // Initially, path is a directory but doesn't have state.json
837         assert!(!is_project_dir(&unique_dir));
838 
839         // Create state.json
840         let file_path_json = unique_dir.join("state.json");
841         std::fs::write(&file_path_json, "{}").unwrap();
842 
843         // Now it should be recognized as a project dir
844         assert!(is_project_dir(&unique_dir));
845 
846         // If it's a file rather than a directory, even if named state.json, it shouldn't be
847         // a project dir itself
848         assert!(!is_project_dir(&file_path_json));
849 
850         // Remove state.json and verify it's not a project dir
851         std::fs::remove_file(&file_path_json).unwrap();
852         assert!(!is_project_dir(&unique_dir));
853 
854         // A state.kdl does NOT make a project: that is what the designer names its settings
855         // file, so matching it made ~/.config/cce/cce-designer/ un-enterable in the browser.
856         let file_path_kdl = unique_dir.join("state.kdl");
857         std::fs::write(&file_path_kdl, "graph {\n    grid_size_x (f64)71.0\n}").unwrap();
858         assert!(!is_project_dir(&unique_dir), "a settings state.kdl is not a project");
859 
860         // Clean up
861         let _ = std::fs::remove_file(&file_path_kdl);
862         let _ = std::fs::remove_dir(&unique_dir);
863     }
864 
865     #[test]
866     fn test_directory_persistence() {
867         let mut ui = UiContext::new();
868         let root = std::env::temp_dir().join(format!(
869             "cce_test_persist_{}",
870             chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
871         ));
872         let config_dir = root.join("config");
873         let test_dir = root.join("persisted");
874         std::fs::create_dir_all(&test_dir).unwrap();
875 
876         crate::services::fs::save_last_dir_in(&config_dir, &test_dir);
877         let restored = crate::services::fs::read_last_dir_in(&config_dir);
878         let _ = std::fs::remove_dir_all(&root);
879 
880         assert_eq!(restored, Some(test_dir));
881     }
882 
883     #[tokio::test]
884     async fn test_delete_entry() {
885         let mut ui = UiContext::new();
886         let temp_dir = std::env::temp_dir();
887         let test_subdir = temp_dir.join(format!("cce_test_delete_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
888         std::fs::create_dir_all(&test_subdir).unwrap();
889 
890         let file_path = test_subdir.join("delete_me.txt");
891         std::fs::write(&file_path, "test delete content").unwrap();
892 
893         let mut state = BrowseState {
894             current_dir: test_subdir.clone(),
895             all_entries: vec![
896                 DirEntry {
897                     name: "delete_me.txt".to_string(),
898                     path: file_path.clone(),
899                     is_dir: false,
900 
901                     size: 19,
902                     permissions: 0o644,
903                     modified: String::new(),
904                     origin: None,
905                 }
906             ],
907             entries: vec![
908                 DirEntry {
909                     name: "delete_me.txt".to_string(),
910                     path: file_path.clone(),
911                     is_dir: false,
912                     size: 19,
913                     permissions: 0o644,
914                     modified: String::new(),
915                     origin: None,
916                 }
917             ],
918             selected: Some(0),
919             ..BrowseState::new(&mut ui)
920         };
921 
922         // Assert file exists before deletion
923         assert!(file_path.exists());
924 
925         // DeleteEntry outside the trash routes to TrashPath (recoverable);
926         // DeleteEntryPermanent is the unrecoverable path.
927         let req = update(&mut state, BrowseMessage::DeleteEntry(0), &mut ui);
928         assert!(matches!(req, Some(crate::services::fs::FsRequest::TrashPath(_))));
929         let req_perm = update(&mut state, BrowseMessage::DeleteEntryPermanent(0), &mut ui);
930         assert!(matches!(req_perm, Some(crate::services::fs::FsRequest::DeletePath(_, _))));
931 
932         // Directly delete the file to simulate the FsService action
933         std::fs::remove_file(&file_path).unwrap();
934         assert!(!file_path.exists());
935 
936         // Perform update call for Deleted response
937         let req2 = update(&mut state, BrowseMessage::Deleted(file_path.clone(), Ok(())), &mut ui);
938         assert!(matches!(req2, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
939 
940         // Check if state entries are updated
941         assert!(state.entries.is_empty());
942         assert!(state.all_entries.is_empty());
943         assert_eq!(state.selected, None);
944 
945         // Clean up directory
946         let _ = std::fs::remove_dir_all(&test_subdir);
947     }
948 
949     #[test]
950     fn test_validate_new_name() {
951         let path = PathBuf::from("/home/user/notes.txt");
952         assert_eq!(validate_new_name(&path, "todo.md"), Ok(Some(PathBuf::from("/home/user/todo.md"))));
953         assert_eq!(validate_new_name(&path, "notes.txt"), Ok(None));
954         for bad in ["", ".", "..", "a/b", "../x"] {
955             assert!(validate_new_name(&path, bad).is_err(), "{bad:?} should be rejected");
956         }
957     }
958 
959     #[test]
960     fn test_component_reconstruction() {
961         let current_dir = PathBuf::from("/home/user/documents");
962 
963         // seg 0 is the root, then each index adds a component.
964         assert_eq!(path_to_segment(&current_dir, 0), PathBuf::from("/"));
965         assert_eq!(path_to_segment(&current_dir, 1), PathBuf::from("/home"));
966         assert_eq!(path_to_segment(&current_dir, 2), PathBuf::from("/home/user"));
967         assert_eq!(path_to_segment(&current_dir, 3), PathBuf::from("/home/user/documents"));
968     }
969 }