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