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