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