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