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