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    fs,
10    io::{self},
11    path::PathBuf,
12    sync::{
13        Arc,
14        atomic::{AtomicU64, Ordering},
15    },
16    thread,
17    time::SystemTime,
18};
19
20pub use crate::themes::Theme;
21use crate::{
22    config::{get_file_config_toml_name, save_config},
23    utils::{self, SelectionResult},
24};
25
26#[derive(Clone, Copy, PartialEq)]
27pub enum AppMode {
28    Normal,
29    DeleteConfirm,
30    ThemeSelect,
31    ConfigSavePrompt,
32    ConfigSaveLocationSelect,
33    About,
34}
35
36#[derive(Clone)]
37pub struct TryEntry {
38    pub name: String,
39    pub display_name: String,
40    pub modified: SystemTime,
41    pub created: SystemTime,
42    pub score: i64,
43    pub is_git: bool,
44    pub is_worktree: bool,
45    pub is_worktree_locked: bool,
46    pub is_gitmodules: bool,
47    pub is_mise: bool,
48    pub is_cargo: bool,
49    pub is_maven: bool,
50    pub is_flutter: bool,
51    pub is_go: bool,
52    pub is_python: bool,
53}
54
55pub struct App {
56    pub query: String,
57    pub all_entries: Vec<TryEntry>,
58    pub filtered_entries: Vec<TryEntry>,
59    pub selected_index: usize,
60    pub should_quit: bool,
61    pub final_selection: SelectionResult,
62    pub mode: AppMode,
63    pub status_message: Option<String>,
64    pub base_path: PathBuf,
65    pub theme: Theme,
66    pub editor_cmd: Option<String>,
67    pub wants_editor: bool,
68    pub apply_date_prefix: Option<bool>,
69    pub transparent_background: bool,
70    pub show_new_option: bool,
71
72    pub available_themes: Vec<Theme>,
73    pub theme_list_state: ListState,
74    pub original_theme: Option<Theme>,
75    pub original_transparent_background: Option<bool>,
76
77    pub config_path: Option<PathBuf>,
78    pub config_location_state: ListState,
79
80    pub cached_free_space_mb: Option<u64>,
81    pub folder_size_mb: Arc<AtomicU64>,
82}
83
84impl App {
85    pub fn new(
86        path: PathBuf,
87        theme: Theme,
88        editor_cmd: Option<String>,
89        config_path: Option<PathBuf>,
90        apply_date_prefix: Option<bool>,
91        transparent_background: bool,
92        query: Option<String>,
93    ) -> Self {
94        let mut entries = Vec::new();
95        if let Ok(read_dir) = fs::read_dir(&path) {
96            for entry in read_dir.flatten() {
97                if let Ok(metadata) = entry.metadata()
98                    && metadata.is_dir()
99                {
100                    let name = entry.file_name().to_string_lossy().to_string();
101                    let git_path = entry.path().join(".git");
102                    let is_git = git_path.exists();
103                    let is_worktree = git_path.is_file();
104                    let is_worktree_locked = utils::is_git_worktree_locked(&entry.path());
105                    let is_gitmodules = entry.path().join(".gitmodules").exists();
106                    let is_mise = entry.path().join("mise.toml").exists();
107                    let is_cargo = entry.path().join("Cargo.toml").exists();
108                    let is_maven = entry.path().join("pom.xml").exists();
109
110                    let created;
111                    let display_name;
112                    if let Some((date_prefix, remainder)) = utils::extract_prefix_date(&name) {
113                        created = date_prefix;
114                        display_name = remainder;
115                    } else {
116                        created = metadata.created().unwrap_or(SystemTime::UNIX_EPOCH);
117                        display_name = name.clone();
118                    }
119                    let is_flutter = entry.path().join("pubspec.yaml").exists();
120                    let is_go = entry.path().join("go.mod").exists();
121                    let is_python = entry.path().join("pyproject.toml").exists()
122                        || entry.path().join("requirements.txt").exists();
123                    entries.push(TryEntry {
124                        name,
125                        display_name,
126                        modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
127                        created,
128                        score: 0,
129                        is_git,
130                        is_worktree,
131                        is_worktree_locked,
132                        is_gitmodules,
133                        is_mise,
134                        is_cargo,
135                        is_maven,
136                        is_flutter,
137                        is_go,
138                        is_python,
139                    });
140                }
141            }
142        }
143        entries.sort_by(|a, b| b.modified.cmp(&a.modified));
144
145        let themes = Theme::all();
146
147        let mut theme_state = ListState::default();
148        theme_state.select(Some(0));
149
150        let mut app = Self {
151            query: query.unwrap_or_else(|| String::new()),
152            all_entries: entries.clone(),
153            filtered_entries: entries,
154            selected_index: 0,
155            should_quit: false,
156            final_selection: SelectionResult::None,
157            mode: AppMode::Normal,
158            status_message: None,
159            base_path: path.clone(),
160            theme,
161            editor_cmd,
162            wants_editor: false,
163            apply_date_prefix,
164            transparent_background,
165            show_new_option: false,
166            available_themes: themes,
167            theme_list_state: theme_state,
168            original_theme: None,
169            original_transparent_background: None,
170            config_path,
171            config_location_state: ListState::default(),
172            cached_free_space_mb: utils::get_free_disk_space_mb(&path),
173            folder_size_mb: Arc::new(AtomicU64::new(0)),
174        };
175
176        // Spawn background thread to calculate folder size
177        let folder_size_arc = Arc::clone(&app.folder_size_mb);
178        let path_clone = path.clone();
179        thread::spawn(move || {
180            let size = utils::get_folder_size_mb(&path_clone);
181            folder_size_arc.store(size, Ordering::Relaxed);
182        });
183
184        app.update_search();
185        app
186    }
187
188    pub fn has_exact_match(&self) -> bool {
189        self.all_entries.iter().any(|e| e.name == self.query)
190    }
191
192    pub fn update_search(&mut self) {
193        let matcher = SkimMatcherV2::default();
194
195        if self.query.is_empty() {
196            self.filtered_entries = self.all_entries.clone();
197        } else {
198            self.filtered_entries = self
199                .all_entries
200                .iter()
201                .filter_map(|entry| {
202                    matcher.fuzzy_match(&entry.name, &self.query).map(|score| {
203                        let mut e = entry.clone();
204                        e.score = score;
205                        e
206                    })
207                })
208                .collect();
209
210            self.filtered_entries.sort_by(|a, b| b.score.cmp(&a.score));
211        }
212        self.show_new_option = !self.query.is_empty() && !self.has_exact_match();
213        self.selected_index = 0;
214    }
215
216    pub fn delete_selected(&mut self) {
217        if let Some(entry_name) = self
218            .filtered_entries
219            .get(self.selected_index)
220            .map(|e| e.name.clone())
221        {
222            let path_to_remove = self.base_path.join(&entry_name);
223
224            // Only use git worktree remove if it's actually a worktree (not main working tree)
225            if utils::is_git_worktree(&path_to_remove) {
226                match utils::remove_git_worktree(&path_to_remove) {
227                    Ok(output) => {
228                        if output.status.success() {
229                            self.all_entries.retain(|e| e.name != entry_name);
230                            self.update_search();
231                            self.status_message =
232                                Some(format!("Worktree removed: {path_to_remove:?}"));
233                        } else {
234                            self.status_message = Some(format!(
235                                "Error deleting: {}",
236                                String::from_utf8_lossy(&output.stderr)
237                                    .lines()
238                                    .take(1)
239                                    .collect::<String>()
240                            ));
241                        }
242                    }
243                    Err(e) => {
244                        self.status_message = Some(format!("Error removing worktree: {}", e));
245                    }
246                };
247            } else {
248                // Regular directory or main git repo - just delete it
249                match fs::remove_dir_all(&path_to_remove) {
250                    Ok(_) => {
251                        self.all_entries.retain(|e| e.name != entry_name);
252                        self.update_search();
253                        self.status_message =
254                            Some(format!("Deleted: {}", path_to_remove.display()));
255                    }
256                    Err(e) => {
257                        self.status_message = Some(format!("Error deleting: {}", e));
258                    }
259                }
260            };
261        }
262        self.mode = AppMode::Normal;
263    }
264}
265
266fn draw_popup(f: &mut Frame, title: &str, message: &str, theme: &Theme) {
267    let area = f.area();
268
269    let popup_layout = Layout::default()
270        .direction(Direction::Vertical)
271        .constraints([
272            Constraint::Percentage(40),
273            Constraint::Length(8),
274            Constraint::Percentage(40),
275        ])
276        .split(area);
277
278    let popup_area = Layout::default()
279        .direction(Direction::Horizontal)
280        .constraints([
281            Constraint::Percentage(35),
282            Constraint::Percentage(30),
283            Constraint::Percentage(35),
284        ])
285        .split(popup_layout[1])[1];
286
287    f.render_widget(Clear, popup_area);
288
289    let block = Block::default()
290        .title(title)
291        .borders(Borders::ALL)
292        .style(Style::default().bg(theme.popup_bg));
293
294    // Vertically center the text inside the popup
295    let inner_height = popup_area.height.saturating_sub(2) as usize; // subtract borders
296    let text_lines = message.lines().count();
297    let top_padding = inner_height.saturating_sub(text_lines) / 2;
298    let padded_message = format!("{}{}", "\n".repeat(top_padding), message);
299
300    let paragraph = Paragraph::new(padded_message)
301        .block(block)
302        .style(
303            Style::default()
304                .fg(theme.popup_text)
305                .add_modifier(Modifier::BOLD),
306        )
307        .alignment(Alignment::Center);
308
309    f.render_widget(paragraph, popup_area);
310}
311
312fn draw_theme_select(f: &mut Frame, app: &mut App) {
313    let area = f.area();
314    let popup_layout = Layout::default()
315        .direction(Direction::Vertical)
316        .constraints([
317            Constraint::Percentage(25),
318            Constraint::Percentage(50),
319            Constraint::Percentage(25),
320        ])
321        .split(area);
322
323    let popup_area = Layout::default()
324        .direction(Direction::Horizontal)
325        .constraints([
326            Constraint::Percentage(25),
327            Constraint::Percentage(50),
328            Constraint::Percentage(25),
329        ])
330        .split(popup_layout[1])[1];
331
332    f.render_widget(Clear, popup_area);
333
334    // Split popup into theme list and transparency option
335    let inner_layout = Layout::default()
336        .direction(Direction::Vertical)
337        .constraints([Constraint::Min(3), Constraint::Length(3)])
338        .split(popup_area);
339
340    let block = Block::default()
341        .title(" Select Theme ")
342        .borders(Borders::ALL)
343        .style(Style::default().bg(app.theme.popup_bg));
344
345    let items: Vec<ListItem> = app
346        .available_themes
347        .iter()
348        .map(|t| {
349            ListItem::new(t.name.clone()).style(Style::default().fg(app.theme.list_highlight_fg))
350        })
351        .collect();
352
353    let list = List::new(items)
354        .block(block)
355        .highlight_style(
356            Style::default()
357                .bg(app.theme.list_highlight_bg)
358                .fg(app.theme.list_selected_fg)
359                .add_modifier(Modifier::BOLD),
360        )
361        .highlight_symbol(">> ");
362
363    f.render_stateful_widget(list, inner_layout[0], &mut app.theme_list_state);
364
365    // Draw transparency checkbox
366    let checkbox = if app.transparent_background {
367        "[x]"
368    } else {
369        "[ ]"
370    };
371    let transparency_text = format!(" {} Transparent Background (Space to toggle)", checkbox);
372    let transparency_block = Block::default()
373        .borders(Borders::ALL)
374        .style(Style::default().bg(app.theme.popup_bg));
375    let transparency_paragraph = Paragraph::new(transparency_text)
376        .style(Style::default().fg(app.theme.list_highlight_fg))
377        .block(transparency_block);
378    f.render_widget(transparency_paragraph, inner_layout[1]);
379}
380
381fn draw_config_location_select(f: &mut Frame, app: &mut App) {
382    let area = f.area();
383    let popup_layout = Layout::default()
384        .direction(Direction::Vertical)
385        .constraints([
386            Constraint::Percentage(40),
387            Constraint::Length(8),
388            Constraint::Percentage(40),
389        ])
390        .split(area);
391
392    let popup_area = Layout::default()
393        .direction(Direction::Horizontal)
394        .constraints([
395            Constraint::Percentage(20),
396            Constraint::Percentage(60),
397            Constraint::Percentage(20),
398        ])
399        .split(popup_layout[1])[1];
400
401    f.render_widget(Clear, popup_area);
402
403    let block = Block::default()
404        .title(" Select Config Location ")
405        .borders(Borders::ALL)
406        .style(Style::default().bg(app.theme.popup_bg));
407
408    let config_name = get_file_config_toml_name();
409    let items = vec![
410        ListItem::new(format!("System Config (~/.config/try-rs/{})", config_name))
411            .style(Style::default().fg(app.theme.list_highlight_fg)),
412        ListItem::new(format!("Home Directory (~/{})", config_name))
413            .style(Style::default().fg(app.theme.list_highlight_fg)),
414    ];
415
416    let list = List::new(items)
417        .block(block)
418        .highlight_style(
419            Style::default()
420                .bg(app.theme.list_highlight_bg)
421                .fg(app.theme.list_selected_fg)
422                .add_modifier(Modifier::BOLD),
423        )
424        .highlight_symbol(">> ");
425
426    f.render_stateful_widget(list, popup_area, &mut app.config_location_state);
427}
428
429fn draw_about_popup(f: &mut Frame, theme: &Theme) {
430    let area = f.area();
431    let popup_layout = Layout::default()
432        .direction(Direction::Vertical)
433        .constraints([
434            Constraint::Percentage(25),
435            Constraint::Length(12),
436            Constraint::Percentage(25),
437        ])
438        .split(area);
439
440    let popup_area = Layout::default()
441        .direction(Direction::Horizontal)
442        .constraints([
443            Constraint::Percentage(30),
444            Constraint::Percentage(40),
445            Constraint::Percentage(30),
446        ])
447        .split(popup_layout[1])[1];
448
449    f.render_widget(Clear, popup_area);
450
451    let block = Block::default()
452        .title(" About ")
453        .borders(Borders::ALL)
454        .style(Style::default().bg(theme.popup_bg));
455
456    let text = vec![
457        Line::from(vec![
458            Span::styled(
459                "🦀 try",
460                Style::default()
461                    .fg(theme.title_try)
462                    .add_modifier(Modifier::BOLD),
463            ),
464            Span::styled("-", Style::default().fg(Color::DarkGray)),
465            Span::styled(
466                "rs",
467                Style::default()
468                    .fg(theme.title_rs)
469                    .add_modifier(Modifier::BOLD),
470            ),
471            Span::styled(
472                format!(" v{}", env!("CARGO_PKG_VERSION")),
473                Style::default().fg(Color::DarkGray),
474            ),
475        ]),
476        Line::from(""),
477        Line::from(Span::styled(
478            "try-rs.org",
479            Style::default().fg(theme.search_title),
480        )),
481        Line::from(""),
482        Line::from(Span::styled(
483            "github.com/tassiovirginio/try-rs",
484            Style::default().fg(theme.search_title),
485        )),
486        Line::from(""),
487        Line::from(vec![
488            Span::styled("󰈙 License: ", Style::default().fg(theme.helpers_colors)),
489            Span::styled(
490                "MIT",
491                Style::default()
492                    .fg(theme.status_message)
493                    .add_modifier(Modifier::BOLD),
494            ),
495        ]),
496        Line::from(""),
497        Line::from(Span::styled(
498            "Press Esc to close",
499            Style::default().fg(theme.helpers_colors),
500        )),
501    ];
502
503    let paragraph = Paragraph::new(text)
504        .block(block)
505        .alignment(Alignment::Center);
506
507    f.render_widget(paragraph, popup_area);
508}
509
510pub fn run_app(
511    terminal: &mut Terminal<CrosstermBackend<io::Stderr>>,
512    mut app: App,
513) -> Result<(SelectionResult, bool)> {
514    while !app.should_quit {
515        terminal.draw(|f| {
516            // Render background if not transparent
517            if !app.transparent_background {
518                if let Some(bg_color) = app.theme.background {
519                    let background = Block::default().style(Style::default().bg(bg_color));
520                    f.render_widget(background, f.area());
521                }
522            }
523
524            let chunks = Layout::default()
525                .direction(Direction::Vertical)
526                .constraints([
527                    Constraint::Length(3),
528                    Constraint::Min(1),
529                    Constraint::Length(1),
530                ])
531                .split(f.area());
532
533            let content_chunks = Layout::default()
534                .direction(Direction::Horizontal)
535                .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
536                .split(chunks[1]);
537
538            let search_chunks = Layout::default()
539                .direction(Direction::Horizontal)
540                .constraints([Constraint::Min(20), Constraint::Length(45)])
541                .split(chunks[0]);
542
543            let search_text = Paragraph::new(app.query.clone())
544                .style(Style::default().fg(app.theme.search_title))
545                .block(
546                    Block::default()
547                        .borders(Borders::ALL)
548                        .title(Span::styled(
549                            " Search/New ",
550                            Style::default().fg(app.theme.search_title),
551                        ))
552                        .border_style(Style::default().fg(app.theme.search_border)),
553                );
554            f.render_widget(search_text, search_chunks[0]);
555
556            let free_space = app
557                .cached_free_space_mb
558                .map(|s| {
559                    if s >= 1000 {
560                        format!("{:.1} GB", s as f64 / 1024.0)
561                    } else {
562                        format!("{} MB", s)
563                    }
564                })
565                .unwrap_or_else(|| "N/A".to_string());
566
567            let folder_size = app.folder_size_mb.load(Ordering::Relaxed);
568            let folder_size_str = if folder_size == 0 {
569                "---".to_string()
570            } else if folder_size >= 1000 {
571                format!("{:.1} GB", folder_size as f64 / 1024.0)
572            } else {
573                format!("{} MB", folder_size)
574            };
575
576            let memory_info = Paragraph::new(Line::from(vec![
577                Span::styled("󰋊 ", Style::default().fg(app.theme.title_rs)),
578                Span::styled("Used: ", Style::default().fg(app.theme.helpers_colors)),
579                Span::styled(
580                    folder_size_str,
581                    Style::default().fg(app.theme.status_message),
582                ),
583                Span::styled(" | ", Style::default().fg(app.theme.helpers_colors)),
584                Span::styled("Free: ", Style::default().fg(app.theme.helpers_colors)),
585                Span::styled(free_space, Style::default().fg(app.theme.status_message)),
586            ]))
587            .block(
588                Block::default()
589                    .borders(Borders::ALL)
590                    .title(Span::styled(
591                        " Disk ",
592                        Style::default().fg(app.theme.disk_title),
593                    ))
594                    .border_style(Style::default().fg(app.theme.disk_border)),
595            )
596            .alignment(Alignment::Center);
597            f.render_widget(memory_info, search_chunks[1]);
598
599            let mut items: Vec<ListItem> = app
600                .filtered_entries
601                .iter()
602                .map(|entry| {
603                    let now = SystemTime::now();
604                    let elapsed = now
605                        .duration_since(entry.modified)
606                        .unwrap_or(std::time::Duration::ZERO);
607                    let secs = elapsed.as_secs();
608                    let days = secs / 86400;
609                    let hours = (secs % 86400) / 3600;
610                    let minutes = (secs % 3600) / 60;
611                    let date_str = format!("({:02}d {:02}h {:02}m)", days, hours, minutes);
612
613                    let width = content_chunks[0].width.saturating_sub(5) as usize;
614
615                    let date_text = date_str.to_string();
616                    let date_width = date_text.chars().count();
617
618                    // Build icon list: (flag, icon_str, color)
619                    let icons: &[(bool, &str, Color)] = &[
620                        (entry.is_cargo, " ", app.theme.icon_rust),
621                        (entry.is_maven, " ", app.theme.icon_maven),
622                        (entry.is_flutter, " ", app.theme.icon_flutter),
623                        (entry.is_go, " ", app.theme.icon_go),
624                        (entry.is_python, " ", app.theme.icon_python),
625                        (entry.is_mise, "󰬔 ", app.theme.icon_mise),
626                        (entry.is_worktree, "󰙅 ", app.theme.icon_worktree),
627                        (entry.is_worktree_locked, " ", app.theme.icon_worktree_lock),
628                        (entry.is_gitmodules, " ", app.theme.icon_gitmodules),
629                        (entry.is_git, " ", app.theme.icon_git),
630                    ];
631                    let icons_width: usize = icons.iter().filter(|(f, _, _)| *f).count() * 2;
632                    let icon_width = 2; // folder icon
633
634                    let created_dt: chrono::DateTime<Local> = entry.created.into();
635                    let created_text = created_dt.format("%Y-%m-%d").to_string();
636                    let created_width = created_text.chars().count();
637
638                    let reserved = date_width + icons_width + icon_width + created_width + 2;
639                    let available_for_name = width.saturating_sub(reserved);
640                    let name_len = entry.display_name.chars().count();
641
642                    let (display_name, padding) = if name_len > available_for_name {
643                        let safe_len = available_for_name.saturating_sub(3);
644                        let truncated: String = entry.display_name.chars().take(safe_len).collect();
645                        (format!("{}...", truncated), 1)
646                    } else {
647                        (
648                            entry.display_name.clone(),
649                            width.saturating_sub(
650                                icon_width
651                                    + created_width
652                                    + 1
653                                    + name_len
654                                    + date_width
655                                    + icons_width,
656                            ),
657                        )
658                    };
659
660                    let mut spans = vec![
661                        Span::styled(" 󰝰 ", Style::default().fg(app.theme.icon_folder)),
662                        Span::styled(created_text, Style::default().fg(app.theme.list_date)),
663                        Span::raw(format!(" {}", display_name)),
664                        Span::raw(" ".repeat(padding)),
665                    ];
666                    for &(flag, icon, color) in icons {
667                        if flag {
668                            spans.push(Span::styled(icon, Style::default().fg(color)));
669                        }
670                    }
671                    spans.push(Span::styled(
672                        date_text,
673                        Style::default().fg(app.theme.list_date),
674                    ));
675
676                    ListItem::new(Line::from(spans))
677                })
678                .collect();
679
680            // Append "new" option when no exact match
681            if app.show_new_option {
682                let new_item = ListItem::new(Line::from(vec![
683                    Span::styled("  ", Style::default().fg(app.theme.search_title)),
684                    Span::styled(
685                        format!("Create new: {}", app.query),
686                        Style::default()
687                            .fg(app.theme.search_title)
688                            .add_modifier(Modifier::ITALIC),
689                    ),
690                ]));
691                items.push(new_item);
692            }
693
694            let list = List::new(items)
695                .block(
696                    Block::default()
697                        .borders(Borders::ALL)
698                        .title(Span::styled(
699                            " Folders ",
700                            Style::default().fg(app.theme.folder_title),
701                        ))
702                        .border_style(Style::default().fg(app.theme.folder_border)),
703                )
704                .highlight_style(
705                    Style::default()
706                        .bg(app.theme.list_highlight_bg)
707                        .fg(app.theme.list_selected_fg)
708                        .add_modifier(Modifier::BOLD),
709                )
710                .highlight_symbol("→ ");
711
712            let mut state = ListState::default();
713            state.select(Some(app.selected_index));
714            f.render_stateful_widget(list, content_chunks[0], &mut state);
715
716            // Split right area between Preview and Icon Legend
717            let right_chunks = Layout::default()
718                .direction(Direction::Vertical)
719                .constraints([Constraint::Min(1), Constraint::Length(4)])
720                .split(content_chunks[1]);
721
722            // Check if "new" option is currently selected
723            let is_new_selected =
724                app.show_new_option && app.selected_index == app.filtered_entries.len();
725
726            if is_new_selected {
727                // Show "new folder" preview
728                let preview_lines = vec![Line::from(Span::styled(
729                    " (new folder) ",
730                    Style::default()
731                        .fg(app.theme.search_title)
732                        .add_modifier(Modifier::ITALIC),
733                ))];
734                let preview = Paragraph::new(preview_lines).block(
735                    Block::default()
736                        .borders(Borders::ALL)
737                        .title(Span::styled(
738                            " Preview ",
739                            Style::default().fg(app.theme.preview_title),
740                        ))
741                        .border_style(Style::default().fg(app.theme.preview_border)),
742                );
743                f.render_widget(preview, right_chunks[0]);
744            } else if let Some(selected) = app.filtered_entries.get(app.selected_index) {
745                let preview_path = app.base_path.join(&selected.name);
746                let mut preview_lines = Vec::new();
747
748                if let Ok(entries) = fs::read_dir(&preview_path) {
749                    for e in entries
750                        .take(right_chunks[0].height.saturating_sub(2) as usize)
751                        .flatten()
752                    {
753                        let file_name = e.file_name().to_string_lossy().to_string();
754                        let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
755                        let (icon, color) = if is_dir {
756                            ("󰝰 ", app.theme.icon_folder)
757                        } else {
758                            ("󰈙 ", app.theme.icon_file)
759                        };
760                        preview_lines.push(Line::from(vec![
761                            Span::styled(icon, Style::default().fg(color)),
762                            Span::raw(file_name),
763                        ]));
764                    }
765                }
766
767                if preview_lines.is_empty() {
768                    preview_lines.push(Line::from(Span::styled(
769                        " (empty) ",
770                        Style::default().fg(Color::DarkGray),
771                    )));
772                }
773
774                let preview = Paragraph::new(preview_lines).block(
775                    Block::default()
776                        .borders(Borders::ALL)
777                        .title(Span::styled(
778                            " Preview ",
779                            Style::default().fg(app.theme.preview_title),
780                        ))
781                        .border_style(Style::default().fg(app.theme.preview_border)),
782                );
783                f.render_widget(preview, right_chunks[0]);
784            } else {
785                let preview = Block::default()
786                    .borders(Borders::ALL)
787                    .title(Span::styled(
788                        " Preview ",
789                        Style::default().fg(app.theme.preview_title),
790                    ))
791                    .border_style(Style::default().fg(app.theme.preview_border));
792                f.render_widget(preview, right_chunks[0]);
793            }
794
795            // Icon legend
796            let legend_lines = vec![Line::from(vec![
797                Span::styled(" ", Style::default().fg(app.theme.icon_rust)),
798                Span::styled("Rust ", Style::default().fg(app.theme.helpers_colors)),
799                Span::styled(" ", Style::default().fg(app.theme.icon_maven)),
800                Span::styled("Maven ", Style::default().fg(app.theme.helpers_colors)),
801                Span::styled(" ", Style::default().fg(app.theme.icon_flutter)),
802                Span::styled("Flutter ", Style::default().fg(app.theme.helpers_colors)),
803                Span::styled(" ", Style::default().fg(app.theme.icon_go)),
804                Span::styled("Go ", Style::default().fg(app.theme.helpers_colors)),
805                Span::styled(" ", Style::default().fg(app.theme.icon_python)),
806                Span::styled("Python ", Style::default().fg(app.theme.helpers_colors)),
807                Span::styled("󰬔 ", Style::default().fg(app.theme.icon_mise)),
808                Span::styled("Mise ", Style::default().fg(app.theme.helpers_colors)),
809                Span::styled(" ", Style::default().fg(app.theme.icon_worktree_lock)),
810                Span::styled("Locked ", Style::default().fg(app.theme.helpers_colors)),
811                Span::styled("󰙅 ", Style::default().fg(app.theme.icon_worktree)),
812                Span::styled(
813                    "Git-Worktree ",
814                    Style::default().fg(app.theme.helpers_colors),
815                ),
816                Span::styled(" ", Style::default().fg(app.theme.icon_gitmodules)),
817                Span::styled("Git-Submod ", Style::default().fg(app.theme.helpers_colors)),
818                Span::styled(" ", Style::default().fg(app.theme.icon_git)),
819                Span::styled("Git ", Style::default().fg(app.theme.helpers_colors)),
820            ])];
821
822            let legend = Paragraph::new(legend_lines)
823                .block(
824                    Block::default()
825                        .borders(Borders::ALL)
826                        .title(Span::styled(
827                            " Legends ",
828                            Style::default().fg(app.theme.legends_title),
829                        ))
830                        .border_style(Style::default().fg(app.theme.legends_border)),
831                )
832                .alignment(Alignment::Left)
833                .wrap(Wrap { trim: true });
834            f.render_widget(legend, right_chunks[1]);
835
836            let help_text = if let Some(msg) = &app.status_message {
837                Line::from(vec![Span::styled(
838                    msg,
839                    Style::default()
840                        .fg(app.theme.status_message)
841                        .add_modifier(Modifier::BOLD),
842                )])
843            } else {
844                Line::from(vec![
845                    Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
846                    Span::raw(" Nav | "),
847                    Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
848                    Span::raw(" Select | "),
849                    Span::styled("Ctrl-D", Style::default().add_modifier(Modifier::BOLD)),
850                    Span::raw(" Del | "),
851                    Span::styled("Ctrl-E", Style::default().add_modifier(Modifier::BOLD)),
852                    Span::raw(" Edit | "),
853                    Span::styled("Ctrl-T", Style::default().add_modifier(Modifier::BOLD)),
854                    Span::raw(" Theme | "),
855                    Span::styled("Ctrl-A", Style::default().add_modifier(Modifier::BOLD)),
856                    Span::raw(" About | "),
857                    Span::styled("Esc/Ctrl+C", Style::default().add_modifier(Modifier::BOLD)),
858                    Span::raw(" Quit"),
859                ])
860            };
861
862            let help_message = Paragraph::new(help_text)
863                .style(Style::default().fg(app.theme.helpers_colors))
864                .alignment(Alignment::Center);
865
866            f.render_widget(help_message, chunks[2]);
867
868            if app.mode == AppMode::DeleteConfirm
869                && let Some(selected) = app.filtered_entries.get(app.selected_index)
870            {
871                let msg = format!("Delete '{}'?\n(y/n)", selected.name);
872                draw_popup(f, " WARNING ", &msg, &app.theme);
873            }
874
875            if app.mode == AppMode::ThemeSelect {
876                draw_theme_select(f, &mut app);
877            }
878
879            if app.mode == AppMode::ConfigSavePrompt {
880                draw_popup(
881                    f,
882                    " Create Config? ",
883                    "Config file not found.\nCreate one now to save theme? (y/n)",
884                    &app.theme,
885                );
886            }
887
888            if app.mode == AppMode::ConfigSaveLocationSelect {
889                draw_config_location_select(f, &mut app);
890            }
891
892            if app.mode == AppMode::About {
893                draw_about_popup(f, &app.theme);
894            }
895        })?;
896
897        // Poll with 1-second timeout so the screen refreshes periodically
898        if !event::poll(std::time::Duration::from_secs(1))? {
899            continue;
900        }
901        if let Event::Key(key) = event::read()? {
902            if !key.is_press() {
903                continue;
904            }
905            // Clear status message on any key press so it disappears after one redraw
906            app.status_message = None;
907            match app.mode {
908                AppMode::Normal => match key.code {
909                    KeyCode::Char(c) => {
910                        if c == 'c' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
911                            app.should_quit = true;
912                        } else if c == 'd' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
913                            let is_new_selected = app.show_new_option
914                                && app.selected_index == app.filtered_entries.len();
915                            if !app.filtered_entries.is_empty() && !is_new_selected {
916                                app.mode = AppMode::DeleteConfirm;
917                            }
918                        } else if c == 'e' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
919                            if app.editor_cmd.is_some() {
920                                let is_new_selected = app.show_new_option
921                                    && app.selected_index == app.filtered_entries.len();
922                                if is_new_selected {
923                                    app.final_selection = SelectionResult::New(app.query.clone());
924                                    app.wants_editor = true;
925                                    app.should_quit = true;
926                                } else if !app.filtered_entries.is_empty() {
927                                    app.final_selection = SelectionResult::Folder(
928                                        app.filtered_entries[app.selected_index].name.clone(),
929                                    );
930                                    app.wants_editor = true;
931                                    app.should_quit = true;
932                                } else if !app.query.is_empty() {
933                                    app.final_selection = SelectionResult::New(app.query.clone());
934                                    app.wants_editor = true;
935                                    app.should_quit = true;
936                                }
937                            } else {
938                                app.status_message =
939                                    Some("No editor configured in config.toml".to_string());
940                            }
941                        } else if c == 't' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
942                            // Save current theme and transparency before opening selector
943                            app.original_theme = Some(app.theme.clone());
944                            app.original_transparent_background = Some(app.transparent_background);
945                            // Find and select current theme in the list
946                            let current_idx = app
947                                .available_themes
948                                .iter()
949                                .position(|t| t.name == app.theme.name)
950                                .unwrap_or(0);
951                            app.theme_list_state.select(Some(current_idx));
952                            app.mode = AppMode::ThemeSelect;
953                        } else if c == 'a' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
954                            app.mode = AppMode::About;
955                        } else if matches!(c, 'k' | 'p')
956                            && key.modifiers.contains(event::KeyModifiers::CONTROL)
957                        {
958                            if app.selected_index > 0 {
959                                app.selected_index -= 1;
960                            }
961                        } else if matches!(c, 'j' | 'n')
962                            && key.modifiers.contains(event::KeyModifiers::CONTROL)
963                        {
964                            let max_index = if app.show_new_option {
965                                app.filtered_entries.len()
966                            } else {
967                                app.filtered_entries.len().saturating_sub(1)
968                            };
969                            if app.selected_index < max_index {
970                                app.selected_index += 1;
971                            }
972                        } else if c == 'u' && key.modifiers.contains(event::KeyModifiers::CONTROL) {
973                            app.query.clear();
974                            app.update_search();
975                        } else if key.modifiers.is_empty()
976                            || key.modifiers == event::KeyModifiers::SHIFT
977                        {
978                            app.query.push(c);
979                            app.update_search();
980                        }
981                    }
982                    KeyCode::Backspace => {
983                        app.query.pop();
984                        app.update_search();
985                    }
986                    KeyCode::Up => {
987                        if app.selected_index > 0 {
988                            app.selected_index -= 1;
989                        }
990                    }
991                    KeyCode::Down => {
992                        let max_index = if app.show_new_option {
993                            app.filtered_entries.len()
994                        } else {
995                            app.filtered_entries.len().saturating_sub(1)
996                        };
997                        if app.selected_index < max_index {
998                            app.selected_index += 1;
999                        }
1000                    }
1001                    KeyCode::Enter => {
1002                        let is_new_selected =
1003                            app.show_new_option && app.selected_index == app.filtered_entries.len();
1004                        if is_new_selected {
1005                            app.final_selection = SelectionResult::New(app.query.clone());
1006                        } else if !app.filtered_entries.is_empty() {
1007                            app.final_selection = SelectionResult::Folder(
1008                                app.filtered_entries[app.selected_index].name.clone(),
1009                            );
1010                        } else if !app.query.is_empty() {
1011                            app.final_selection = SelectionResult::New(app.query.clone());
1012                        }
1013                        app.should_quit = true;
1014                    }
1015                    KeyCode::Esc => app.should_quit = true,
1016                    _ => {}
1017                },
1018
1019                AppMode::DeleteConfirm => match key.code {
1020                    KeyCode::Char('y') | KeyCode::Char('Y') => {
1021                        app.delete_selected();
1022                    }
1023                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1024                        app.mode = AppMode::Normal;
1025                    }
1026                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1027                        app.should_quit = true;
1028                    }
1029                    _ => {}
1030                },
1031
1032                AppMode::ThemeSelect => match key.code {
1033                    KeyCode::Char(' ') => {
1034                        // Toggle transparent background
1035                        app.transparent_background = !app.transparent_background;
1036                    }
1037                    KeyCode::Esc => {
1038                        // Restore original theme and transparency
1039                        if let Some(original) = app.original_theme.take() {
1040                            app.theme = original;
1041                        }
1042                        if let Some(original_transparent) =
1043                            app.original_transparent_background.take()
1044                        {
1045                            app.transparent_background = original_transparent;
1046                        }
1047                        app.mode = AppMode::Normal;
1048                    }
1049                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1050                        // Restore original theme and transparency
1051                        if let Some(original) = app.original_theme.take() {
1052                            app.theme = original;
1053                        }
1054                        if let Some(original_transparent) =
1055                            app.original_transparent_background.take()
1056                        {
1057                            app.transparent_background = original_transparent;
1058                        }
1059                        app.mode = AppMode::Normal;
1060                    }
1061                    KeyCode::Up | KeyCode::Char('k' | 'p') => {
1062                        let i = match app.theme_list_state.selected() {
1063                            Some(i) => {
1064                                if i > 0 {
1065                                    i - 1
1066                                } else {
1067                                    i
1068                                }
1069                            }
1070                            None => 0,
1071                        };
1072                        app.theme_list_state.select(Some(i));
1073                        // Apply theme preview
1074                        if let Some(theme) = app.available_themes.get(i) {
1075                            app.theme = theme.clone();
1076                        }
1077                    }
1078                    KeyCode::Down | KeyCode::Char('j' | 'n') => {
1079                        let i = match app.theme_list_state.selected() {
1080                            Some(i) => {
1081                                if i < app.available_themes.len() - 1 {
1082                                    i + 1
1083                                } else {
1084                                    i
1085                                }
1086                            }
1087                            None => 0,
1088                        };
1089                        app.theme_list_state.select(Some(i));
1090                        // Apply theme preview
1091                        if let Some(theme) = app.available_themes.get(i) {
1092                            app.theme = theme.clone();
1093                        }
1094                    }
1095                    KeyCode::Enter => {
1096                        // Clear original theme and transparency (we're confirming the new values)
1097                        app.original_theme = None;
1098                        app.original_transparent_background = None;
1099                        if let Some(i) = app.theme_list_state.selected() {
1100                            if let Some(theme) = app.available_themes.get(i) {
1101                                app.theme = theme.clone();
1102
1103                                if let Some(ref path) = app.config_path {
1104                                    if let Err(e) = save_config(
1105                                        path,
1106                                        &app.theme,
1107                                        &app.base_path,
1108                                        &app.editor_cmd,
1109                                        app.apply_date_prefix,
1110                                        Some(app.transparent_background),
1111                                    ) {
1112                                        app.status_message = Some(format!("Error saving: {}", e));
1113                                    } else {
1114                                        app.status_message = Some("Theme saved.".to_string());
1115                                    }
1116                                    app.mode = AppMode::Normal;
1117                                } else {
1118                                    app.mode = AppMode::ConfigSavePrompt;
1119                                }
1120                            } else {
1121                                app.mode = AppMode::Normal;
1122                            }
1123                        } else {
1124                            app.mode = AppMode::Normal;
1125                        }
1126                    }
1127                    _ => {}
1128                },
1129                AppMode::ConfigSavePrompt => match key.code {
1130                    KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
1131                        app.mode = AppMode::ConfigSaveLocationSelect;
1132                        app.config_location_state.select(Some(0));
1133                    }
1134                    KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1135                        app.mode = AppMode::Normal;
1136                    }
1137                    _ => {}
1138                },
1139
1140                AppMode::ConfigSaveLocationSelect => match key.code {
1141                    KeyCode::Esc | KeyCode::Char('c')
1142                        if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
1143                    {
1144                        app.mode = AppMode::Normal;
1145                    }
1146                    KeyCode::Up | KeyCode::Char('k' | 'p') => {
1147                        let i = match app.config_location_state.selected() {
1148                            Some(i) => {
1149                                if i > 0 {
1150                                    i - 1
1151                                } else {
1152                                    i
1153                                }
1154                            }
1155                            None => 0,
1156                        };
1157                        app.config_location_state.select(Some(i));
1158                    }
1159                    KeyCode::Down | KeyCode::Char('j' | 'n') => {
1160                        let i = match app.config_location_state.selected() {
1161                            Some(i) => {
1162                                if i < 1 {
1163                                    i + 1
1164                                } else {
1165                                    i
1166                                }
1167                            }
1168                            None => 0,
1169                        };
1170                        app.config_location_state.select(Some(i));
1171                    }
1172                    KeyCode::Enter => {
1173                        if let Some(i) = app.config_location_state.selected() {
1174                            let config_name = get_file_config_toml_name();
1175                            let path = if i == 0 {
1176                                dirs::config_dir()
1177                                    .unwrap_or_else(|| {
1178                                        dirs::home_dir().expect("Folder not found").join(".config")
1179                                    })
1180                                    .join("try-rs")
1181                                    .join(&config_name)
1182                            } else {
1183                                dirs::home_dir()
1184                                    .expect("Folder not found")
1185                                    .join(&config_name)
1186                            };
1187
1188                            if let Err(e) = save_config(
1189                                &path,
1190                                &app.theme,
1191                                &app.base_path,
1192                                &app.editor_cmd,
1193                                app.apply_date_prefix,
1194                                Some(app.transparent_background),
1195                            ) {
1196                                app.status_message = Some(format!("Error saving config: {}", e));
1197                            } else {
1198                                app.config_path = Some(path);
1199                                app.status_message = Some("Theme saved!".to_string());
1200                            }
1201                        }
1202                        app.mode = AppMode::Normal;
1203                    }
1204                    _ => {}
1205                },
1206                AppMode::About => match key.code {
1207                    KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char(' ') => {
1208                        app.mode = AppMode::Normal;
1209                    }
1210                    KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
1211                        app.mode = AppMode::Normal;
1212                    }
1213                    _ => {}
1214                },
1215            }
1216        }
1217    }
1218
1219    Ok((app.final_selection, app.wants_editor))
1220}