diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6e4ef80..ad81417 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -63,7 +63,7 @@ jobs: key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} - name: Run cargo test - run: cargo test --workspace + run: ATUIN_SESSION=beepboopiamasession cargo test --workspace clippy: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 1d03a8a..7afd0df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,7 @@ dependencies = [ "tracing-subscriber", "tui", "unicode-width", + "whoami", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index be1c1b7..5eb3d2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ crossbeam-channel = "0.5.1" clap = { version = "3.1.11", features = ["derive"] } clap_complete = "3.1.2" fs-err = "2.7" +whoami = "1.1.2" [dependencies.tracing-subscriber] diff --git a/README.md b/README.md index 2f4f421..c6e7b9f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ I wanted to. And I **really** don't want to. - calculate statistics such as "most used command" - old history file is not replaced - quick-jump to previous items with Alt-\ +- switch filter modes via ctrl-r; search history just from the current session, directory, or globally ## Documentation diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs index 9efde2c..d1b892e 100644 --- a/atuin-client/src/database.rs +++ b/atuin-client/src/database.rs @@ -1,3 +1,4 @@ +use std::env; use std::path::Path; use std::str::FromStr; @@ -16,7 +17,29 @@ use sqlx::{ use super::history::History; use super::ordering; -use super::settings::SearchMode; +use super::settings::{FilterMode, SearchMode}; + +pub struct Context { + session: String, + cwd: String, + hostname: String, +} + +pub fn current_context() -> Context { + let session = + env::var("ATUIN_SESSION").expect("failed to find ATUIN_SESSION - check your shell setup"); + let hostname = format!("{}:{}", whoami::hostname(), whoami::username()); + let cwd = match env::current_dir() { + Ok(dir) => dir.display().to_string(), + Err(_) => String::from(""), + }; + + Context { + session, + hostname, + cwd, + } +} #[async_trait] pub trait Database { @@ -24,7 +47,13 @@ pub trait Database { async fn save_bulk(&mut self, h: &[History]) -> Result<()>; async fn load(&self, id: &str) -> Result; - async fn list(&self, max: Option, unique: bool) -> Result>; + async fn list( + &self, + filter: FilterMode, + context: &Context, + max: Option, + unique: bool, + ) -> Result>; async fn range( &self, from: chrono::DateTime, @@ -42,6 +71,8 @@ pub trait Database { &self, limit: Option, search_mode: SearchMode, + filter: FilterMode, + context: &Context, query: &str, ) -> Result>; @@ -179,33 +210,52 @@ impl Database for Sqlite { } // make a unique list, that only shows the *newest* version of things - async fn list(&self, max: Option, unique: bool) -> Result> { + async fn list( + &self, + filter: FilterMode, + context: &Context, + max: Option, + unique: bool, + ) -> Result> { debug!("listing history"); - // very likely vulnerable to SQL injection - // however, this is client side, and only used by the client, on their - // own data. They can just open the db file... - // otherwise building the query is awkward + // gotta get that query builder in soon cuz I kinda hate this + let query = if unique { + "where timestamp = ( + select max(timestamp) from history + where h.command = history.command + )" + } else { + "" + } + .to_string(); + + let mut join = if unique { "and" } else { "where" }.to_string(); + + let filter_query = match filter { + FilterMode::Global => { + join = "".to_string(); + "".to_string() + } + FilterMode::Host => format!("hostname = '{}'", context.hostname).to_string(), + FilterMode::Session => format!("session = '{}'", context.session).to_string(), + FilterMode::Directory => format!("cwd = '{}'", context.cwd).to_string(), + }; + + let filter = format!("{} {}", join, filter_query); + + let limit = if let Some(max) = max { + format!("limit {}", max) + } else { + "".to_string() + }; + let query = format!( "select * from history h {} order by timestamp desc - {}", - // inject the unique check - if unique { - "where timestamp = ( - select max(timestamp) from history - where h.command = history.command - )" - } else { - "" - }, - // inject the limit - if let Some(max) = max { - format!("limit {}", max) - } else { - "".to_string() - } + {} {}", + query, filter, limit, ); let res = sqlx::query(query.as_str()) @@ -281,6 +331,8 @@ impl Database for Sqlite { &self, limit: Option, search_mode: SearchMode, + filter: FilterMode, + context: &Context, query: &str, ) -> Result> { let orig_query = query; @@ -350,6 +402,13 @@ impl Database for Sqlite { } }; + let filter_sql = match filter { + FilterMode::Global => String::from(""), + FilterMode::Session => format!("and session = '{}'", context.session), + FilterMode::Directory => format!("and cwd = '{}'", context.cwd), + FilterMode::Host => format!("and hostname = '{}'", context.hostname), + }; + let res = query_params .iter() .fold( @@ -357,10 +416,12 @@ impl Database for Sqlite { format!( "select * from history h where {} + {} group by command having max(timestamp) order by timestamp desc {}", query_sql.as_str(), + filter_sql.as_str(), limit.clone() ) .as_str(), @@ -392,10 +453,18 @@ mod test { async fn assert_search_eq<'a>( db: &impl Database, mode: SearchMode, + filter_mode: FilterMode, query: &str, expected: usize, ) -> Result> { - let results = db.search(None, mode, query).await?; + let context = Context { + hostname: "test:host".to_string(), + session: "beepboopiamasession".to_string(), + cwd: "/home/ellie".to_string(), + }; + + let results = db.search(None, mode, filter_mode, &context, query).await?; + assert_eq!( results.len(), expected, @@ -409,10 +478,11 @@ mod test { async fn assert_search_commands( db: &impl Database, mode: SearchMode, + filter_mode: FilterMode, query: &str, expected_commands: Vec<&str>, ) { - let results = assert_search_eq(db, mode, query, expected_commands.len()) + let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len()) .await .unwrap(); let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect(); @@ -437,13 +507,13 @@ mod test { let mut db = Sqlite::new("sqlite::memory:").await.unwrap(); new_history_item(&mut db, "ls /home/ellie").await.unwrap(); - assert_search_eq(&db, SearchMode::Prefix, "ls", 1) + assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::Prefix, "/home", 0) + assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0) .await .unwrap(); - assert_search_eq(&db, SearchMode::Prefix, "ls ", 0) + assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0) .await .unwrap(); } @@ -453,13 +523,13 @@ mod test { let mut db = Sqlite::new("sqlite::memory:").await.unwrap(); new_history_item(&mut db, "ls /home/ellie").await.unwrap(); - assert_search_eq(&db, SearchMode::FullText, "ls", 1) + assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::FullText, "/home", 1) + assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::FullText, "ls ", 0) + assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ", 0) .await .unwrap(); } @@ -474,70 +544,82 @@ mod test { .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "ls /", 3) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "ls/", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "l/h/", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "/h/e", 3) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "/hmoe/", 0) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "ellie/home", 0) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "lsellie", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, " ", 4) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4) .await .unwrap(); // single term operators - assert_search_eq(&db, SearchMode::Fuzzy, "^ls", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "'ls", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "ellie$", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "!^ls", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "!ellie", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "!ellie$", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2) .await .unwrap(); // multiple terms - assert_search_eq(&db, SearchMode::Fuzzy, "ls !ellie", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "^ls !e$", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "home !^ls", 2) - .await - .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "'frank | 'rustup", 2) - .await - .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "'frank | 'rustup 'ls", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2) .await .unwrap(); + assert_search_eq( + &db, + SearchMode::Fuzzy, + FilterMode::Global, + "'frank | 'rustup", + 2, + ) + .await + .unwrap(); + assert_search_eq( + &db, + SearchMode::Fuzzy, + FilterMode::Global, + "'frank | 'rustup 'ls", + 1, + ) + .await + .unwrap(); // case matching - assert_search_eq(&db, SearchMode::Fuzzy, "Ellie", 1) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1) .await .unwrap(); } @@ -551,18 +633,31 @@ mod test { new_history_item(&mut db, "corburl").await.unwrap(); // if fuzzy reordering is on, it should come back in a more sensible order - assert_search_commands(&db, SearchMode::Fuzzy, "curl", vec!["curl", "corburl"]).await; + assert_search_commands( + &db, + SearchMode::Fuzzy, + FilterMode::Global, + "curl", + vec!["curl", "corburl"], + ) + .await; - assert_search_eq(&db, SearchMode::Fuzzy, "xxxx", 0) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0) .await .unwrap(); - assert_search_eq(&db, SearchMode::Fuzzy, "", 2) + assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2) .await .unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn test_search_bench_dupes() { + let context = Context { + hostname: "test:host".to_string(), + session: "beepboopiamasession".to_string(), + cwd: "/home/ellie".to_string(), + }; + let mut db = Sqlite::new("sqlite::memory:").await.unwrap(); for _i in 1..10000 { new_history_item(&mut db, "i am a duplicated command") @@ -570,7 +665,10 @@ mod test { .unwrap(); } let start = Instant::now(); - let _results = db.search(None, SearchMode::Fuzzy, "").await.unwrap(); + let _results = db + .search(None, SearchMode::Fuzzy, FilterMode::Global, &context, "") + .await + .unwrap(); let duration = start.elapsed(); assert!(duration < Duration::from_secs(15)); diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index ee3376a..bb3424a 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -23,6 +23,21 @@ pub enum SearchMode { Fuzzy, } +#[derive(Clone, Debug, Deserialize, Copy)] +pub enum FilterMode { + #[serde(rename = "global")] + Global, + + #[serde(rename = "host")] + Host, + + #[serde(rename = "session")] + Session, + + #[serde(rename = "directory")] + Directory, +} + // FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged #[derive(Clone, Debug, Deserialize, Copy)] pub enum Dialect { @@ -65,6 +80,7 @@ pub struct Settings { pub key_path: String, pub session_path: String, pub search_mode: SearchMode, + pub filter_mode: FilterMode, // This is automatically loaded when settings is created. Do not set in // config! Keep secrets and settings apart. pub session_token: String, @@ -147,6 +163,7 @@ impl Settings { .set_default("sync_frequency", "1h")? .set_default("sync_address", "https://api.atuin.sh")? .set_default("search_mode", "prefix")? + .set_default("filter_mode", "global")? .set_default("session_token", "")? .set_default("style", "auto")? .add_source( diff --git a/docs/config.md b/docs/config.md index 405e3b5..8b5fa29 100644 --- a/docs/config.md +++ b/docs/config.md @@ -102,6 +102,20 @@ the search syntax [described below](#fuzzy-search-syntax). Defaults to "prefix" +### `filter_mode` + +The default filter to use when searching + +| Column1 | Column2 | +|--------------- | --------------- | +| global (default) | Search history from all hosts, all sessions, all directories | +| host | Search history just from this host | +| session | Search history just from the current session | +| directory | Search history just from the current directory| + +Filter modes can still be toggled via ctrl-r + + ``` search_mode = "fulltext" ``` diff --git a/src/command/client/history.rs b/src/command/client/history.rs index b2f68f9..994cbfd 100644 --- a/src/command/client/history.rs +++ b/src/command/client/history.rs @@ -6,7 +6,7 @@ use clap::Subcommand; use eyre::Result; use tabwriter::TabWriter; -use atuin_client::database::Database; +use atuin_client::database::{current_context, Database}; use atuin_client::history::History; use atuin_client::settings::Settings; use atuin_client::sync; @@ -97,6 +97,8 @@ impl Cmd { settings: &Settings, db: &mut (impl Database + Send + Sync), ) -> Result<()> { + let context = current_context(); + match self { Self::Start { command: words } => { let command = words.join(" "); @@ -168,7 +170,7 @@ impl Cmd { }; let history = match (session, cwd) { - (None, None) => db.list(None, false).await?, + (None, None) => db.list(settings.filter_mode, &context, None, false).await?, (None, Some(cwd)) => { let query = format!("select * from history where cwd = '{}';", cwd); db.query_history(&query).await? diff --git a/src/command/client/search.rs b/src/command/client/search.rs index a1dc5aa..a913e16 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -1,6 +1,7 @@ use chrono::Utc; use clap::Parser; use eyre::Result; +use std::env; use std::{io::stdout, ops::Sub, time::Duration}; use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; use tui::{ @@ -14,9 +15,11 @@ use tui::{ use unicode_width::UnicodeWidthStr; use atuin_client::{ + database::current_context, + database::Context, database::Database, history::History, - settings::{SearchMode, Settings}, + settings::{FilterMode, SearchMode, Settings}, }; use super::event::{Event, Events}; @@ -91,9 +94,13 @@ impl Cmd { struct State { input: String, + filter_mode: FilterMode, + results: Vec, results_state: ListState, + + context: Context, } impl State { @@ -233,8 +240,14 @@ async fn query_results( db: &mut (impl Database + Send + Sync), ) -> Result<()> { let results = match app.input.as_str() { - "" => db.list(Some(200), true).await?, - i => db.search(Some(200), search_mode, i).await?, + "" => { + db.list(app.filter_mode, &app.context, Some(200), true) + .await? + } + i => { + db.search(Some(200), search_mode, app.filter_mode, &app.context, i) + .await? + } }; app.results = results; @@ -300,6 +313,16 @@ async fn key_handler( app.input = String::from(""); query_results(app, search_mode, db).await.unwrap(); } + Key::Ctrl('r') => { + app.filter_mode = match app.filter_mode { + FilterMode::Global => FilterMode::Host, + FilterMode::Host => FilterMode::Session, + FilterMode::Session => FilterMode::Directory, + FilterMode::Directory => FilterMode::Global, + }; + + query_results(app, search_mode, db).await.unwrap(); + } Key::Down | Key::Ctrl('n') => { let i = match app.results_state.selected() { Some(i) => { @@ -376,8 +399,15 @@ fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { let help = Text::from(Spans::from(help)); let help = Paragraph::new(help); + let filter_mode = match app.filter_mode { + FilterMode::Global => "GLOBAL", + FilterMode::Host => "HOST", + FilterMode::Session => "SESSION", + FilterMode::Directory => "DIRECTORY", + }; + let input = Paragraph::new(app.input.clone()) - .block(Block::default().borders(Borders::ALL).title("Query")); + .block(Block::default().borders(Borders::ALL).title(filter_mode)); let stats = Paragraph::new(Text::from(Span::raw(format!( "history count: {}", @@ -451,7 +481,15 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Right); - let input = Paragraph::new(format!("] {}", app.input.clone())).block(Block::default()); + let filter_mode = match app.filter_mode { + FilterMode::Global => "GLOBAL", + FilterMode::Host => "HOST", + FilterMode::Session => "SESSION", + FilterMode::Directory => "DIRECTORY", + }; + + let input = + Paragraph::new(format!("{}] {}", filter_mode, app.input.clone())).block(Block::default()); f.render_widget(title, header_chunks[0]); f.render_widget(help, header_chunks[1]); @@ -460,9 +498,11 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut app.render_results(f, chunks[1], Block::default()); f.render_widget(input, chunks[2]); + let extra_width = app.input.width() + filter_mode.len(); + f.set_cursor( // Put cursor past the end of the input text - chunks[2].x + app.input.width() as u16 + 2, + chunks[2].x + extra_width as u16 + 2, // Move one line down, from the border to the input line chunks[2].y + 1, ); @@ -475,6 +515,7 @@ fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut async fn select_history( query: &[String], search_mode: SearchMode, + filter_mode: FilterMode, style: atuin_client::settings::Style, db: &mut (impl Database + Send + Sync), ) -> Result { @@ -491,6 +532,8 @@ async fn select_history( input: query.join(" "), results: Vec::new(), results_state: ListState::default(), + context: current_context(), + filter_mode, }; query_results(&mut app, search_mode, db).await?; @@ -551,11 +594,26 @@ pub async fn run( }; if interactive { - let item = select_history(query, settings.search_mode, settings.style, db).await?; + let item = select_history( + query, + settings.search_mode, + settings.filter_mode, + settings.style, + db, + ) + .await?; eprintln!("{}", item); } else { + let context = current_context(); + let results = db - .search(None, settings.search_mode, query.join(" ").as_str()) + .search( + None, + settings.search_mode, + settings.filter_mode, + &context, + query.join(" ").as_str(), + ) .await?; // TODO: This filtering would be better done in the SQL query, I just diff --git a/src/command/client/stats.rs b/src/command/client/stats.rs index 6d342c1..85c58cc 100644 --- a/src/command/client/stats.rs +++ b/src/command/client/stats.rs @@ -8,9 +8,9 @@ use clap::Parser; use cli_table::{format::Justify, print_stdout, Cell, Style, Table}; use eyre::{bail, Result}; -use atuin_client::database::Database; +use atuin_client::database::{current_context, Database}; use atuin_client::history::History; -use atuin_client::settings::Settings; +use atuin_client::settings::{FilterMode, Settings}; #[derive(Parser)] #[clap(infer_subcommands = true)] @@ -71,6 +71,8 @@ impl Cmd { db: &mut (impl Database + Send + Sync), settings: &Settings, ) -> Result<()> { + let context = current_context(); + match self { Self::Day { words } => { let words = if words.is_empty() { @@ -90,7 +92,7 @@ impl Cmd { } Self::All => { - let history = db.list(None, false).await?; + let history = db.list(FilterMode::Global, &context, None, false).await?; compute_stats(&history)?;