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