Skip to main content

try_rs/
tui.rs

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        // Spawn background thread to calculate folder size (only if disk panel is visible)
273        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            // Only use git worktree remove if it's actually a worktree (not main working tree)
415            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                // Regular directory or main git repo - just delete it
439                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    // Vertically center the text inside the popup
542    let inner_height = popup_area.height.saturating_sub(2) as usize; // subtract borders
543    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    // Split popup into theme list and transparency option
582    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    // Draw transparency checkbox
614    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            // Render background if not transparent
875            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                    // Build icon list: (flag, icon_str, color)
997                    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; // folder icon
1011
1012                    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            // Append "new" option when no exact match
1093            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                    // Check if "new" option is currently selected
1264                    let is_new_selected =
1265                        app.show_new_option && app.selected_index == app.filtered_entries.len();
1266
1267                    if is_new_selected {
1268                        // Show "new folder" preview
1269                        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                    // Icon legend
1342                    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        // Poll with 1-second timeout so the screen refreshes periodically
1465        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            // Clear status message on any key press so it disappears after one redraw
1473            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                            // Save current theme and transparency before opening selector
1518                            app.original_theme = Some(app.theme.clone());
1519                            app.original_transparent_background = Some(app.transparent_background);
1520                            // Find and select current theme in the list
1521                            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                        // Toggle transparent background
1659                        app.transparent_background = !app.transparent_background;
1660                    }
1661                    KeyCode::Esc => {
1662                        // Restore original theme and transparency
1663                        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                        // Restore original theme and transparency
1675                        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                        // Apply theme preview
1698                        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                        // Apply theme preview
1715                        if let Some(theme) = app.available_themes.get(i) {
1716                            app.theme = theme.clone();
1717                        }
1718                    }
1719                    KeyCode::Enter => {
1720                        // Clear original theme and transparency (we're confirming the new values)
1721                        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}