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