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(¤t_dir, 0), PathBuf::from("/"));
965 assert_eq!(path_to_segment(¤t_dir, 1), PathBuf::from("/home"));
966 assert_eq!(path_to_segment(¤t_dir, 2), PathBuf::from("/home/user"));
967 assert_eq!(path_to_segment(¤t_dir, 3), PathBuf::from("/home/user/documents"));
968 }
969 }