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

src/pages/browse.rs (35K)

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