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 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 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 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 let inner_height = popup_area.height.saturating_sub(2) as usize; 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 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 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 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 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; 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 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 let is_new_selected =
1065 app.show_new_option && app.selected_index == app.filtered_entries.len();
1066
1067 if is_new_selected {
1068 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 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 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 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 app.original_theme = Some(app.theme.clone());
1301 app.original_transparent_background = Some(app.transparent_background);
1302 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 app.transparent_background = !app.transparent_background;
1416 }
1417 KeyCode::Esc => {
1418 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 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 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 if let Some(theme) = app.available_themes.get(i) {
1472 app.theme = theme.clone();
1473 }
1474 }
1475 KeyCode::Enter => {
1476 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}