use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
cursor::SetCursorStyle,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode, disable_raw_mode},
};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
use std::io;
use std::time::Duration;
use crate::db::{HistoryEntry, query_frequent, query_recent};
#[derive(Debug, Clone)]
pub enum Action {
Insert(String),
Execute(String),
Quit,
}
pub struct App {
pub frequent_mode: bool,
pub keyword: String,
pub cursor_pos: usize,
pub number_buf: String,
pub selected: usize,
pub cwd: String,
pub entries: Vec<HistoryEntry>,
pub limit: usize,
pub dedup: bool,
pub action: Option<Action>,
pub quit: bool,
pub num_mode: bool,
pub limit_mode: bool,
pub limit_input: String,
pub notification: Option<String>,
pub notification_timer: u8,
pub show_help: bool,
pub total_count: usize,
pub scroll_offset: usize,
}
impl App {
pub fn new(cwd: String, limit: usize, dedup: bool) -> Self {
Self {
frequent_mode: false,
keyword: String::new(),
cursor_pos: 0,
number_buf: String::new(),
selected: 0,
cwd,
entries: Vec::new(),
limit,
dedup,
action: None,
quit: false,
num_mode: false,
limit_mode: false,
limit_input: String::new(),
notification: None,
notification_timer: 0,
show_help: false,
total_count: 0,
scroll_offset: 0,
}
}
pub fn load_entries(&mut self, conn: &rusqlite::Connection) {
if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get::<_, usize>(0)) {
self.total_count = count;
}
let query_limit = if !self.frequent_mode && self.dedup {
usize::MAX
} else {
self.limit
};
let result = if self.frequent_mode {
query_frequent(conn, &self.keyword, &self.cwd, query_limit)
} else {
query_recent(conn, &self.keyword, &self.cwd, query_limit)
};
match result {
Ok(entries) => {
let entries = if !self.frequent_mode && self.dedup {
let entries = dedup_consecutive(entries);
entries.into_iter().take(self.limit).collect()
} else {
entries
};
self.entries = entries;
if self.entries.is_empty() {
self.selected = 0;
} else if self.selected >= self.entries.len() {
self.selected = self.entries.len() - 1;
}
}
Err(_e) => {
self.entries.clear();
self.selected = 0;
}
}
}
pub fn move_up(&mut self) {
if !self.entries.is_empty() {
if self.selected == 0 {
self.selected = self.entries.len() - 1;
} else {
self.selected -= 1;
}
}
}
pub fn move_down(&mut self) {
if !self.entries.is_empty() {
if self.selected >= self.entries.len() - 1 {
self.selected = 0;
} else {
self.selected += 1;
}
}
}
pub fn toggle_mode(&mut self) {
self.frequent_mode = !self.frequent_mode;
}
pub fn push_digit(&mut self, digit: char) {
self.number_buf.push(digit);
}
pub fn jump_to_number(&mut self) -> bool {
if self.number_buf.is_empty() {
return false;
}
if let Ok(n) = self.number_buf.parse::<usize>() {
if n > 0 && n <= self.entries.len() {
self.selected = n - 1;
}
}
self.number_buf.clear();
true
}
pub fn select_execute(&mut self) {
if let Some(entry) = self.entries.get(self.selected) {
self.action = Some(Action::Execute(entry.command.clone()));
self.quit = true;
}
}
pub fn select_insert(&mut self) {
if let Some(entry) = self.entries.get(self.selected) {
self.action = Some(Action::Insert(entry.command.clone()));
self.quit = true;
}
}
}
pub fn run(
conn: &rusqlite::Connection,
cwd: &str,
limit: usize,
dedup: bool,
) -> io::Result<Action> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, SetCursorStyle::BlinkingBar)?;
unsafe { libc::tcflush(0, libc::TCIFLUSH) };
let backend = ratatui::backend::CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(cwd.to_string(), limit, dedup);
app.load_entries(conn);
let result = run_loop(&mut terminal, &mut app, conn);
execute!(io::stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
terminal.show_cursor()?;
result?;
Ok(app.action.unwrap_or(Action::Quit))
}
fn run_loop(
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
app: &mut App,
conn: &rusqlite::Connection,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, app))?;
terminal.show_cursor()?;
if app.notification_timer > 0 {
app.notification_timer -= 1;
if app.notification_timer == 0 {
app.notification = None;
}
}
if app.quit {
return Ok(());
}
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Release {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.action = Some(Action::Quit);
app.quit = true;
continue;
}
match key.code {
_ if app.show_help => {
match key.code {
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::ALT) => {
app.show_help = false;
}
_ => {
app.show_help = false;
}
}
}
KeyCode::Esc => {
if app.limit_mode {
app.limit_mode = false;
app.limit_input.clear();
} else if app.num_mode {
app.num_mode = false;
app.number_buf.clear();
} else {
app.action = Some(Action::Quit);
app.quit = true;
}
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::ALT) => {
app.toggle_mode();
let mode_name = if app.frequent_mode { "频率顺序" } else { "时间顺序" };
app.notification = Some(mode_name.to_string());
app.notification_timer = 40;
app.load_entries(conn);
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::ALT) => {
app.num_mode = !app.num_mode;
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::ALT) => {
app.show_help = !app.show_help;
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::ALT) => {
app.limit_mode = true;
app.limit_input.clear();
}
KeyCode::Char(c) if app.limit_mode && c.is_ascii_digit() => {
app.limit_input.push(c);
}
KeyCode::Backspace if app.limit_mode => {
app.limit_input.pop();
}
KeyCode::Enter if app.limit_mode => {
if let Ok(n) = app.limit_input.parse::<usize>() {
let n = n.clamp(1, 100);
app.limit = n;
app.notification = Some(format!("已设置为 {} 条", n));
app.notification_timer = 60;
}
app.limit_mode = false;
app.limit_input.clear();
app.load_entries(conn);
}
KeyCode::Char(c) if app.num_mode && c.is_ascii_digit() => {
app.push_digit(c);
}
KeyCode::Enter if app.num_mode => {
app.jump_to_number();
app.num_mode = false;
}
KeyCode::Up => {
app.move_up();
}
KeyCode::Down => {
app.move_down();
}
KeyCode::Left => {
if app.cursor_pos > 0 {
let mut prev = app.cursor_pos - 1;
while prev > 0 && !app.keyword.is_char_boundary(prev) {
prev -= 1;
}
app.cursor_pos = prev;
}
}
KeyCode::Right => {
if app.cursor_pos < app.keyword.len() {
let mut next = app.cursor_pos + 1;
while next < app.keyword.len() && !app.keyword.is_char_boundary(next) {
next += 1;
}
app.cursor_pos = next;
}
}
KeyCode::Home => {
app.cursor_pos = 0;
}
KeyCode::End => {
app.cursor_pos = app.keyword.len();
}
KeyCode::PageDown => {
if !app.entries.is_empty() {
let page = 10usize;
app.selected = (app.selected + page).min(app.entries.len() - 1);
app.scroll_offset = (app.scroll_offset + page).min(app.entries.len().saturating_sub(20));
}
}
KeyCode::PageUp => {
let page = 10usize;
app.selected = app.selected.saturating_sub(page);
app.scroll_offset = app.scroll_offset.saturating_sub(page);
}
KeyCode::Tab => {
if !app.jump_to_number() {
app.select_insert();
}
}
KeyCode::Enter => {
if !app.jump_to_number() {
app.select_execute();
}
}
KeyCode::Backspace => {
if !app.number_buf.is_empty() {
app.number_buf.clear();
} else if app.cursor_pos > 0 {
let mut prev = app.cursor_pos - 1;
while prev > 0 && !app.keyword.is_char_boundary(prev) {
prev -= 1;
}
app.keyword.remove(prev);
app.cursor_pos = prev;
app.load_entries(conn);
}
}
KeyCode::Delete => {
if app.cursor_pos < app.keyword.len() {
let mut next = app.cursor_pos + 1;
while next < app.keyword.len() && !app.keyword.is_char_boundary(next) {
next += 1;
}
app.keyword.drain(app.cursor_pos..next);
app.load_entries(conn);
}
}
KeyCode::Char(c) => {
if c.is_ascii_graphic() || c == ' ' {
app.number_buf.clear();
app.keyword.insert(app.cursor_pos, c);
app.cursor_pos += c.len_utf8();
app.load_entries(conn);
}
}
_ => {}
}
}
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
if app.show_help {
render_help(f, f.area());
return;
}
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
render_header(f, chunks[0], app);
render_divider(f, chunks[1], app);
render_list(f, chunks[2], app);
render_footer(f, chunks[3], app);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let label_style = Style::default().fg(Color::DarkGray);
let input_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD);
let jump_style = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD);
let max_keyword = (area.width as usize).saturating_sub(12);
let visible_keyword: String = app.keyword.chars().rev().take(max_keyword).collect::<Vec<_>>().into_iter().rev().collect();
let mut spans = vec![
Span::styled("> ", label_style),
Span::styled(&visible_keyword, input_style),
];
if app.num_mode {
spans.push(Span::styled(format!(" │ 跳转:{}_", app.number_buf), jump_style));
}
if app.limit_mode {
spans.push(Span::styled(format!(" │ 上限:{}_", app.limit_input), jump_style));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
f.render_widget(paragraph, area);
let cursor_col = 2 + app.cursor_pos as u16;
f.set_cursor_position((area.x + cursor_col, area.y));
}
fn render_divider(f: &mut Frame, area: Rect, app: &App) {
let text = format!(" {}/{}", app.selected.saturating_add(1).min(app.entries.len()), app.entries.len());
let padded = format!("{:width$}", text, width = area.width as usize);
let span = Span::styled(padded, Style::default().fg(Color::Rgb(86, 95, 95)).bg(Color::Black));
f.render_widget(Paragraph::new(Line::from(span)), area);
}
fn render_list(f: &mut Frame, area: Rect, app: &mut App) {
let visible_rows = area.height as usize;
let context_lines = 3usize;
let mut offset = app.scroll_offset;
let ctx = context_lines.min(visible_rows / 2);
if app.selected < offset + ctx {
offset = app.selected.saturating_sub(ctx);
}
if app.selected + ctx >= offset + visible_rows {
offset = app.selected + ctx + 1 - visible_rows;
}
let max_offset = app.entries.len().saturating_sub(visible_rows);
offset = offset.min(max_offset);
app.scroll_offset = offset;
let visible_entries: Vec<(usize, &HistoryEntry)> = app
.entries
.iter()
.enumerate()
.skip(offset)
.take(visible_rows)
.collect();
let items: Vec<ListItem> = visible_entries
.iter()
.map(|&(i, entry)| {
let is_selected = i == app.selected;
let num = format!(" {:2}.", i + 1);
let cmd_text = first_line(&entry.command);
let cwd_display = shorten_cwd(entry.cwd.as_deref().unwrap_or(""));
let time_display = relative_time(&entry.created_at);
let freq_badge = if entry.freq > 1 {
format!(" (x{})", entry.freq)
} else {
String::new()
};
let line = Line::from(vec![
Span::styled(
num,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", cmd_text)),
Span::styled(cwd_display, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(time_display, Style::default().fg(Color::Green)),
Span::styled(freq_badge, Style::default().fg(Color::Magenta)),
]);
let style = if is_selected {
Style::default().bg(Color::Rgb(5, 122, 212)).fg(Color::White)
} else {
Style::default()
};
ListItem::new(line).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::NONE));
f.render_widget(list, area);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let selected_entry = app.entries.get(app.selected);
if let Some(entry) = selected_entry {
if entry.command.contains('\n') {
let full_cmd = strip_ansi(&entry.command).replace('\n', " \\n ");
let para = Paragraph::new(full_cmd);
f.render_widget(para, area);
return;
}
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
if let Some(ref msg) = app.notification {
let para = Paragraph::new(Line::from(Span::styled(msg, Style::default().fg(Color::Gray))));
f.render_widget(para, chunks[0]);
}
let _ = chunks[1];
}
fn render_help(f: &mut Frame, area: Rect) {
let key_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(Color::White);
let lines = vec![
Line::from(vec![
Span::styled(" 按键", key_style),
Span::raw(" "),
Span::styled("功能", key_style),
]),
Line::from(Span::raw("")),
Line::from(vec![
Span::styled(" > keyword", key_style),
Span::raw(" "),
Span::styled("输入关键字过滤历史", desc_style),
]),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::raw(" "),
Span::styled("执行高亮命令", desc_style),
]),
Line::from(vec![
Span::styled(" Tab", key_style),
Span::raw(" "),
Span::styled("选中命令到命令行", desc_style),
]),
Line::from(vec![
Span::styled(" Alt + s", key_style),
Span::raw(" "),
Span::styled("切换时间 / 频率排序", desc_style),
]),
Line::from(vec![
Span::styled(" Alt + n", key_style),
Span::raw(" "),
Span::styled("进入数字跳转模式", desc_style),
]),
Line::from(vec![
Span::styled(" Alt + h", key_style),
Span::raw(" "),
Span::styled("显示 / 隐藏此帮助", desc_style),
]),
Line::from(vec![
Span::styled(" Esc", key_style),
Span::raw(" "),
Span::styled("退出(跳转模式中取消)", desc_style),
]),
Line::from(vec![
Span::styled(" Ctrl + C", key_style),
Span::raw(" "),
Span::styled("强制退出", desc_style),
]),
Line::from(vec![
Span::styled(" ↑ ↓ PgUp PgDn", key_style),
Span::raw(" "),
Span::styled("上下导航 / 翻页", desc_style),
]),
Line::from(vec![
Span::styled(" ← → Home End", key_style),
Span::raw(" "),
Span::styled("搜索框内移动光标", desc_style),
]),
Line::from(vec![
Span::styled(" Backspace Del", key_style),
Span::raw(""),
Span::styled("搜索框内删除字符", desc_style),
]),
];
let para = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Rgb(5, 122, 212)))
.title(" 帮助 "))
.style(Style::default());
f.render_widget(para, area);
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next(); while let Some(&nc) = chars.peek() {
if nc.is_ascii_alphabetic() || nc == '~' {
chars.next();
break;
}
chars.next();
}
} else {
result.push(c);
}
}
result
}
fn first_line(cmd: &str) -> String {
let cmd = strip_ansi(cmd);
let line = cmd.lines().next().unwrap_or("");
let max_len = 80;
if line.chars().count() > max_len {
format!("{}...", line.chars().take(max_len).collect::<String>())
} else {
let mut s = line.to_string();
if cmd.contains('\n') {
s.push_str(" ...");
}
s
}
}
fn shorten_cwd(cwd: &str) -> String {
let home = dirs::home_dir()
.and_then(|h| h.to_str().map(|s| s.to_string()))
.unwrap_or_default();
let shortened = if !home.is_empty() && cwd.starts_with(&home) {
format!("~{}", &cwd[home.len()..])
} else {
cwd.to_string()
};
if shortened.len() > 30 {
format!("...{}", &shortened[shortened.len().saturating_sub(27)..])
} else {
shortened
}
}
fn relative_time(iso: &str) -> String {
let parsed = chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S");
let dt = match parsed {
Ok(dt) => dt.and_utc(),
Err(_) => return String::new(),
};
let now = chrono::Utc::now();
let duration = now.signed_duration_since(dt);
if duration.num_seconds() < 0 {
return "刚刚".to_string();
}
let secs = duration.num_seconds() as u64;
if secs < 60 {
format!("{}秒前", secs)
} else if secs < 3600 {
format!("{}分钟前", secs / 60)
} else if secs < 86400 {
format!("{}小时前", secs / 3600)
} else if secs < 604800 {
format!("{}天前", secs / 86400)
} else {
format!("{}周前", secs / 604800)
}
}
fn dedup_consecutive(entries: Vec<HistoryEntry>) -> Vec<HistoryEntry> {
let mut result: Vec<HistoryEntry> = Vec::new();
for entry in entries {
if let Some(last) = result.last() {
if last.command == entry.command && last.cwd == entry.cwd {
continue;
}
}
result.push(entry);
}
result
}