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