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