1use anyhow::Result;
2use chrono::Local;
3use crossterm::event::{self, Event, KeyCode};
4use fuzzy_matcher::FuzzyMatcher;
5use fuzzy_matcher::skim::SkimMatcherV2;
6use ratatui::{prelude::*, widgets::*};
7
8use std::{
9 collections::HashSet,
10 fs,
11 io::{self},
12 path::{Path, PathBuf},
13 sync::{
14 Arc,
15 atomic::{AtomicU64, Ordering},
16 },
17 thread,
18 time::SystemTime,
19};
20
21pub use crate::themes::Theme;
22use crate::{
23 config::{get_file_config_toml_name, save_config},
24 utils::{self, SelectionResult},
25};
26
27#[derive(Clone, Copy, PartialEq)]
28pub enum AppMode {
29 Normal,
30 DeleteConfirm,
31 ThemeSelect,
32 ConfigSavePrompt,
33 ConfigSaveLocationSelect,
34 About,
35}
36
37#[derive(Clone)]
38pub struct TryEntry {
39 pub name: String,
40 pub display_name: String,
41 pub display_offset: usize,
42 pub match_indices: Vec<usize>,
43 pub modified: SystemTime,
44 pub created: SystemTime,
45 pub score: i64,
46 pub is_git: bool,
47 pub is_worktree: bool,
48 pub is_worktree_locked: bool,
49 pub is_gitmodules: bool,
50 pub is_mise: bool,
51 pub is_cargo: bool,
52 pub is_maven: bool,
53 pub is_flutter: bool,
54 pub is_go: bool,
55 pub is_python: bool,
56}
57
58pub struct App {
59 pub query: String,
60 pub all_entries: Vec<TryEntry>,
61 pub filtered_entries: Vec<TryEntry>,
62 pub selected_index: usize,
63 pub should_quit: bool,
64 pub final_selection: SelectionResult,
65 pub mode: AppMode,
66 pub status_message: Option<String>,
67 pub base_path: PathBuf,
68 pub theme: Theme,
69 pub editor_cmd: Option<String>,
70 pub wants_editor: bool,
71 pub apply_date_prefix: Option<bool>,
72 pub transparent_background: bool,
73 pub show_new_option: bool,
74 pub show_disk: bool,
75 pub show_preview: bool,
76 pub show_legend: bool,
77 pub right_panel_visible: bool,
78 pub right_panel_width: u16,
79
80 pub available_themes: Vec<Theme>,
81 pub theme_list_state: ListState,
82 pub original_theme: Option<Theme>,
83 pub original_transparent_background: Option<bool>,
84
85 pub config_path: Option<PathBuf>,
86 pub config_location_state: ListState,
87
88 pub cached_free_space_mb: Option<u64>,
89 pub folder_size_mb: Arc<AtomicU64>,
90
91 current_entries: HashSet<String>,
92 matcher: SkimMatcherV2,
93}
94
95impl App {
96 fn is_current_entry(
97 entry_path: &Path,
98 entry_name: &str,
99 is_symlink: bool,
100 cwd_unresolved: &Path,
101 cwd_real: &Path,
102 base_real: &Path,
103 ) -> bool {
104 if cwd_unresolved.starts_with(entry_path) {
105 return true;
106 }
107
108 if is_symlink {
109 if let Ok(target) = entry_path.canonicalize()
110 && cwd_real.starts_with(&target)
111 {
112 return true;
113 }
114 } else {
115 let resolved_entry = base_real.join(entry_name);
116 if cwd_real.starts_with(&resolved_entry) {
117 return true;
118 }
119 }
120
121 false
122 }
123
124 pub fn new(
125 path: PathBuf,
126 theme: Theme,
127 editor_cmd: Option<String>,
128 config_path: Option<PathBuf>,
129 apply_date_prefix: Option<bool>,
130 transparent_background: bool,
131 query: Option<String>,
132 ) -> Self {
133 let mut entries = Vec::new();
134 let mut current_entries = HashSet::new();
135 let cwd_unresolved = std::env::var_os("PWD")
136 .map(PathBuf::from)
137 .filter(|p| !p.as_os_str().is_empty())
138 .or_else(|| std::env::current_dir().ok())
139 .unwrap_or_else(|| PathBuf::from("."));
140 let cwd_real = std::env::current_dir()
141 .ok()
142 .and_then(|cwd| cwd.canonicalize().ok())
143 .unwrap_or_else(|| cwd_unresolved.clone());
144 let base_real = path.canonicalize().unwrap_or_else(|_| path.clone());
145
146 if let Ok(read_dir) = fs::read_dir(&path) {
147 for entry in read_dir.flatten() {
148 if let Ok(metadata) = entry.metadata()
149 && metadata.is_dir()
150 {
151 let entry_path = entry.path();
152 let name = entry.file_name().to_string_lossy().to_string();
153 let git_path = entry_path.join(".git");
154 let is_git = git_path.exists();
155 let is_worktree = git_path.is_file();
156 let is_worktree_locked = utils::is_git_worktree_locked(&entry_path);
157 let is_gitmodules = entry_path.join(".gitmodules").exists();
158 let is_mise = entry_path.join("mise.toml").exists();
159 let is_cargo = entry_path.join("Cargo.toml").exists();
160 let is_maven = entry_path.join("pom.xml").exists();
161 let is_symlink = entry
162 .file_type()
163 .map(|kind| kind.is_symlink())
164 .unwrap_or(false);
165 let is_current = Self::is_current_entry(
166 &entry_path,
167 &name,
168 is_symlink,
169 &cwd_unresolved,
170 &cwd_real,
171 &base_real,
172 );
173 if is_current {
174 current_entries.insert(name.clone());
175 }
176
177 let created;
178 let display_name;
179 if let Some((date_prefix, remainder)) = utils::extract_prefix_date(&name) {
180 created = date_prefix;
181 display_name = remainder;
182 } else {
183 created = metadata.created().unwrap_or(SystemTime::UNIX_EPOCH);
184 display_name = name.clone();
185 }
186 let display_offset = name
187 .chars()
188 .count()
189 .saturating_sub(display_name.chars().count());
190 let is_flutter = entry_path.join("pubspec.yaml").exists();
191 let is_go = entry_path.join("go.mod").exists();
192 let is_python = entry_path.join("pyproject.toml").exists()
193 || entry_path.join("requirements.txt").exists();
194 entries.push(TryEntry {
195 name,
196 display_name,
197 display_offset,
198 match_indices: Vec::new(),
199 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
200 created,
201 score: 0,
202 is_git,
203 is_worktree,
204 is_worktree_locked,
205 is_gitmodules,
206 is_mise,
207 is_cargo,
208 is_maven,
209 is_flutter,
210 is_go,
211 is_python,
212 });
213 }
214 }
215 }
216 entries.sort_by(|a, b| b.modified.cmp(&a.modified));
217
218 let themes = Theme::all();
219
220 let mut theme_state = ListState::default();
221 theme_state.select(Some(0));
222
223 let mut app = Self {
224 query: query.unwrap_or_else(|| String::new()),
225 all_entries: entries.clone(),
226 filtered_entries: entries,
227 selected_index: 0,
228 should_quit: false,
229 final_selection: SelectionResult::None,
230 mode: AppMode::Normal,
231 status_message: None,
232 base_path: path.clone(),
233 theme,
234 editor_cmd,
235 wants_editor: false,
236 apply_date_prefix,
237 transparent_background,
238 show_new_option: false,
239 show_disk: true,
240 show_preview: true,
241 show_legend: true,
242 right_panel_visible: true,
243 right_panel_width: 25,
244 available_themes: themes,
245 theme_list_state: theme_state,
246 original_theme: None,
247 original_transparent_background: None,
248 config_path,
249 config_location_state: ListState::default(),
250 cached_free_space_mb: utils::get_free_disk_space_mb(&path),
251 folder_size_mb: Arc::new(AtomicU64::new(0)),
252 current_entries,
253 matcher: SkimMatcherV2::default(),
254 };
255
256 let folder_size_arc = Arc::clone(&app.folder_size_mb);
258 let path_clone = path.clone();
259 thread::spawn(move || {
260 let size = utils::get_folder_size_mb(&path_clone);
261 folder_size_arc.store(size, Ordering::Relaxed);
262 });
263
264 app.update_search();
265 app
266 }
267
268 pub fn has_exact_match(&self) -> bool {
269 self.all_entries.iter().any(|e| e.name == self.query)
270 }
271
272 pub fn update_search(&mut self) {
273 if self.query.is_empty() {
274 self.filtered_entries = self.all_entries.clone();
275 } else {
276 self.filtered_entries = self
277 .all_entries
278 .iter()
279 .filter_map(|entry| {
280 self.matcher
281 .fuzzy_indices(&entry.name, &self.query)
282 .map(|(score, indices)| {
283 let mut e = entry.clone();
284 e.score = score;
285 if entry.display_offset == 0 {
286 e.match_indices = indices;
287 } else {
288 e.match_indices = indices
289 .into_iter()
290 .filter_map(|idx| idx.checked_sub(entry.display_offset))
291 .collect();
292 }
293 e
294 })
295 })
296 .collect();
297
298 self.filtered_entries.sort_by(|a, b| b.score.cmp(&a.score));
299 }
300 self.show_new_option = !self.query.is_empty() && !self.has_exact_match();
301 self.selected_index = 0;
302 }
303
304 pub fn delete_selected(&mut self) {
305 if let Some(entry_name) = self
306 .filtered_entries
307 .get(self.selected_index)
308 .map(|e| e.name.clone())
309 {
310 let path_to_remove = self.base_path.join(&entry_name);
311
312 if utils::is_git_worktree(&path_to_remove) {
314 match utils::remove_git_worktree(&path_to_remove) {
315 Ok(output) => {
316 if output.status.success() {
317 self.all_entries.retain(|e| e.name != entry_name);
318 self.update_search();
319 self.status_message =
320 Some(format!("Worktree removed: {path_to_remove:?}"));
321 } else {
322 self.status_message = Some(format!(
323 "Error deleting: {}",
324 String::from_utf8_lossy(&output.stderr)
325 .lines()
326 .take(1)
327 .collect::<String>()
328 ));
329 }
330 }
331 Err(e) => {
332 self.status_message = Some(format!("Error removing worktree: {}", e));
333 }
334 };
335 } else {
336 match fs::remove_dir_all(&path_to_remove) {
338 Ok(_) => {
339 self.all_entries.retain(|e| e.name != entry_name);
340 self.update_search();
341 self.status_message =
342 Some(format!("Deleted: {}", path_to_remove.display()));
343 }
344 Err(e) => {
345 self.status_message = Some(format!("Error deleting: {}", e));
346 }
347 }
348 };
349 }
350 self.mode = AppMode::Normal;
351 }
352}
353
354fn draw_popup(f: &mut Frame, title: &str, message: &str, theme: &Theme) {
355 let area = f.area();
356
357 let popup_layout = Layout::default()
358 .direction(Direction::Vertical)
359 .constraints([
360 Constraint::Percentage(40),
361 Constraint::Length(8),
362 Constraint::Percentage(40),
363 ])
364 .split(area);
365
366 let popup_area = Layout::default()
367 .direction(Direction::Horizontal)
368 .constraints([
369 Constraint::Percentage(35),
370 Constraint::Percentage(30),
371 Constraint::Percentage(35),
372 ])
373 .split(popup_layout[1])[1];
374
375 f.render_widget(Clear, popup_area);
376
377 let block = Block::default()
378 .title(title)
379 .borders(Borders::ALL)
380 .padding(Padding::horizontal(1))
381 .style(Style::default().bg(theme.popup_bg));
382
383 let inner_height = popup_area.height.saturating_sub(2) as usize; let text_lines = message.lines().count();
386 let top_padding = inner_height.saturating_sub(text_lines) / 2;
387 let padded_message = format!("{}{}", "\n".repeat(top_padding), message);
388
389 let paragraph = Paragraph::new(padded_message)
390 .block(block)
391 .style(
392 Style::default()
393 .fg(theme.popup_text)
394 .add_modifier(Modifier::BOLD),
395 )
396 .alignment(Alignment::Center);
397
398 f.render_widget(paragraph, popup_area);
399}
400
401fn draw_theme_select(f: &mut Frame, app: &mut App) {
402 let area = f.area();
403 let popup_layout = Layout::default()
404 .direction(Direction::Vertical)
405 .constraints([
406 Constraint::Percentage(25),
407 Constraint::Percentage(50),
408 Constraint::Percentage(25),
409 ])
410 .split(area);
411
412 let popup_area = Layout::default()
413 .direction(Direction::Horizontal)
414 .constraints([
415 Constraint::Percentage(25),
416 Constraint::Percentage(50),
417 Constraint::Percentage(25),
418 ])
419 .split(popup_layout[1])[1];
420
421 f.render_widget(Clear, popup_area);
422
423 let inner_layout = Layout::default()
425 .direction(Direction::Vertical)
426 .constraints([Constraint::Min(3), Constraint::Length(3)])
427 .split(popup_area);
428
429 let block = Block::default()
430 .title(" Select Theme ")
431 .borders(Borders::ALL)
432 .padding(Padding::horizontal(1))
433 .style(Style::default().bg(app.theme.popup_bg));
434
435 let items: Vec<ListItem> = app
436 .available_themes
437 .iter()
438 .map(|t| {
439 ListItem::new(t.name.clone()).style(Style::default().fg(app.theme.list_highlight_fg))
440 })
441 .collect();
442
443 let list = List::new(items)
444 .block(block)
445 .highlight_style(
446 Style::default()
447 .bg(app.theme.list_highlight_bg)
448 .fg(app.theme.list_selected_fg)
449 .add_modifier(Modifier::BOLD),
450 )
451 .highlight_symbol(">> ");
452
453 f.render_stateful_widget(list, inner_layout[0], &mut app.theme_list_state);
454
455 let checkbox = if app.transparent_background {
457 "[x]"
458 } else {
459 "[ ]"
460 };
461 let transparency_text = format!(" {} Transparent Background (Space to toggle)", checkbox);
462 let transparency_block = Block::default()
463 .borders(Borders::ALL)
464 .padding(Padding::horizontal(1))
465 .style(Style::default().bg(app.theme.popup_bg));
466 let transparency_paragraph = Paragraph::new(transparency_text)
467 .style(Style::default().fg(app.theme.list_highlight_fg))
468 .block(transparency_block);
469 f.render_widget(transparency_paragraph, inner_layout[1]);
470}
471
472fn draw_config_location_select(f: &mut Frame, app: &mut App) {
473 let area = f.area();
474 let popup_layout = Layout::default()
475 .direction(Direction::Vertical)
476 .constraints([
477 Constraint::Percentage(40),
478 Constraint::Length(8),
479 Constraint::Percentage(40),
480 ])
481 .split(area);
482
483 let popup_area = Layout::default()
484 .direction(Direction::Horizontal)
485 .constraints([
486 Constraint::Percentage(20),
487 Constraint::Percentage(60),
488 Constraint::Percentage(20),
489 ])
490 .split(popup_layout[1])[1];
491
492 f.render_widget(Clear, popup_area);
493
494 let block = Block::default()
495 .title(" Select Config Location ")
496 .borders(Borders::ALL)
497 .padding(Padding::horizontal(1))
498 .style(Style::default().bg(app.theme.popup_bg));
499
500 let config_name = get_file_config_toml_name();
501 let items = vec![
502 ListItem::new(format!("System Config (~/.config/try-rs/{})", config_name))
503 .style(Style::default().fg(app.theme.list_highlight_fg)),
504 ListItem::new(format!("Home Directory (~/{})", config_name))
505 .style(Style::default().fg(app.theme.list_highlight_fg)),
506 ];
507
508 let list = List::new(items)
509 .block(block)
510 .highlight_style(
511 Style::default()
512 .bg(app.theme.list_highlight_bg)
513 .fg(app.theme.list_selected_fg)
514 .add_modifier(Modifier::BOLD),
515 )
516 .highlight_symbol(">> ");
517
518 f.render_stateful_widget(list, popup_area, &mut app.config_location_state);
519}
520
521fn draw_about_popup(f: &mut Frame, theme: &Theme) {
522 let area = f.area();
523 let popup_layout = Layout::default()
524 .direction(Direction::Vertical)
525 .constraints([
526 Constraint::Percentage(25),
527 Constraint::Length(12),
528 Constraint::Percentage(25),
529 ])
530 .split(area);
531
532 let popup_area = Layout::default()
533 .direction(Direction::Horizontal)
534 .constraints([
535 Constraint::Percentage(30),
536 Constraint::Percentage(40),
537 Constraint::Percentage(30),
538 ])
539 .split(popup_layout[1])[1];
540
541 f.render_widget(Clear, popup_area);
542
543 let block = Block::default()
544 .title(" About ")
545 .borders(Borders::ALL)
546 .padding(Padding::horizontal(1))
547 .style(Style::default().bg(theme.popup_bg));
548
549 let text = vec![
550 Line::from(vec![
551 Span::styled(
552 "🦀 try",
553 Style::default()
554 .fg(theme.title_try)
555 .add_modifier(Modifier::BOLD),
556 ),
557 Span::styled("-", Style::default().fg(Color::DarkGray)),
558 Span::styled(
559 "rs",
560 Style::default()
561 .fg(theme.title_rs)
562 .add_modifier(Modifier::BOLD),
563 ),
564 Span::styled(
565 format!(" v{}", env!("CARGO_PKG_VERSION")),
566 Style::default().fg(Color::DarkGray),
567 ),
568 ]),
569 Line::from(""),
570 Line::from(Span::styled(
571 "try-rs.org",
572 Style::default().fg(theme.search_title),
573 )),
574 Line::from(""),
575 Line::from(Span::styled(
576 "github.com/tassiovirginio/try-rs",
577 Style::default().fg(theme.search_title),
578 )),
579 Line::from(""),
580 Line::from(vec![
581 Span::styled(" License: ", Style::default().fg(theme.helpers_colors)),
582 Span::styled(
583 "MIT",
584 Style::default()
585 .fg(theme.status_message)
586 .add_modifier(Modifier::BOLD),
587 ),
588 ]),
589 Line::from(""),
590 Line::from(Span::styled(
591 "Press Esc to close",
592 Style::default().fg(theme.helpers_colors),
593 )),
594 ];
595
596 let paragraph = Paragraph::new(text)
597 .block(block)
598 .alignment(Alignment::Center);
599
600 f.render_widget(paragraph, popup_area);
601}
602
603fn build_highlighted_name_spans(
604 text: &str,
605 match_indices: &[usize],
606 highlight_style: Style,
607) -> Vec<Span<'static>> {
608 if text.is_empty() {
609 return Vec::new();
610 }
611
612 if match_indices.is_empty() {
613 return vec![Span::raw(text.to_string())];
614 }
615
616 let chars = text.chars().collect::<Vec<_>>();
617 let mut spans = Vec::new();
618 let mut cursor = 0usize;
619 let mut idx = 0usize;
620
621 while idx < match_indices.len() {
622 let start = match_indices[idx];
623 if start >= chars.len() {
624 break;
625 }
626
627 if cursor < start {
628 spans.push(Span::raw(chars[cursor..start].iter().collect::<String>()));
629 }
630
631 let mut end = start + 1;
632 idx += 1;
633 while idx < match_indices.len() && match_indices[idx] == end {
634 end += 1;
635 idx += 1;
636 }
637
638 let end = end.min(chars.len());
639
640 spans.push(Span::styled(
641 chars[start..end].iter().collect::<String>(),
642 highlight_style,
643 ));
644 cursor = end;
645 }
646
647 if cursor < chars.len() {
648 spans.push(Span::raw(chars[cursor..].iter().collect::<String>()));
649 }
650
651 spans
652}
653
654pub fn run_app(
655 terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
656 mut app: App,
657) -> Result<(SelectionResult, bool)> {
658 while !app.should_quit {
659 terminal.draw(|f| {
660 if !app.transparent_background {
662 if let Some(bg_color) = app.theme.background {
663 let background = Block::default().style(Style::default().bg(bg_color));
664 f.render_widget(background, f.area());
665 }
666 }
667
668 let chunks = Layout::default()
669 .direction(Direction::Vertical)
670 .constraints([Constraint::Min(1), Constraint::Length(1)])
671 .split(f.area());
672
673 let show_disk_panel = app.show_disk;
674 let show_preview_panel = app.show_preview;
675 let show_legend_panel = app.show_legend;
676 let has_right_panel_content =
677 show_disk_panel || show_preview_panel || show_legend_panel;
678 let show_right_panel = app.right_panel_visible && has_right_panel_content;
679
680 let right_panel_width = app.right_panel_width.clamp(20, 80);
681 let content_constraints = if !show_right_panel {
682 [Constraint::Percentage(100), Constraint::Percentage(0)]
683 } else {
684 [
685 Constraint::Percentage(100 - right_panel_width),
686 Constraint::Percentage(right_panel_width),
687 ]
688 };
689 let content_chunks = Layout::default()
690 .direction(Direction::Horizontal)
691 .constraints(content_constraints)
692 .split(chunks[0]);
693
694 let left_chunks = Layout::default()
695 .direction(Direction::Vertical)
696 .constraints([Constraint::Length(3), Constraint::Min(1)])
697 .split(content_chunks[0]);
698
699 let search_text = Paragraph::new(app.query.clone())
700 .style(Style::default().fg(app.theme.search_title))
701 .block(
702 Block::default()
703 .borders(Borders::ALL)
704 .padding(Padding::horizontal(1))
705 .title(Span::styled(
706 " Search/New ",
707 Style::default().fg(app.theme.search_title),
708 ))
709 .border_style(Style::default().fg(app.theme.search_border)),
710 );
711 f.render_widget(search_text, left_chunks[0]);
712
713 let matched_char_style = Style::default()
714 .fg(app.theme.list_match_fg)
715 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
716
717 let now = SystemTime::now();
718
719 let mut items: Vec<ListItem> = app
720 .filtered_entries
721 .iter()
722 .map(|entry| {
723 let elapsed = now
724 .duration_since(entry.modified)
725 .unwrap_or(std::time::Duration::ZERO);
726 let secs = elapsed.as_secs();
727 let days = secs / 86400;
728 let hours = (secs % 86400) / 3600;
729 let minutes = (secs % 3600) / 60;
730 let date_str = format!("({:02}d {:02}h {:02}m)", days, hours, minutes);
731
732 let width = left_chunks[1].width.saturating_sub(7) as usize;
733
734 let date_width = date_str.chars().count();
735
736 let icons: &[(bool, &str, Color)] = &[
738 (entry.is_cargo, " ", app.theme.icon_rust),
739 (entry.is_maven, " ", app.theme.icon_maven),
740 (entry.is_flutter, " ", app.theme.icon_flutter),
741 (entry.is_go, " ", app.theme.icon_go),
742 (entry.is_python, " ", app.theme.icon_python),
743 (entry.is_mise, " ", app.theme.icon_mise),
744 (entry.is_worktree, " ", app.theme.icon_worktree),
745 (entry.is_worktree_locked, " ", app.theme.icon_worktree_lock),
746 (entry.is_gitmodules, " ", app.theme.icon_gitmodules),
747 (entry.is_git, " ", app.theme.icon_git),
748 ];
749 let icons_width: usize = icons.iter().filter(|(f, _, _)| *f).count() * 2;
750 let icon_width = 2; let created_dt: chrono::DateTime<Local> = entry.created.into();
753 let created_text = created_dt.format("%Y-%m-%d").to_string();
754 let created_width = created_text.chars().count();
755
756 let reserved = date_width + icons_width + icon_width + created_width + 2;
757 let available_for_name = width.saturating_sub(reserved);
758 let name_len = entry.display_name.chars().count();
759
760 let (display_name, display_match_indices, is_truncated, padding) = if name_len
761 > available_for_name
762 {
763 let safe_len = available_for_name.saturating_sub(3);
764 let truncated: String = entry.display_name.chars().take(safe_len).collect();
765 (
766 truncated,
767 entry
768 .match_indices
769 .iter()
770 .copied()
771 .filter(|idx| *idx < safe_len)
772 .collect::<Vec<_>>(),
773 true,
774 1,
775 )
776 } else {
777 (
778 entry.display_name.clone(),
779 entry.match_indices.clone(),
780 false,
781 width.saturating_sub(
782 icon_width
783 + created_width
784 + 1
785 + name_len
786 + date_width
787 + icons_width,
788 ),
789 )
790 };
791
792 let is_current = app.current_entries.contains(&entry.name);
793 let marker = if is_current { "* " } else { " " };
794 let marker_style = if is_current {
795 Style::default()
796 .fg(app.theme.list_match_fg)
797 .add_modifier(Modifier::BOLD)
798 } else {
799 Style::default()
800 };
801
802 let mut spans = vec![
803 Span::styled(marker, marker_style),
804 Span::styled(" ", Style::default().fg(app.theme.icon_folder)),
805 Span::styled(created_text, Style::default().fg(app.theme.list_date)),
806 Span::raw(" "),
807 ];
808 spans.extend(build_highlighted_name_spans(
809 &display_name,
810 &display_match_indices,
811 matched_char_style,
812 ));
813 if is_truncated {
814 spans.push(Span::raw("..."));
815 }
816 spans.push(Span::raw(" ".repeat(padding)));
817 for &(flag, icon, color) in icons {
818 if flag {
819 spans.push(Span::styled(icon, Style::default().fg(color)));
820 }
821 }
822 spans.push(Span::styled(
823 date_str,
824 Style::default().fg(app.theme.list_date),
825 ));
826
827 ListItem::new(Line::from(spans))
828 .style(Style::default().fg(app.theme.list_highlight_fg))
829 })
830 .collect();
831
832 if app.show_new_option {
834 let new_item = ListItem::new(Line::from(vec![
835 Span::styled(" ", Style::default().fg(app.theme.search_title)),
836 Span::styled(
837 format!("Create new: {}", app.query),
838 Style::default()
839 .fg(app.theme.search_title)
840 .add_modifier(Modifier::ITALIC),
841 ),
842 ]));
843 items.push(new_item);
844 }
845
846 let list = List::new(items)
847 .block(
848 Block::default()
849 .borders(Borders::ALL)
850 .padding(Padding::horizontal(1))
851 .title(Span::styled(
852 " Folders ",
853 Style::default().fg(app.theme.folder_title),
854 ))
855 .border_style(Style::default().fg(app.theme.folder_border)),
856 )
857 .highlight_style(
858 Style::default()
859 .bg(app.theme.list_highlight_bg)
860 .add_modifier(Modifier::BOLD),
861 )
862 .highlight_symbol("→ ");
863
864 let mut state = ListState::default();
865 state.select(Some(app.selected_index));
866 f.render_stateful_widget(list, left_chunks[1], &mut state);
867
868 if show_right_panel {
869 let free_space = app
870 .cached_free_space_mb
871 .map(|s| {
872 if s >= 1000 {
873 format!("{:.1} GB", s as f64 / 1024.0)
874 } else {
875 format!("{} MB", s)
876 }
877 })
878 .unwrap_or_else(|| "N/A".to_string());
879
880 let folder_size = app.folder_size_mb.load(Ordering::Relaxed);
881 let folder_size_str = if folder_size == 0 {
882 "---".to_string()
883 } else if folder_size >= 1000 {
884 format!("{:.1} GB", folder_size as f64 / 1024.0)
885 } else {
886 format!("{} MB", folder_size)
887 };
888
889 let legend_items: [(&str, Color, &str); 10] = [
890 ("", app.theme.icon_rust, "Rust"),
891 ("", app.theme.icon_maven, "Maven"),
892 ("", app.theme.icon_flutter, "Flutter"),
893 ("", app.theme.icon_go, "Go"),
894 ("", app.theme.icon_python, "Python"),
895 ("", app.theme.icon_mise, "Mise"),
896 ("", app.theme.icon_worktree_lock, "Locked"),
897 ("", app.theme.icon_worktree, "Worktree"),
898 ("", app.theme.icon_gitmodules, "Submodule"),
899 ("", app.theme.icon_git, "Git"),
900 ];
901
902 let legend_required_lines = if show_legend_panel {
903 let legend_inner_width = content_chunks[1].width.saturating_sub(4).max(1);
904 let mut lines: u16 = 1;
905 let mut used: u16 = 0;
906
907 for (idx, (icon, _, label)) in legend_items.iter().enumerate() {
908 let item_width = (icon.chars().count() + 1 + label.chars().count()) as u16;
909 let separator_width = if idx == 0 { 0 } else { 2 };
910
911 if used > 0 && used + separator_width + item_width > legend_inner_width {
912 lines += 1;
913 used = item_width;
914 } else {
915 used += separator_width + item_width;
916 }
917 }
918
919 lines
920 } else {
921 0
922 };
923
924 let legend_height = legend_required_lines.saturating_add(2).max(3);
925
926 let right_constraints = if show_disk_panel {
927 if show_preview_panel && show_legend_panel {
928 [
929 Constraint::Length(3),
930 Constraint::Min(1),
931 Constraint::Length(legend_height),
932 ]
933 } else if show_preview_panel {
934 [
935 Constraint::Length(3),
936 Constraint::Min(1),
937 Constraint::Length(0),
938 ]
939 } else if show_legend_panel {
940 [
941 Constraint::Length(3),
942 Constraint::Length(0),
943 Constraint::Min(1),
944 ]
945 } else {
946 [
947 Constraint::Length(3),
948 Constraint::Length(0),
949 Constraint::Length(0),
950 ]
951 }
952 } else if show_preview_panel && show_legend_panel {
953 [
954 Constraint::Length(0),
955 Constraint::Min(1),
956 Constraint::Length(legend_height),
957 ]
958 } else if show_preview_panel {
959 [
960 Constraint::Length(0),
961 Constraint::Min(1),
962 Constraint::Length(0),
963 ]
964 } else {
965 [
966 Constraint::Length(0),
967 Constraint::Length(0),
968 Constraint::Min(1),
969 ]
970 };
971 let right_chunks = Layout::default()
972 .direction(Direction::Vertical)
973 .constraints(right_constraints)
974 .split(content_chunks[1]);
975
976 if show_disk_panel {
977 let memory_info = Paragraph::new(Line::from(vec![
978 Span::styled(" ", Style::default().fg(app.theme.title_rs)),
979 Span::styled("Used: ", Style::default().fg(app.theme.helpers_colors)),
980 Span::styled(
981 folder_size_str,
982 Style::default().fg(app.theme.status_message),
983 ),
984 Span::styled(" | ", Style::default().fg(app.theme.helpers_colors)),
985 Span::styled("Free: ", Style::default().fg(app.theme.helpers_colors)),
986 Span::styled(free_space, Style::default().fg(app.theme.status_message)),
987 ]))
988 .block(
989 Block::default()
990 .borders(Borders::ALL)
991 .padding(Padding::horizontal(1))
992 .title(Span::styled(
993 " Disk ",
994 Style::default().fg(app.theme.disk_title),
995 ))
996 .border_style(Style::default().fg(app.theme.disk_border)),
997 )
998 .alignment(Alignment::Center);
999 f.render_widget(memory_info, right_chunks[0]);
1000 }
1001
1002 if show_preview_panel {
1003 let is_new_selected =
1005 app.show_new_option && app.selected_index == app.filtered_entries.len();
1006
1007 if is_new_selected {
1008 let preview_lines = vec![Line::from(Span::styled(
1010 "(new folder)",
1011 Style::default()
1012 .fg(app.theme.search_title)
1013 .add_modifier(Modifier::ITALIC),
1014 ))];
1015 let preview = Paragraph::new(preview_lines).block(
1016 Block::default()
1017 .borders(Borders::ALL)
1018 .padding(Padding::horizontal(1))
1019 .title(Span::styled(
1020 " Preview ",
1021 Style::default().fg(app.theme.preview_title),
1022 ))
1023 .border_style(Style::default().fg(app.theme.preview_border)),
1024 );
1025 f.render_widget(preview, right_chunks[1]);
1026 } else if let Some(selected) = app.filtered_entries.get(app.selected_index) {
1027 let preview_path = app.base_path.join(&selected.name);
1028 let mut preview_lines = Vec::new();
1029
1030 if let Ok(entries) = fs::read_dir(&preview_path) {
1031 for e in entries
1032 .take(right_chunks[1].height.saturating_sub(2) as usize)
1033 .flatten()
1034 {
1035 let file_name = e.file_name().to_string_lossy().to_string();
1036 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1037 let (icon, color) = if is_dir {
1038 (" ", app.theme.icon_folder)
1039 } else {
1040 (" ", app.theme.icon_file)
1041 };
1042 preview_lines.push(Line::from(vec![
1043 Span::styled(icon, Style::default().fg(color)),
1044 Span::raw(file_name),
1045 ]));
1046 }
1047 }
1048
1049 if preview_lines.is_empty() {
1050 preview_lines.push(Line::from(Span::styled(
1051 " (empty) ",
1052 Style::default().fg(app.theme.helpers_colors),
1053 )));
1054 }
1055
1056 let preview = Paragraph::new(preview_lines).block(
1057 Block::default()
1058 .borders(Borders::ALL)
1059 .padding(Padding::horizontal(1))
1060 .title(Span::styled(
1061 " Preview ",
1062 Style::default().fg(app.theme.preview_title),
1063 ))
1064 .border_style(Style::default().fg(app.theme.preview_border)),
1065 );
1066 f.render_widget(preview, right_chunks[1]);
1067 } else {
1068 let preview = Block::default()
1069 .borders(Borders::ALL)
1070 .padding(Padding::horizontal(1))
1071 .title(Span::styled(
1072 " Preview ",
1073 Style::default().fg(app.theme.preview_title),
1074 ))
1075 .border_style(Style::default().fg(app.theme.preview_border));
1076 f.render_widget(preview, right_chunks[1]);
1077 }
1078 }
1079
1080 if show_legend_panel {
1081 let mut legend_spans = Vec::with_capacity(legend_items.len() * 4);
1083 for (idx, (icon, color, label)) in legend_items.iter().enumerate() {
1084 if idx > 0 {
1085 legend_spans.push(Span::raw(" "));
1086 }
1087 legend_spans.push(Span::styled(*icon, Style::default().fg(*color)));
1088 legend_spans.push(Span::styled(
1089 "\u{00A0}",
1090 Style::default().fg(app.theme.helpers_colors),
1091 ));
1092 legend_spans.push(Span::styled(
1093 *label,
1094 Style::default().fg(app.theme.helpers_colors),
1095 ));
1096 }
1097 let legend_lines = vec![Line::from(legend_spans)];
1098
1099 let legend = Paragraph::new(legend_lines)
1100 .block(
1101 Block::default()
1102 .borders(Borders::ALL)
1103 .padding(Padding::horizontal(1))
1104 .title(Span::styled(
1105 " Legends ",
1106 Style::default().fg(app.theme.legends_title),
1107 ))
1108 .border_style(Style::default().fg(app.theme.legends_border)),
1109 )
1110 .alignment(Alignment::Left)
1111 .wrap(Wrap { trim: true });
1112 f.render_widget(legend, right_chunks[2]);
1113 }
1114 }
1115
1116 let help_text = if let Some(msg) = &app.status_message {
1117 Line::from(vec![Span::styled(
1118 msg,
1119 Style::default()
1120 .fg(app.theme.status_message)
1121 .add_modifier(Modifier::BOLD),
1122 )])
1123 } else {
1124 Line::from(vec![
1125 Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
1126 Span::raw(" Nav | "),
1127 Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
1128 Span::raw(" Select | "),
1129 Span::styled("Ctrl-D", Style::default().add_modifier(Modifier::BOLD)),
1130 Span::raw(" Del | "),
1131 Span::styled("Ctrl-E", Style::default().add_modifier(Modifier::BOLD)),
1132 Span::raw(" Edit | "),
1133 Span::styled("Ctrl-T", Style::default().add_modifier(Modifier::BOLD)),
1134 Span::raw(" Theme | "),
1135 Span::styled("Ctrl-A", Style::default().add_modifier(Modifier::BOLD)),
1136 Span::raw(" About | "),
1137 Span::styled("Alt-P", Style::default().add_modifier(Modifier::BOLD)),
1138 Span::raw(" Panel | "),
1139 Span::styled("Esc/Ctrl+C", Style::default().add_modifier(Modifier::BOLD)),
1140 Span::raw(" Quit"),
1141 ])
1142 };
1143
1144 let help_message = Paragraph::new(help_text)
1145 .style(Style::default().fg(app.theme.helpers_colors))
1146 .alignment(Alignment::Center);
1147
1148 f.render_widget(help_message, chunks[1]);
1149
1150 if app.mode == AppMode::DeleteConfirm
1151 && let Some(selected) = app.filtered_entries.get(app.selected_index)
1152 {
1153 let msg = format!("Delete '{}'?\n(y/n)", selected.name);
1154 draw_popup(f, " WARNING ", &msg, &app.theme);
1155 }
1156
1157 if app.mode == AppMode::ThemeSelect {
1158 draw_theme_select(f, &mut app);
1159 }
1160
1161 if app.mode == AppMode::ConfigSavePrompt {
1162 draw_popup(
1163 f,
1164 " Create Config? ",
1165 "Config file not found.\nCreate one now to save theme? (y/n)",
1166 &app.theme,
1167 );
1168 }
1169
1170 if app.mode == AppMode::ConfigSaveLocationSelect {
1171 draw_config_location_select(f, &mut app);
1172 }
1173
1174 if app.mode == AppMode::About {
1175 draw_about_popup(f, &app.theme);
1176 }
1177 })?;
1178
1179 if !event::poll(std::time::Duration::from_secs(1))? {
1181 continue;
1182 }
1183 if let Event::Key(key) = event::read()? {
1184 if !key.is_press() {
1185 continue;
1186 }
1187 app.status_message = None;
1189 match app.mode {
1190 AppMode::Normal => match key.code {
1191 KeyCode::Char(c) => {
1192 if c == 'c' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1193 app.should_quit = true;
1194 } else if c == 'd' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1195 let is_new_selected = app.show_new_option
1196 && app.selected_index == app.filtered_entries.len();
1197 if !app.filtered_entries.is_empty() && !is_new_selected {
1198 app.mode = AppMode::DeleteConfirm;
1199 }
1200 } else if c == 'e' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1201 if app.editor_cmd.is_some() {
1202 let is_new_selected = app.show_new_option
1203 && app.selected_index == app.filtered_entries.len();
1204 if is_new_selected {
1205 app.final_selection = SelectionResult::New(app.query.clone());
1206 app.wants_editor = true;
1207 app.should_quit = true;
1208 } else if !app.filtered_entries.is_empty() {
1209 app.final_selection = SelectionResult::Folder(
1210 app.filtered_entries[app.selected_index].name.clone(),
1211 );
1212 app.wants_editor = true;
1213 app.should_quit = true;
1214 } else if !app.query.is_empty() {
1215 app.final_selection = SelectionResult::New(app.query.clone());
1216 app.wants_editor = true;
1217 app.should_quit = true;
1218 }
1219 } else {
1220 app.status_message =
1221 Some("No editor configured in config.toml".to_string());
1222 }
1223 } else if c == 't' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1224 app.original_theme = Some(app.theme.clone());
1226 app.original_transparent_background = Some(app.transparent_background);
1227 let current_idx = app
1229 .available_themes
1230 .iter()
1231 .position(|t| t.name == app.theme.name)
1232 .unwrap_or(0);
1233 app.theme_list_state.select(Some(current_idx));
1234 app.mode = AppMode::ThemeSelect;
1235 } else if c == 'a' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1236 app.mode = AppMode::About;
1237 } else if matches!(c, 'p')
1238 && key.modifiers.contains(event::KeyModifiers::ALT)
1239 {
1240 app.right_panel_visible = !app.right_panel_visible;
1241 } else if matches!(c, 'k' | 'p')
1242 && key.modifiers.contains(event::KeyModifiers::CONTROL)
1243 {
1244 if app.selected_index > 0 {
1245 app.selected_index -= 1;
1246 }
1247 } else if matches!(c, 'j' | 'n')
1248 && key.modifiers.contains(event::KeyModifiers::CONTROL)
1249 {
1250 let max_index = if app.show_new_option {
1251 app.filtered_entries.len()
1252 } else {
1253 app.filtered_entries.len().saturating_sub(1)
1254 };
1255 if app.selected_index < max_index {
1256 app.selected_index += 1;
1257 }
1258 } else if c == 'u' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
1259 app.query.clear();
1260 app.update_search();
1261 } else if key.modifiers.is_empty()
1262 || key.modifiers == event::KeyModifiers::SHIFT
1263 {
1264 app.query.push(c);
1265 app.update_search();
1266 }
1267 }
1268 KeyCode::Backspace => {
1269 app.query.pop();
1270 app.update_search();
1271 }
1272 KeyCode::Up => {
1273 if app.selected_index > 0 {
1274 app.selected_index -= 1;
1275 }
1276 }
1277 KeyCode::Down => {
1278 let max_index = if app.show_new_option {
1279 app.filtered_entries.len()
1280 } else {
1281 app.filtered_entries.len().saturating_sub(1)
1282 };
1283 if app.selected_index < max_index {
1284 app.selected_index += 1;
1285 }
1286 }
1287 KeyCode::Enter => {
1288 let is_new_selected =
1289 app.show_new_option && app.selected_index == app.filtered_entries.len();
1290 if is_new_selected {
1291 app.final_selection = SelectionResult::New(app.query.clone());
1292 } else if !app.filtered_entries.is_empty() {
1293 app.final_selection = SelectionResult::Folder(
1294 app.filtered_entries[app.selected_index].name.clone(),
1295 );
1296 } else if !app.query.is_empty() {
1297 app.final_selection = SelectionResult::New(app.query.clone());
1298 }
1299 app.should_quit = true;
1300 }
1301 KeyCode::Esc => app.should_quit = true,
1302 _ => {}
1303 },
1304
1305 AppMode::DeleteConfirm => match key.code {
1306 KeyCode::Char('y') | KeyCode::Char('Y') => {
1307 app.delete_selected();
1308 }
1309 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1310 app.mode = AppMode::Normal;
1311 }
1312 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1313 app.should_quit = true;
1314 }
1315 _ => {}
1316 },
1317
1318 AppMode::ThemeSelect => match key.code {
1319 KeyCode::Char(' ') => {
1320 app.transparent_background = !app.transparent_background;
1322 }
1323 KeyCode::Esc => {
1324 if let Some(original) = app.original_theme.take() {
1326 app.theme = original;
1327 }
1328 if let Some(original_transparent) =
1329 app.original_transparent_background.take()
1330 {
1331 app.transparent_background = original_transparent;
1332 }
1333 app.mode = AppMode::Normal;
1334 }
1335 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1336 if let Some(original) = app.original_theme.take() {
1338 app.theme = original;
1339 }
1340 if let Some(original_transparent) =
1341 app.original_transparent_background.take()
1342 {
1343 app.transparent_background = original_transparent;
1344 }
1345 app.mode = AppMode::Normal;
1346 }
1347 KeyCode::Up | KeyCode::Char('k' | 'p') => {
1348 let i = match app.theme_list_state.selected() {
1349 Some(i) => {
1350 if i > 0 {
1351 i - 1
1352 } else {
1353 i
1354 }
1355 }
1356 None => 0,
1357 };
1358 app.theme_list_state.select(Some(i));
1359 if let Some(theme) = app.available_themes.get(i) {
1361 app.theme = theme.clone();
1362 }
1363 }
1364 KeyCode::Down | KeyCode::Char('j' | 'n') => {
1365 let i = match app.theme_list_state.selected() {
1366 Some(i) => {
1367 if i < app.available_themes.len() - 1 {
1368 i + 1
1369 } else {
1370 i
1371 }
1372 }
1373 None => 0,
1374 };
1375 app.theme_list_state.select(Some(i));
1376 if let Some(theme) = app.available_themes.get(i) {
1378 app.theme = theme.clone();
1379 }
1380 }
1381 KeyCode::Enter => {
1382 app.original_theme = None;
1384 app.original_transparent_background = None;
1385 if let Some(i) = app.theme_list_state.selected() {
1386 if let Some(theme) = app.available_themes.get(i) {
1387 app.theme = theme.clone();
1388
1389 if let Some(ref path) = app.config_path {
1390 if let Err(e) = save_config(
1391 path,
1392 &app.theme,
1393 &app.base_path,
1394 &app.editor_cmd,
1395 app.apply_date_prefix,
1396 Some(app.transparent_background),
1397 Some(app.show_disk),
1398 Some(app.show_preview),
1399 Some(app.show_legend),
1400 Some(app.right_panel_visible),
1401 Some(app.right_panel_width),
1402 ) {
1403 app.status_message = Some(format!("Error saving: {}", e));
1404 } else {
1405 app.status_message = Some("Theme saved.".to_string());
1406 }
1407 app.mode = AppMode::Normal;
1408 } else {
1409 app.mode = AppMode::ConfigSavePrompt;
1410 }
1411 } else {
1412 app.mode = AppMode::Normal;
1413 }
1414 } else {
1415 app.mode = AppMode::Normal;
1416 }
1417 }
1418 _ => {}
1419 },
1420 AppMode::ConfigSavePrompt => match key.code {
1421 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
1422 app.mode = AppMode::ConfigSaveLocationSelect;
1423 app.config_location_state.select(Some(0));
1424 }
1425 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1426 app.mode = AppMode::Normal;
1427 }
1428 _ => {}
1429 },
1430
1431 AppMode::ConfigSaveLocationSelect => match key.code {
1432 KeyCode::Esc | KeyCode::Char('c')
1433 if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
1434 {
1435 app.mode = AppMode::Normal;
1436 }
1437 KeyCode::Up | KeyCode::Char('k' | 'p') => {
1438 let i = match app.config_location_state.selected() {
1439 Some(i) => {
1440 if i > 0 {
1441 i - 1
1442 } else {
1443 i
1444 }
1445 }
1446 None => 0,
1447 };
1448 app.config_location_state.select(Some(i));
1449 }
1450 KeyCode::Down | KeyCode::Char('j' | 'n') => {
1451 let i = match app.config_location_state.selected() {
1452 Some(i) => {
1453 if i < 1 {
1454 i + 1
1455 } else {
1456 i
1457 }
1458 }
1459 None => 0,
1460 };
1461 app.config_location_state.select(Some(i));
1462 }
1463 KeyCode::Enter => {
1464 if let Some(i) = app.config_location_state.selected() {
1465 let config_name = get_file_config_toml_name();
1466 let path = if i == 0 {
1467 dirs::config_dir()
1468 .unwrap_or_else(|| {
1469 dirs::home_dir().expect("Folder not found").join(".config")
1470 })
1471 .join("try-rs")
1472 .join(&config_name)
1473 } else {
1474 dirs::home_dir()
1475 .expect("Folder not found")
1476 .join(&config_name)
1477 };
1478
1479 if let Err(e) = save_config(
1480 &path,
1481 &app.theme,
1482 &app.base_path,
1483 &app.editor_cmd,
1484 app.apply_date_prefix,
1485 Some(app.transparent_background),
1486 Some(app.show_disk),
1487 Some(app.show_preview),
1488 Some(app.show_legend),
1489 Some(app.right_panel_visible),
1490 Some(app.right_panel_width),
1491 ) {
1492 app.status_message = Some(format!("Error saving config: {}", e));
1493 } else {
1494 app.config_path = Some(path);
1495 app.status_message = Some("Theme saved!".to_string());
1496 }
1497 }
1498 app.mode = AppMode::Normal;
1499 }
1500 _ => {}
1501 },
1502 AppMode::About => match key.code {
1503 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char(' ') => {
1504 app.mode = AppMode::Normal;
1505 }
1506 KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1507 app.mode = AppMode::Normal;
1508 }
1509 _ => {}
1510 },
1511 }
1512 }
1513 }
1514
1515 Ok((app.final_selection, app.wants_editor))
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::App;
1521 use std::{fs, path::PathBuf};
1522 use tempdir::TempDir;
1523
1524 #[test]
1525 fn current_entry_detects_nested_path() {
1526 let temp = TempDir::new("current-entry-nested").unwrap();
1527 let base_path = temp.path().to_path_buf();
1528 let entry_name = "2025-11-20-gamma";
1529 let entry_path = base_path.join(entry_name);
1530 let nested_path = entry_path.join("nested/deeper");
1531
1532 fs::create_dir_all(&nested_path).unwrap();
1533
1534 assert!(App::is_current_entry(
1535 &entry_path,
1536 entry_name,
1537 false,
1538 &nested_path,
1539 &nested_path,
1540 &base_path,
1541 ));
1542 }
1543
1544 #[test]
1545 fn current_entry_detects_nested_path_with_stale_pwd() {
1546 let temp = TempDir::new("current-entry-script").unwrap();
1547 let base_path = temp.path().to_path_buf();
1548 let entry_name = "2025-11-20-gamma";
1549 let entry_path = base_path.join(entry_name);
1550 let nested_path = entry_path.join("nested/deeper");
1551
1552 fs::create_dir_all(&nested_path).unwrap();
1553
1554 let stale_pwd = PathBuf::from("/tmp/not-the-real-cwd");
1555
1556 assert!(App::is_current_entry(
1557 &entry_path,
1558 entry_name,
1559 false,
1560 &stale_pwd,
1561 &nested_path,
1562 &base_path,
1563 ));
1564 }
1565}