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