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