pub mod actions;
pub mod app;
pub mod clipboard;
pub mod events;
pub mod format;
pub mod help;
pub mod theme;
pub mod ui;
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config as NucleoConfig, Matcher, Utf32Str};
use ratatui::backend::CrosstermBackend;
use ratatui::{Frame, Terminal};
use std::fs::{File, OpenOptions};
use crate::db::CommandRecord;
use actions::{DbBackend, TuiBackend};
use app::{TuiApp, TuiFilters};
pub fn run_interactive(
records: Vec<CommandRecord>,
filters: TuiFilters,
initial_query: String,
limit: usize,
) -> Result<Option<String>> {
let mut backend = DbBackend::open(limit)?;
let mut app = TuiApp::new(records, filters, initial_query);
if let Ok(cwd) = std::env::current_dir() {
let ctx = crate::gitctx::detect(&cwd);
let project = ctx
.root
.as_deref()
.map(|r| app::last_segment(r).to_string());
app.set_current_context(project, ctx.branch);
}
let mut terminal = setup_terminal()?;
let result = event_loop(&mut terminal, &mut app, &mut backend);
restore_terminal(&mut terminal)?;
result?;
Ok(app.outcome.clone())
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<File>>> {
enable_raw_mode()?;
let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
execute!(tty, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(tty);
Ok(Terminal::new(backend)?)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<File>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn event_loop<B: TuiBackend>(
terminal: &mut Terminal<CrosstermBackend<File>>,
app: &mut TuiApp,
backend: &mut B,
) -> Result<()> {
loop {
terminal.draw(|f: &mut Frame| ui::render(f, app))?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
let action = events::map_key(app.mode, key.code, key.modifiers);
app.dispatch(action, backend);
if app.should_quit {
break;
}
}
}
Ok(())
}
pub fn fuzzy_filter(records: &[CommandRecord], query: &str, matcher: &mut Matcher) -> Vec<usize> {
if query.trim().is_empty() {
return (0..records.len()).collect();
}
let pattern = Pattern::parse(query.trim(), CaseMatching::Ignore, Normalization::Smart);
let mut buf: Vec<char> = Vec::new();
let mut scored: Vec<(usize, u32)> = records
.iter()
.enumerate()
.filter_map(|(i, r)| {
let haystack = Utf32Str::new(&r.command, &mut buf);
pattern.score(haystack, matcher).map(|score| (i, score))
})
.collect();
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
scored.into_iter().map(|(i, _)| i).collect()
}
pub fn search_print(records: &[CommandRecord], query: &str, limit: usize) -> Vec<String> {
let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
fuzzy_filter(records, query, &mut matcher)
.into_iter()
.take(limit)
.map(|i| records[i].command.clone())
.collect()
}
pub fn search_records<'a>(
records: &'a [CommandRecord],
query: &str,
limit: usize,
) -> Vec<&'a CommandRecord> {
let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
fuzzy_filter(records, query, &mut matcher)
.into_iter()
.take(limit)
.map(|i| &records[i])
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::CommandRecord;
fn rec(id: i64, command: &str) -> CommandRecord {
CommandRecord {
id,
command: command.to_string(),
cwd: None,
shell: None,
hostname: None,
exit_code: None,
created_at: "2026-06-13 10:00:00".to_string(),
git_root: None,
git_branch: None,
git_remote: None,
session_id: None,
}
}
fn sample() -> Vec<CommandRecord> {
vec![
rec(1, "cargo build --release"),
rec(2, "git status"),
rec(3, "cargo test"),
rec(4, "ls -la"),
]
}
#[test]
fn print_filtre_par_requete() {
let records = sample();
let out = search_print(&records, "cargo", 10);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|c| c.contains("cargo")));
}
#[test]
fn print_respecte_la_limite() {
let records = sample();
let out = search_print(&records, "", 2);
assert_eq!(out.len(), 2);
}
#[test]
fn requete_vide_renvoie_tout() {
let records = sample();
let out = search_print(&records, " ", 100);
assert_eq!(out.len(), records.len());
}
}