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(¤t_dir, 0), PathBuf::from("/"));
878 assert_eq!(path_to_segment(¤t_dir, 1), PathBuf::from("/home"));
879 assert_eq!(path_to_segment(¤t_dir, 2), PathBuf::from("/home/user"));
880 assert_eq!(path_to_segment(¤t_dir, 3), PathBuf::from("/home/user/documents"));
881 }
882 }