Skip to main content

mnemo/
tui.rs

1//! Interface TUI de mnemo.
2//!
3//! Le module racine gère le terminal et la boucle d'événements ; la logique est
4//! répartie dans des sous-modules testables :
5//! - [`app`] : modèle et logique métier (navigation, filtres, suppression) ;
6//! - [`events`] : mapping clavier -> action ;
7//! - [`actions`] : énumération des actions et accès base isolé ;
8//! - [`ui`] : rendu Ratatui ;
9//! - [`theme`] : palette et styles centralisés ;
10//! - [`format`] : helpers de formatage purs et testables ;
11//! - [`help`] : texte d'aide ;
12//! - [`clipboard`] : copie système optionnelle.
13
14pub mod actions;
15pub mod app;
16pub mod clipboard;
17pub mod events;
18pub mod format;
19pub mod help;
20pub mod theme;
21pub mod ui;
22
23use anyhow::Result;
24use crossterm::event::{self, Event, KeyEventKind};
25use crossterm::execute;
26use crossterm::terminal::{
27    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
28};
29use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
30use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str};
31use ratatui::backend::CrosstermBackend;
32use ratatui::{Frame, Terminal};
33use std::fs::{File, OpenOptions};
34
35use crate::db::CommandRecord;
36use actions::{DbBackend, TuiBackend};
37use app::{TuiApp, TuiFilters};
38
39/// Lance la TUI avancée. Retourne la commande sélectionnée (Entrée), ou `None`
40/// si l'utilisateur a quitté.
41///
42/// `limit` borne le rechargement (`r`). `backend` est injecté pour rester
43/// testable, mais l'appelant utilise normalement [`DbBackend`].
44pub fn run_interactive(
45    records: Vec<CommandRecord>,
46    filters: TuiFilters,
47    initial_query: String,
48    limit: usize,
49) -> Result<Option<String>> {
50    let mut backend = DbBackend::open(limit)?;
51    let mut app = TuiApp::new(records, filters, initial_query);
52
53    // Contexte Git du dossier de lancement pour les filtres « projet/branche
54    // courant(e) » (`p` / `b`). Détection best-effort : aucune erreur bloquante.
55    if let Ok(cwd) = std::env::current_dir() {
56        let ctx = crate::gitctx::detect(&cwd);
57        let project = ctx
58            .root
59            .as_deref()
60            .map(|r| app::last_segment(r).to_string());
61        app.set_current_context(project, ctx.branch);
62    }
63
64    let mut terminal = setup_terminal()?;
65    let result = event_loop(&mut terminal, &mut app, &mut backend);
66    restore_terminal(&mut terminal)?;
67    result?;
68
69    Ok(app.outcome.clone())
70}
71
72/// On rend l'interface sur `/dev/tty` (et non stdout) afin que `mnemo search`
73/// fonctionne dans une substitution de commande `$(mnemo search)`.
74fn setup_terminal() -> Result<Terminal<CrosstermBackend<File>>> {
75    enable_raw_mode()?;
76    let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
77    execute!(tty, EnterAlternateScreen)?;
78    let backend = CrosstermBackend::new(tty);
79    Ok(Terminal::new(backend)?)
80}
81
82fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<File>>) -> Result<()> {
83    disable_raw_mode()?;
84    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
85    terminal.show_cursor()?;
86    Ok(())
87}
88
89fn event_loop<B: TuiBackend>(
90    terminal: &mut Terminal<CrosstermBackend<File>>,
91    app: &mut TuiApp,
92    backend: &mut B,
93) -> Result<()> {
94    loop {
95        terminal.draw(|f: &mut Frame| ui::render(f, app))?;
96
97        if let Event::Key(key) = event::read()? {
98            if key.kind != KeyEventKind::Press {
99                continue;
100            }
101            let action = events::map_key(app.mode, key.code, key.modifiers);
102            app.dispatch(action, backend);
103            if app.should_quit {
104                break;
105            }
106        }
107    }
108    Ok(())
109}
110
111/// Filtre fuzzy partagé avec le mode `--print`.
112///
113/// Retourne les indices des `records` correspondant à `query`, triés par score
114/// décroissant. Une requête vide renvoie tous les indices dans l'ordre reçu.
115pub fn fuzzy_filter(records: &[CommandRecord], query: &str, matcher: &mut Matcher) -> Vec<usize> {
116    if query.trim().is_empty() {
117        return (0..records.len()).collect();
118    }
119    let pattern = Pattern::parse(query.trim(), CaseMatching::Ignore, Normalization::Smart);
120    let mut buf: Vec<char> = Vec::new();
121    let mut scored: Vec<(usize, u32)> = records
122        .iter()
123        .enumerate()
124        .filter_map(|(i, r)| {
125            let haystack = Utf32Str::new(&r.command, &mut buf);
126            pattern.score(haystack, matcher).map(|score| (i, score))
127        })
128        .collect();
129    scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
130    scored.into_iter().map(|(i, _)| i).collect()
131}
132
133/// Recherche non interactive : renvoie les commandes correspondantes (au plus
134/// `limit`), pour le mode `--print` et les scripts/CI.
135pub fn search_print(records: &[CommandRecord], query: &str, limit: usize) -> Vec<String> {
136    let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
137    fuzzy_filter(records, query, &mut matcher)
138        .into_iter()
139        .take(limit)
140        .map(|i| records[i].command.clone())
141        .collect()
142}
143
144/// Variante de [`search_print`] conservant les enregistrements complets, pour la
145/// sortie JSON stable de `mnemo search --print --json`.
146pub fn search_records<'a>(
147    records: &'a [CommandRecord],
148    query: &str,
149    limit: usize,
150) -> Vec<&'a CommandRecord> {
151    let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
152    fuzzy_filter(records, query, &mut matcher)
153        .into_iter()
154        .take(limit)
155        .map(|i| &records[i])
156        .collect()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::db::CommandRecord;
163
164    fn rec(id: i64, command: &str) -> CommandRecord {
165        CommandRecord {
166            id,
167            command: command.to_string(),
168            cwd: None,
169            shell: None,
170            hostname: None,
171            exit_code: None,
172            created_at: "2026-06-13 10:00:00".to_string(),
173            git_root: None,
174            git_branch: None,
175            git_remote: None,
176            session_id: None,
177        }
178    }
179
180    fn sample() -> Vec<CommandRecord> {
181        vec![
182            rec(1, "cargo build --release"),
183            rec(2, "git status"),
184            rec(3, "cargo test"),
185            rec(4, "ls -la"),
186        ]
187    }
188
189    #[test]
190    fn print_filtre_par_requete() {
191        let records = sample();
192        let out = search_print(&records, "cargo", 10);
193        assert_eq!(out.len(), 2);
194        assert!(out.iter().all(|c| c.contains("cargo")));
195    }
196
197    #[test]
198    fn print_respecte_la_limite() {
199        let records = sample();
200        let out = search_print(&records, "", 2);
201        assert_eq!(out.len(), 2);
202    }
203
204    #[test]
205    fn requete_vide_renvoie_tout() {
206        let records = sample();
207        let out = search_print(&records, "   ", 100);
208        assert_eq!(out.len(), records.len());
209    }
210}