aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Dockerfile2
-rw-r--r--README.md1
-rw-r--r--crates/atuin-client/src/database.rs181
-rw-r--r--crates/atuin-client/src/settings.rs2
-rw-r--r--crates/atuin/src/command/client/search/engines/db.rs57
-rw-r--r--crates/atuin/src/command/client/search/history_list.rs10
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs33
-rw-r--r--docs/docs/guide/dotfiles.md1
-rw-r--r--docs/docs/guide/getting-started.md1
-rw-r--r--docs/docs/guide/installation.md33
-rw-r--r--docs/docs/index.md1
11 files changed, 244 insertions, 78 deletions
diff --git a/Dockerfile b/Dockerfile
index fbefb280..24492719 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,7 +16,7 @@ RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin atuin
-FROM debian:bookworm-20251208-slim AS runtime
+FROM debian:bookworm-20260112-slim AS runtime
RUN useradd -c 'atuin user' atuin && mkdir /config && chown atuin:atuin /config
# Install ca-certificates for webhooks to work
diff --git a/README.md b/README.md
index 787e5c3e..60eaa000 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@ I wanted to. And I **really** don't want to.
- fish
- nushell
- xonsh
+- powershell (tier 2 support)
## Community
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs
index 82644182..408e8e52 100644
--- a/crates/atuin-client/src/database.rs
+++ b/crates/atuin-client/src/database.rs
@@ -1,5 +1,4 @@
use std::{
- borrow::Cow,
env,
path::{Path, PathBuf},
str::FromStr,
@@ -483,76 +482,47 @@ impl Database for Sqlite {
SearchMode::Prefix => sql.and_where_like_left("command", query.replace('*', "%")),
_ => {
let mut is_or = false;
- let mut regex = None;
- for part in query.split_inclusive(' ') {
- let query_part: Cow<str> = match (&mut regex, part.starts_with("r/")) {
- (None, false) => {
- if part.trim_end().is_empty() {
- continue;
- }
- Cow::Owned(part.trim_end().replace('*', "%")) // allow wildcard char
- }
- (None, true) => {
- if part[2..].trim_end().ends_with('/') {
- let end_pos = part.trim_end().len() - 1;
- regexes.push(String::from(&part[2..end_pos]));
- } else {
- regex = Some(String::from(&part[2..]));
- }
- continue;
- }
- (Some(r), _) => {
- if part.trim_end().ends_with('/') {
- let end_pos = part.trim_end().len() - 1;
- r.push_str(&part.trim_end()[..end_pos]);
- regexes.push(regex.take().unwrap());
- } else {
- r.push_str(part);
- }
- continue;
- }
- };
-
+ for token in QueryTokenizer::new(query) {
// TODO smart case mode could be made configurable like in fzf
- let (is_glob, glob) = if query_part.contains(char::is_uppercase) {
+ let (is_glob, glob) = if token.has_uppercase() {
(true, "*")
} else {
(false, "%")
};
-
- let (is_inverse, query_part) = match query_part.strip_prefix('!') {
- Some(stripped) => (true, Cow::Borrowed(stripped)),
- None => (false, query_part),
- };
-
- #[allow(clippy::if_same_then_else)]
- let param = if query_part == "|" {
- if !is_or {
- is_or = true;
+ let param = match token {
+ QueryToken::Regex(r) => {
+ regexes.push(String::from(r));
continue;
- } else {
- format!("{glob}|{glob}")
}
- } else if let Some(term) = query_part.strip_prefix('^') {
- format!("{term}{glob}")
- } else if let Some(term) = query_part.strip_suffix('$') {
- format!("{glob}{term}")
- } else if let Some(term) = query_part.strip_prefix('\'') {
- format!("{glob}{term}{glob}")
- } else if is_inverse {
- format!("{glob}{query_part}{glob}")
- } else if search_mode == SearchMode::FullText {
- format!("{glob}{query_part}{glob}")
- } else {
- query_part.split("").join(glob)
+ QueryToken::Or => {
+ if !is_or {
+ is_or = true;
+ continue;
+ } else {
+ format!("{glob}|{glob}")
+ }
+ }
+ QueryToken::MatchStart(term, _) => {
+ format!("{term}{glob}")
+ }
+ QueryToken::MatchEnd(term, _) => {
+ format!("{glob}{term}")
+ }
+ QueryToken::MatchFull(term, _) => {
+ format!("{glob}{term}{glob}")
+ }
+ QueryToken::Match(term, _) => {
+ if search_mode == SearchMode::FullText {
+ format!("{glob}{term}{glob}")
+ } else {
+ term.split("").join(glob)
+ }
+ }
};
- sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or);
+ sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
is_or = false;
}
- if let Some(r) = regex {
- regexes.push(r);
- }
&mut sql
}
@@ -1206,3 +1176,94 @@ mod test {
assert!(duration < Duration::from_secs(15));
}
}
+
+pub struct QueryTokenizer<'a> {
+ query: &'a str,
+ last_pos: usize,
+}
+
+pub enum QueryToken<'a> {
+ Match(&'a str, bool),
+ MatchStart(&'a str, bool),
+ MatchEnd(&'a str, bool),
+ MatchFull(&'a str, bool),
+ Or,
+ Regex(&'a str),
+}
+
+impl<'a> QueryToken<'a> {
+ pub fn has_uppercase(&self) -> bool {
+ match self {
+ Self::Match(term, _)
+ | Self::MatchStart(term, _)
+ | Self::MatchEnd(term, _)
+ | Self::MatchFull(term, _) => term.contains(char::is_uppercase),
+ _ => false,
+ }
+ }
+
+ pub fn is_inverse(&self) -> bool {
+ match self {
+ Self::Match(_, inv)
+ | Self::MatchStart(_, inv)
+ | Self::MatchEnd(_, inv)
+ | Self::MatchFull(_, inv) => *inv,
+ _ => false,
+ }
+ }
+}
+
+impl<'a> QueryTokenizer<'a> {
+ pub fn new(query: &'a str) -> Self {
+ Self { query, last_pos: 0 }
+ }
+}
+
+impl<'a> Iterator for QueryTokenizer<'a> {
+ type Item = QueryToken<'a>;
+ fn next(&mut self) -> Option<Self::Item> {
+ let remaining = &self.query[self.last_pos..];
+ if remaining.is_empty() {
+ return None;
+ }
+
+ if let Some(remaining) = remaining.strip_prefix("r/") {
+ let (regex, next_pos) = if let Some(end) = remaining.find("/ ") {
+ (&remaining[..end], self.last_pos + 2 + end + 2)
+ } else if let Some(remaining) = remaining.strip_suffix('/') {
+ (remaining, self.query.len())
+ } else {
+ (remaining, self.query.len())
+ };
+ self.last_pos = next_pos;
+ Some(QueryToken::Regex(regex))
+ } else {
+ let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {
+ (&remaining[..sp], self.last_pos + sp + 1)
+ } else {
+ (remaining, self.query.len())
+ };
+ self.last_pos = next_pos;
+
+ if part == "|" {
+ return Some(QueryToken::Or);
+ }
+
+ let mut is_inverse = false;
+ if let Some(s) = part.strip_prefix('!') {
+ part = s;
+ is_inverse = true;
+ }
+ let token = if let Some(s) = part.strip_prefix('^') {
+ QueryToken::MatchStart(s, is_inverse)
+ } else if let Some(s) = part.strip_suffix('$') {
+ QueryToken::MatchEnd(s, is_inverse)
+ } else if let Some(s) = part.strip_prefix('\'') {
+ QueryToken::MatchFull(s, is_inverse)
+ } else {
+ QueryToken::Match(part, is_inverse)
+ };
+ Some(token)
+ }
+ }
+}
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index bfe9278d..916172ba 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -479,7 +479,7 @@ impl UiColumnType {
pub fn default_width(&self) -> u16 {
match self {
UiColumnType::Duration => 5,
- UiColumnType::Time => 8, // "59m ago" with padding
+ UiColumnType::Time => 9, // "459ms ago" with padding
UiColumnType::Datetime => 16, // "2025-01-22 14:35"
UiColumnType::Directory => 20,
UiColumnType::Host => 15,
diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs
index 9358ee58..f0ed424e 100644
--- a/crates/atuin/src/command/client/search/engines/db.rs
+++ b/crates/atuin/src/command/client/search/engines/db.rs
@@ -1,7 +1,11 @@
use super::{SearchEngine, SearchState};
use async_trait::async_trait;
use atuin_client::{
- database::Database, database::OptFilters, history::History, settings::SearchMode,
+ database::Database,
+ database::OptFilters,
+ database::{QueryToken, QueryTokenizer},
+ history::History,
+ settings::SearchMode,
};
use eyre::Result;
use norm::Metric;
@@ -36,6 +40,8 @@ impl SearchEngine for Search {
fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
if self.0 == SearchMode::Prefix {
return vec![];
+ } else if self.0 == SearchMode::FullText {
+ return get_highlight_indices_fulltext(command, search_input);
}
let mut fzf = FzfV2::new();
let mut parser = FzfParser::new();
@@ -47,3 +53,52 @@ impl SearchEngine for Search {
ranges.into_iter().flatten().collect()
}
}
+
+fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {
+ let mut ranges = vec![];
+ let lower_command = command.to_ascii_lowercase();
+
+ for token in QueryTokenizer::new(search_input) {
+ let matchee = if token.has_uppercase() {
+ command
+ } else {
+ &lower_command
+ };
+
+ if token.is_inverse() {
+ continue;
+ }
+
+ match token {
+ QueryToken::Or => {}
+ QueryToken::Regex(r) => {
+ if let Ok(re) = regex::Regex::new(r) {
+ for m in re.find_iter(command) {
+ ranges.push(m.range());
+ }
+ }
+ }
+ QueryToken::MatchStart(term, _) => {
+ if matchee.starts_with(term) {
+ ranges.push(0..term.len());
+ }
+ }
+ QueryToken::MatchEnd(term, _) => {
+ if matchee.ends_with(term) {
+ let l = matchee.len();
+ ranges.push((l - term.len())..l);
+ }
+ }
+ QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {
+ for (idx, m) in matchee.match_indices(term) {
+ ranges.push(idx..(idx + m.len()));
+ }
+ }
+ }
+ }
+
+ let mut ret: Vec<_> = ranges.into_iter().flatten().collect();
+ ret.sort_unstable();
+ ret.dedup();
+ ret
+}
diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs
index b1bf8176..565a7972 100644
--- a/crates/atuin/src/command/client/search/history_list.rs
+++ b/crates/atuin/src/command/client/search/history_list.rs
@@ -178,10 +178,6 @@ struct DrawState<'a> {
columns: &'a [UiColumn],
}
-// Default prefix length for backwards compatibility (used by interactive.rs)
-#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length
-pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16;
-
// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
// Yes, this is a hack, but it makes me feel happy
static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
@@ -302,7 +298,9 @@ impl DrawState<'_> {
let mut pos = 0;
for section in h.command.escape_control().split_ascii_whitespace() {
- self.draw(" ", style.into());
+ if pos != 0 {
+ self.draw(" ", style.into());
+ }
for ch in section.chars() {
if self.x > self.list_area.width {
// Avoid attempting to draw a command section beyond the width
@@ -362,7 +360,7 @@ impl DrawState<'_> {
/// Render the host column (just the hostname)
fn host(&mut self, h: &History, width: u16) {
let style = self.theme.as_style(Meaning::Annotation);
- let w = width as usize;
+ let w = width as usize - 1;
// Database stores hostname as "hostname:username"
let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
let char_count = host.chars().count();
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index e28323c8..bda4873d 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -13,7 +13,7 @@ use unicode_width::UnicodeWidthStr;
use super::{
cursor::Cursor,
engines::{SearchEngine, SearchState},
- history_list::{HistoryList, ListState, PREFIX_LENGTH},
+ history_list::{HistoryList, ListState},
};
use atuin_client::{
database::{Database, current_context},
@@ -1004,11 +1004,27 @@ impl State {
preview_chunk.width.into(),
theme,
);
- self.draw_preview(f, style, input_chunk, compactness, preview_chunk, preview);
+ #[allow(clippy::cast_possible_truncation)]
+ let prefix_width = settings
+ .ui
+ .columns
+ .iter()
+ .filter_map(|col| if col.expand { None } else { Some(col.width) })
+ .sum::<u16>()
+ + " > ".len() as u16;
+ self.draw_preview(
+ f,
+ style,
+ input_chunk,
+ compactness,
+ preview_chunk,
+ preview,
+ prefix_width,
+ );
}
}
- #[allow(clippy::cast_possible_truncation)]
+ #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)]
fn draw_preview(
&self,
f: &mut Frame,
@@ -1017,8 +1033,9 @@ impl State {
compactness: Compactness,
preview_chunk: Rect,
preview: Paragraph,
+ prefix_width: u16,
) {
- let input = self.build_input(style);
+ let input = self.build_input(style, prefix_width - 2);
f.render_widget(input, input_chunk);
f.render_widget(preview, preview_chunk);
@@ -1031,7 +1048,7 @@ impl State {
};
f.set_cursor_position((
// Put cursor past the end of the input text
- input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
+ input_chunk.x + extra_width as u16 + prefix_width + 1 + cursor_offset,
input_chunk.y + cursor_offset,
));
}
@@ -1146,15 +1163,13 @@ impl State {
}
}
- fn build_input(&self, style: StyleState) -> Paragraph<'_> {
- /// Max width of the UI box showing current mode
- const MAX_WIDTH: usize = 14;
+ fn build_input(&self, style: StyleState, max_width: u16) -> Paragraph<'_> {
let (pref, mode) = if self.switched_search_mode {
(" SRCH:", self.search_mode.as_str())
} else {
("", self.search.filter_mode.as_str())
};
- let mode_width = MAX_WIDTH - pref.len();
+ let mode_width = usize::from(max_width) - pref.len();
// sanity check to ensure we don't exceed the layout limits
debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),);
diff --git a/docs/docs/guide/dotfiles.md b/docs/docs/guide/dotfiles.md
index 79a571ce..d4157cb7 100644
--- a/docs/docs/guide/dotfiles.md
+++ b/docs/docs/guide/dotfiles.md
@@ -13,6 +13,7 @@ The following shells are supported:
- bash
- fish
- xonsh
+- powershell
Note: Atuin handles your configuration internally, so once it is installed you
no longer need to edit your config files manually.
diff --git a/docs/docs/guide/getting-started.md b/docs/docs/guide/getting-started.md
index c25645d0..85140b25 100644
--- a/docs/docs/guide/getting-started.md
+++ b/docs/docs/guide/getting-started.md
@@ -20,6 +20,7 @@ If you have any problems, please open an [issue](https://github.com/ellie/atuin/
- fish
- nushell
- xonsh
+- powershell (tier 2 support)
## Quickstart
diff --git a/docs/docs/guide/installation.md b/docs/docs/guide/installation.md
index 72130af4..aeb33518 100644
--- a/docs/docs/guide/installation.md
+++ b/docs/docs/guide/installation.md
@@ -2,6 +2,8 @@
## Recommended installation approach
+### On Unix
+
Let's get started! First up, you will want to install Atuin. The recommended
approach is to use the installation script, which automatically handles the
installation of Atuin including the requirements for your environment.
@@ -16,6 +18,21 @@ curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh
[**Set up sync** - Move on to the next step, or read on to manually install Atuin instead.](sync.md)
+### On Windows
+
+The recommended approach on Windows is to use WinGet to install Atuin. Then, if you use PowerShell,
+add the initialization command to your PowerShell profile, and restart your shell.
+
+```shell
+winget install -e Atuinsh.Atuin
+if (-not (Test-Path -Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null }
+Write-Output 'atuin init powershell | Out-String | Invoke-Expression' >> $PROFILE
+```
+
+Note that the `$PROFILE` path may depend on your PowerShell version.
+
+[**Set up sync** - Move on to the next step.](sync.md)
+
## Manual installation
### Installing the binary
@@ -105,6 +122,14 @@ If you don't wish to use the installer, the manual installation steps are as fol
zinit light atuinsh/atuin
```
+=== "WinGet"
+
+ Atuin is available on WinGet:
+
+ ```shell
+ winget install -e Atuinsh.Atuin
+ ```
+
=== "Source"
Atuin builds on the latest stable version of Rust, and we make no
@@ -232,6 +257,14 @@ After installing, remember to restart your shell.
```
to the end of your `~/.xonshrc`
+=== "PowerShell"
+
+ Add the following to the end of your `$PROFILE` file:
+
+ ```shell
+ atuin init powershell | Out-String | Invoke-Expression
+ ```
+
## Upgrade
Run `atuin update`, and if that command is not available, run the install script again.
diff --git a/docs/docs/index.md b/docs/docs/index.md
index 76a4b51f..43ef2f9c 100644
--- a/docs/docs/index.md
+++ b/docs/docs/index.md
@@ -22,6 +22,7 @@ Alternatively, get in touch on our [Discord](https://discord.gg/Fq8bJSKPHh) or o
- fish
- nushell
- xonsh
+- powershell (tier 2 support)
## Quickstart