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