diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c044cba..6370409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: if: ${{ matrix.os != 'windows-latest' }} with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 + - uses: cachix/cachix-action@v16 if: ${{ matrix.os != 'windows-latest' && env.CACHIX_AUTH_TOKEN != '' }} with: authToken: ${{ env.CACHIX_AUTH_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 419c5ae..d47ae49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Bash: zoxide will now rewrite the prompt when using Space-Tab completions. + ## [0.9.7] - 2025-02-10 ### Added diff --git a/README.md b/README.md index f0893a2..3c7f043 100644 --- a/README.md +++ b/README.md @@ -461,14 +461,17 @@ Environment variables[^2] can be used for configuration. They must be set before | [lf] | File manager | See the [wiki][lf-wiki] | | [nnn] | File manager | [nnn-autojump] | | [ranger] | File manager | [ranger-zoxide] | +| [rfm] | File manager | Natively supported | +| [sesh] | `tmux` session manager | Natively supported | | [telescope.nvim] | Fuzzy finder for Neovim | [telescope-zoxide] | -| [t] | `tmux` session manager | Natively supported | | [tmux-session-wizard] | `tmux` session manager | Natively supported | +| [tmux-sessionx] | `tmux` session manager | Natively supported | | [vim] / [neovim] | Text editor | [zoxide.vim] | | [xplr] | File manager | [zoxide.xplr] | | [xxh] | Transports shell configuration over SSH | [xxh-plugin-prerun-zoxide] | | [yazi] | File manager | Natively supported | | [zabb] | Finds the shortest possible query for a path | Natively supported | +| [zesh] | `zellij` session manager | Natively supported | | [zsh-autocomplete] | Realtime completions for zsh | Natively supported | [^1]: @@ -528,15 +531,17 @@ Environment variables[^2] can be used for configuration. They must be set before [ranger]: https://github.com/ranger/ranger [raspbian packages]: https://archive.raspbian.org/raspbian/pool/main/r/rust-zoxide/ [releases]: https://github.com/ajeetdsouza/zoxide/releases +[rfm]: https://github.com/dsxmachina/rfm [scoop]: https://github.com/ScoopInstaller/Main/tree/master/bucket/zoxide.json +[sesh]: https://github.com/joshmedeski/sesh [slackbuilds]: https://slackbuilds.org/repository/15.0/system/zoxide/ [slackbuilds-howto]: https://slackbuilds.org/howto/ [solus packages]: https://github.com/getsolus/packages/tree/main/packages/z/zoxide/ -[t]: https://github.com/joshmedeski/t-smart-tmux-session-manager [telescope-zoxide]: https://github.com/jvgrootveld/telescope-zoxide [telescope.nvim]: https://github.com/nvim-telescope/telescope.nvim [termux]: https://github.com/termux/termux-packages/tree/master/packages/zoxide [tmux-session-wizard]: https://github.com/27medkamal/tmux-session-wizard +[tmux-sessionx]: https://github.com/omerxx/tmux-sessionx [tutorial]: contrib/tutorial.webp [ubuntu packages]: https://packages.ubuntu.com/jammy/zoxide [vim]: https://github.com/vim/vim @@ -547,6 +552,7 @@ Environment variables[^2] can be used for configuration. They must be set before [xxh]: https://github.com/xxh/xxh [yazi]: https://github.com/sxyazi/yazi [zabb]: https://github.com/Mellbourn/zabb +[zesh]: https://github.com/roberte777/zesh [zoxide.el]: https://gitlab.com/Vonfry/zoxide.el [zoxide.vim]: https://github.com/nanotee/zoxide.vim [zoxide.xplr]: https://github.com/sayanarijit/zoxide.xplr diff --git a/src/cmd/bookmark.rs b/src/cmd/bookmark.rs index 5c36921..f206dc0 100644 --- a/src/cmd/bookmark.rs +++ b/src/cmd/bookmark.rs @@ -1,8 +1,18 @@ -use super::{Bookmark, Run}; use anyhow::Result; +use super::{Bookmark, Run}; +use crate::db::Database; + impl Run for Bookmark { - fn run(&self) -> Result<()> {} + fn run(&self) -> Result<()> { + let mut db = crate::db::Database::open()?; + self.add_bookmark(&mut db).and(db.save()) + } } -impl Bookmark {} +impl Bookmark { + fn add_bookmark(&self, db: &mut Database) -> Result<()> { + db.add_bookmark(self.bookmark_id.clone(), self.path.clone()); + Ok(()) + } +} diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 362d80a..08f3c8b 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -18,6 +18,13 @@ impl Run for Query { impl Query { fn query(&self, db: &mut Database) -> Result<()> { let now = util::current_time()?; + + if let Ok(is_mark) = self.try_bookmark(db) { + if is_mark { + return Ok(()); + } + } + let mut stream = self.get_stream(db, now)?; if self.interactive { @@ -28,7 +35,22 @@ impl Query { self.query_first(&mut stream, now) } } + fn try_bookmark(&self, db: &Database) -> Result { + // NOTE We only assume bookmarking if they supply one keyword + // Could be trivially changed to iterate over keywords + if self.keywords.len() == 1 { + let keyword = &self.keywords[0]; + if let Some(path) = db.get_bookmark(keyword) { + let handle = &mut io::stdout(); + return match writeln!(handle, "{}", path.to_str().unwrap()).pipe_exit("stdout") { + Ok(_) => Ok(true), + Err(_) => Err(()), + }; + } + } + Ok(false) + } fn query_interactive(&self, stream: &mut Stream, now: Epoch) -> Result<()> { let mut fzf = Self::get_fzf()?; let selection = loop { diff --git a/src/db/mod.rs b/src/db/mod.rs index 560e9e3..39d50d6 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::{fs, io}; use anyhow::{Context, Result, bail}; -use bincode::Options; +use bincode::{Options, serialize}; use ouroboros::self_referencing; pub use crate::db::dir::{Dir, Epoch, Rank}; @@ -19,11 +19,11 @@ pub struct Database { bytes: Vec, #[borrows(bytes)] #[covariant] - pub dirs: Vec>, - map_bytes: Vec, - #[borrows(map_bytes)] - #[covariant] - pub bookmarks: HashMap>, + // NOTE Directories and Bookmarks + // They must be the same field otherwise two closures which take bytes and yield each + // respectively would have to be constructed, causing bytes requiring bytes to have static + // lifetime + pub dirs: (Vec>, HashMap), dirty: bool, } @@ -32,65 +32,29 @@ impl Database { pub fn open() -> Result { let data_dir = config::data_dir()?; - let bookmarks_dir: PathBuf = config::bookmarks_dir()?; - Self::open_dir(data_dir, bookmarks_dir) + Self::open_dir(data_dir) } - pub fn open_dir(data_dir: impl AsRef, bookmarks_dir: impl AsRef) -> Result { + pub fn open_dir(data_dir: impl AsRef) -> Result { let data_dir = data_dir.as_ref(); let path = data_dir.join("db.zo"); let path = fs::canonicalize(&path).unwrap_or(path); - let bookmarks_dir = bookmarks_dir.as_ref(); - let bookmarks_path = bookmarks_dir.join("db_bm.zo"); - let bookmarks_path = fs::canonicalize(&bookmarks_path).unwrap_or(bookmarks_path); - - match (fs::read(&path), fs::read(&bookmarks_path)) { - (Ok(bytes), Ok(bookmarks_bytes)) => Self::try_new( - path, - bytes, - |bytes| Self::deserialize(bytes), - bookmarks_bytes, - |bookmarks_bytes| Self::deserialize_bookmarks(bookmarks_bytes), - false, - ), - (Err(e), _) if e.kind() == io::ErrorKind::NotFound => { + match fs::read(&path) { + Ok(bytes) => { + Self::try_new(path.clone(), bytes, |bytes| Self::deserialize(bytes, path), false) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { // Create data directory, but don't create any file yet. The file will be // created later by [`Database::save`] if any data is modified. fs::create_dir_all(data_dir).with_context(|| { format!("unable to create data directory: {}", data_dir.display()) })?; - Ok(Self::new( - path, - Vec::new(), - |_| Vec::new(), - Vec::new(), - |_| HashMap::new(), - false, - )) + Ok(Self::new(path, Vec::new(), |_| (Vec::new(), HashMap::new()), false)) } - (_, Err(e)) if e.kind() == io::ErrorKind::NotFound => { - // Create data directory, but don't create any file yet. The file will be - // created later by [`Database::save`] if any data is modified. - fs::create_dir_all(data_dir).with_context(|| { - format!("unable to create bookmarks directory: {}", data_dir.display()) - })?; - Ok(Self::new( - path, - Vec::new(), - |_| Vec::new(), - Vec::new(), - |_| HashMap::new(), - false, - )) - } - - (Err(e), _) => { + Err(e) => { Err(e).with_context(|| format!("could not read from database: {}", path.display())) } - (_, Err(e)) => Err(e).with_context(|| { - format!("could not read from bookmarks database: {}", bookmarks_path.display()) - }), } } @@ -100,7 +64,7 @@ impl Database { return Ok(()); } - let bytes = Self::serialize(self.dirs())?; + let bytes = Self::serialize((self.dirs(), self.bookmarks()))?; util::write(self.borrow_path(), bytes).context("could not write to database")?; self.with_dirty_mut(|dirty| *dirty = false); @@ -109,10 +73,10 @@ impl Database { /// Increments the rank of a directory, or creates it if it does not exist. pub fn add(&mut self, path: impl AsRef + Into, by: Rank, now: Epoch) { - self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) { + self.with_dirs_mut(|dirs| match dirs.0.iter_mut().find(|dir| dir.path == path.as_ref()) { Some(dir) => dir.rank = (dir.rank + by).max(0.0), None => { - dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }) + dirs.0.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }) } }); self.with_dirty_mut(|dirty| *dirty = true); @@ -123,7 +87,7 @@ impl Database { /// does a check before calling this, or calls `dedup()` afterward. pub fn add_unchecked(&mut self, path: impl AsRef + Into, rank: Rank, now: Epoch) { self.with_dirs_mut(|dirs| { - dirs.push(Dir { path: path.into().into(), rank, last_accessed: now }) + dirs.0.push(Dir { path: path.into().into(), rank, last_accessed: now }) }); self.with_dirty_mut(|dirty| *dirty = true); } @@ -131,13 +95,13 @@ impl Database { /// Increments the rank and updates the last_accessed of a directory, or /// creates it if it does not exist. pub fn add_update(&mut self, path: impl AsRef + Into, by: Rank, now: Epoch) { - self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) { + self.with_dirs_mut(|dirs| match dirs.0.iter_mut().find(|dir| dir.path == path.as_ref()) { Some(dir) => { dir.rank = (dir.rank + by).max(0.0); dir.last_accessed = now; } None => { - dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }) + dirs.0.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }) } }); self.with_dirty_mut(|dirty| *dirty = true); @@ -156,21 +120,21 @@ impl Database { } pub fn swap_remove(&mut self, idx: usize) { - self.with_dirs_mut(|dirs| dirs.swap_remove(idx)); + self.with_dirs_mut(|dirs| dirs.0.swap_remove(idx)); self.with_dirty_mut(|dirty| *dirty = true); } pub fn age(&mut self, max_age: Rank) { let mut dirty = false; self.with_dirs_mut(|dirs| { - let total_age = dirs.iter().map(|dir| dir.rank).sum::(); + let total_age = dirs.0.iter().map(|dir| dir.rank).sum::(); if total_age > max_age { let factor = 0.9 * max_age / total_age; - for idx in (0..dirs.len()).rev() { - let dir = &mut dirs[idx]; + for idx in (0..dirs.0.len()).rev() { + let dir = &mut dirs.0[idx]; dir.rank *= factor; if dir.rank < 1.0 { - dirs.swap_remove(idx); + dirs.0.swap_remove(idx); } } dirty = true; @@ -185,10 +149,10 @@ impl Database { let mut dirty = false; self.with_dirs_mut(|dirs| { - for idx in (1..dirs.len()).rev() { + for idx in (1..dirs.0.len()).rev() { // Check if curr_dir and next_dir have equal paths. - let curr_dir = &dirs[idx]; - let next_dir = &dirs[idx - 1]; + let curr_dir = &dirs.0[idx]; + let next_dir = &dirs.0[idx - 1]; if next_dir.path != curr_dir.path { continue; } @@ -196,12 +160,12 @@ impl Database { // Merge curr_dir's rank and last_accessed into next_dir. let rank = curr_dir.rank; let last_accessed = curr_dir.last_accessed; - let next_dir = &mut dirs[idx - 1]; + let next_dir = &mut dirs.0[idx - 1]; next_dir.last_accessed = next_dir.last_accessed.max(last_accessed); next_dir.rank += rank; // Delete curr_dir. - dirs.swap_remove(idx); + dirs.0.swap_remove(idx); dirty = true; } }); @@ -209,13 +173,13 @@ impl Database { } pub fn sort_by_path(&mut self) { - self.with_dirs_mut(|dirs| dirs.sort_unstable_by(|dir1, dir2| dir1.path.cmp(&dir2.path))); + self.with_dirs_mut(|dirs| dirs.0.sort_unstable_by(|dir1, dir2| dir1.path.cmp(&dir2.path))); self.with_dirty_mut(|dirty| *dirty = true); } pub fn sort_by_score(&mut self, now: Epoch) { self.with_dirs_mut(|dirs| { - dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| { + dirs.0.sort_unstable_by(|dir1: &Dir, dir2: &Dir| { dir1.score(now).total_cmp(&dir2.score(now)) }) }); @@ -227,10 +191,23 @@ impl Database { } pub fn dirs(&self) -> &[Dir] { - self.borrow_dirs() + &self.borrow_dirs().0 } - fn serialize(dirs: &[Dir<'_>]) -> Result> { + pub fn bookmarks(&self) -> &HashMap { + &self.borrow_dirs().1 + } + + pub fn get_bookmark(&self, id: &str) -> Option<&PathBuf> { + self.borrow_dirs().1.get(id) + } + + pub fn add_bookmark(&mut self, id: String, path: PathBuf) { + self.with_dirs_mut(|dirs| dirs.1.insert(id, path)); + self.with_dirty_mut(|is_dirty| *is_dirty = true); + } + + fn serialize(dirs: (&[Dir<'_>], &HashMap)) -> Result> { (|| -> bincode::Result<_> { // Preallocate buffer with combined size of sections. let buffer_size = @@ -246,7 +223,7 @@ impl Database { .context("could not serialize database") } - fn deserialize(bytes: &[u8]) -> Result> { + fn deserialize(bytes: &[u8], path: PathBuf) -> Result<(Vec, HashMap)> { // Assume a maximum size for the database. This prevents bincode from throwing // strange errors when it encounters invalid data. const MAX_SIZE: u64 = 32 << 20; // 32 MiB @@ -263,34 +240,24 @@ impl Database { let version = deserializer.deserialize(bytes_version)?; let dirs = match version { Self::VERSION => { - deserializer.deserialize(bytes_dirs).context("could not deserialize database")? - } - version => { - bail!("unsupported version (got {version}, supports {})", Self::VERSION) - } - }; - - Ok(dirs) - } - - fn deserialize_bookmarks(bytes: &[u8]) -> Result> { - // Assume a maximum size for the database. This prevents bincode from throwing - // strange errors when it encounters invalid data. - const MAX_SIZE: u64 = 32 << 20; // 32 MiB - let deserializer = &mut bincode::options().with_fixint_encoding().with_limit(MAX_SIZE); - - // Split bytes into sections. - let version_size = deserializer.serialized_size(&Self::VERSION).unwrap() as _; - if bytes.len() < version_size { - bail!("could not deserialize database: corrupted data"); - } - let (bytes_version, bytes_dirs) = bytes.split_at(version_size); - - // Deserialize sections. - let version = deserializer.deserialize(bytes_version)?; - let dirs = match version { - Self::VERSION => { - deserializer.deserialize(bytes_dirs).context("could not deserialize database")? + match deserializer.deserialize::<(Vec, HashMap)>(bytes_dirs) { + Err(err) => { + let dirs: Vec = match deserializer.deserialize(bytes_dirs) { + Ok(dirs) => dirs, + Err(_) => return Err(err).context("could not deserialize database"), + }; + let bookmarks: HashMap = HashMap::new(); + match serialize(&(&dirs, &bookmarks)) { + Ok(_) => { + util::write(path, bytes).context("could not write to database")?; + println!("yellow"); + return Ok((dirs, bookmarks)); + } + Err(_) => return Err(err).context("could not deserialize database"), + }; + } + Ok(dirs) => dirs, + } } version => { bail!("unsupported version (got {version}, supports {})", Self::VERSION) @@ -311,18 +278,15 @@ mod tests { let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" }; let now = 946684800; - let bookmarks_dir = tempfile::tempdir().unwrap(); - let bookmarks_path = if cfg!(windows) { r"C:\foo\bar2" } else { "/foo/bar2" }; - { - let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); - db.add(bookmarks_path, 1.0, now); - db.add(bookmarks_path, 1.0, now); + let mut db = Database::open_dir(data_dir.path()).unwrap(); + db.add(path, 1.0, now); + db.add(path, 1.0, now); db.save().unwrap(); } { - let db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); + let db = Database::open_dir(data_dir.path()).unwrap(); assert_eq!(db.dirs().len(), 1); let dir = &db.dirs()[0]; @@ -338,26 +302,23 @@ mod tests { let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" }; let now = 946684800; - let bookmarks_dir = tempfile::tempdir().unwrap(); - let bookmarks_path = if cfg!(windows) { r"C:\foo\bar2" } else { "/foo/bar2" }; - { - let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); - db.add(bookmarks_path, 1.0, now); - db.add(bookmarks_path, 1.0, now); + let mut db = Database::open_dir(data_dir.path()).unwrap(); + db.add(path, 1.0, now); + db.add(path, 1.0, now); db.save().unwrap(); } { - let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); - assert!(db.remove(bookmarks_path)); + let mut db = Database::open_dir(data_dir.path()).unwrap(); + assert!(db.remove(path)); db.save().unwrap(); } { - let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); + let mut db = Database::open_dir(data_dir.path()).unwrap(); assert!(db.dirs().is_empty()); - assert!(!db.remove(bookmarks_path)); + assert!(!db.remove(path)); db.save().unwrap(); } } diff --git a/src/db/stream.rs b/src/db/stream.rs index 4af7d7a..38fd598 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -153,6 +153,7 @@ impl StreamOptions { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::path::PathBuf; use rstest::rstest; @@ -180,7 +181,8 @@ mod tests { #[case(&["/foo/", "/bar"], "/foo/bar", false)] #[case(&["/foo/", "/bar"], "/foo/baz/bar", true)] fn query(#[case] keywords: &[&str], #[case] path: &str, #[case] is_match: bool) { - let db = &mut Database::new(PathBuf::new(), Vec::new(), |_| Vec::new(), false); + let db = + &mut Database::new(PathBuf::new(), Vec::new(), |_| (Vec::new(), HashMap::new()), false); let options = StreamOptions::new(0).with_keywords(keywords.iter()); let stream = Stream::new(db, options); assert_eq!(is_match, stream.filter_by_keywords(path)); diff --git a/templates/bash.txt b/templates/bash.txt index 560a050..a4177a8 100644 --- a/templates/bash.txt +++ b/templates/bash.txt @@ -86,8 +86,6 @@ function __zoxide_doctor() { # When using zoxide with --no-cmd, alias these internal functions as desired. # -__zoxide_z_prefix='z#' - # Jump to a directory using only keywords. function __zoxide_z() { __zoxide_doctor @@ -101,10 +99,6 @@ function __zoxide_z() { __zoxide_cd "$1" elif [[ $# -eq 2 && $1 == '--' ]]; then __zoxide_cd "$2" - elif [[ ${@: -1} == "${__zoxide_z_prefix}"?* ]]; then - # shellcheck disable=SC2124 - \builtin local result="${@: -1}" - __zoxide_cd "{{ "${result:${#__zoxide_z_prefix}}" }}" else \builtin local result # shellcheck disable=SC2312 @@ -144,8 +138,11 @@ function {{cmd}}i() { # - Completions don't work on `dumb` terminals. if [[ ${BASH_VERSINFO[0]:-0} -eq 4 && ${BASH_VERSINFO[1]:-0} -ge 4 || ${BASH_VERSINFO[0]:-0} -ge 5 ]] && [[ :"${SHELLOPTS}": =~ :(vi|emacs): && ${TERM} != 'dumb' ]]; then - # Use `printf '\e[5n'` to redraw line after fzf closes. - \builtin bind '"\e[0n": redraw-current-line' &>/dev/null + + function __zoxide_z_complete_helper() { + READLINE_LINE="{{ cmd }} ${__zoxide_result@Q}" + READLINE_POINT={{ "${#READLINE_LINE}" }} + } function __zoxide_z_complete() { # Only show completions when the cursor is at the end of the line. @@ -157,12 +154,15 @@ if [[ ${BASH_VERSINFO[0]:-0} -eq 4 && ${BASH_VERSINFO[1]:-0} -ge 4 || ${BASH_VER \builtin compgen -A directory -- "${COMP_WORDS[-1]}" || \builtin true ) # If there is a space after the last word, use interactive selection. - elif [[ -z ${COMP_WORDS[-1]} ]] && [[ ${COMP_WORDS[-2]} != "${__zoxide_z_prefix}"?* ]]; then - \builtin local result + elif [[ -z ${COMP_WORDS[-1]} ]]; then # shellcheck disable=SC2312 - result="$(\command zoxide query --exclude "$(__zoxide_pwd)" --interactive -- "{{ "${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-2}" }}")" && - COMPREPLY=("${__zoxide_z_prefix}${result}/") - \builtin printf '\e[5n' + __zoxide_result="$(\command zoxide query --exclude "$(__zoxide_pwd)" --interactive -- "{{ "${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-2}" }}")" && { + \builtin bind '"\e[0n": redraw-current-line' + \builtin printf '\e[5n' + + \builtin bind -x '"\e[0n": __zoxide_z_complete_helper "${result}"' + \builtin printf '\e[5n' + } fi }