From 79de8659b4b317d20ad6c5cb5d56e998d5d0c795 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Sat, 15 Mar 2025 19:27:33 -0700 Subject: [PATCH 1/7] feat!(wip): new bookmark cmd --- contrib/completions/_zoxide | 16 ++++++++++++++++ contrib/completions/_zoxide.ps1 | 8 ++++++++ contrib/completions/zoxide.bash | 19 ++++++++++++++++++- contrib/completions/zoxide.elv | 7 +++++++ contrib/completions/zoxide.fish | 3 +++ contrib/completions/zoxide.ts | 22 ++++++++++++++++++++++ src/cmd/bookmark.rs | 8 ++++++++ src/cmd/cmd.rs | 9 +++++++++ src/cmd/mod.rs | 2 ++ src/db/mod.rs | 18 ++++++++++++++++-- 10 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/cmd/bookmark.rs diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index 69882b41d..e507bcc09 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -141,6 +141,16 @@ _arguments "${_arguments_options[@]}" : \ '--version[Print version]' \ '*::paths:_files -/' \ && ret=0 +;; +(bookmark) +_arguments "${_arguments_options[@]}" : \ +'-h[Print help]' \ +'--help[Print help]' \ +'-V[Print version]' \ +'--version[Print version]' \ +':bookmark_id:' \ +':path:_files -/' \ +&& ret=0 ;; esac ;; @@ -156,6 +166,7 @@ _zoxide_commands() { 'init:Generate shell configuration' \ 'query:Search for a directory in the database' \ 'remove:Remove a directory from the database' \ +'bookmark:' \ ) _describe -t commands 'zoxide commands' commands "$@" } @@ -164,6 +175,11 @@ _zoxide__add_commands() { local commands; commands=() _describe -t commands 'zoxide add commands' commands "$@" } +(( $+functions[_zoxide__bookmark_commands] )) || +_zoxide__bookmark_commands() { + local commands; commands=() + _describe -t commands 'zoxide bookmark commands' commands "$@" +} (( $+functions[_zoxide__edit_commands] )) || _zoxide__edit_commands() { local commands; commands=( diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index af15c66dd..b1335d005 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -31,6 +31,7 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('init', 'init', [CompletionResultType]::ParameterValue, 'Generate shell configuration') [CompletionResult]::new('query', 'query', [CompletionResultType]::ParameterValue, 'Search for a directory in the database') [CompletionResult]::new('remove', 'remove', [CompletionResultType]::ParameterValue, 'Remove a directory from the database') + [CompletionResult]::new('bookmark', 'bookmark', [CompletionResultType]::ParameterValue, 'bookmark') break } 'zoxide;add' { @@ -121,6 +122,13 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') break } + 'zoxide;bookmark' { + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') + break + } }) $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | diff --git a/contrib/completions/zoxide.bash b/contrib/completions/zoxide.bash index 73dbd45f2..b47d110af 100644 --- a/contrib/completions/zoxide.bash +++ b/contrib/completions/zoxide.bash @@ -15,6 +15,9 @@ _zoxide() { zoxide,add) cmd="zoxide__add" ;; + zoxide,bookmark) + cmd="zoxide__bookmark" + ;; zoxide,edit) cmd="zoxide__edit" ;; @@ -49,7 +52,7 @@ _zoxide() { case "${cmd}" in zoxide) - opts="-h -V --help --version add edit import init query remove" + opts="-h -V --help --version add edit import init query remove bookmark" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -76,6 +79,20 @@ _zoxide() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + zoxide__bookmark) + opts="-h -V --help --version " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; zoxide__edit) opts="-h -V --help --version decrement delete increment reload" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/contrib/completions/zoxide.elv b/contrib/completions/zoxide.elv index 6183d37b4..0ef16864b 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -28,6 +28,7 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand init 'Generate shell configuration' cand query 'Search for a directory in the database' cand remove 'Remove a directory from the database' + cand bookmark 'bookmark' } &'zoxide;add'= { cand -h 'Print help' @@ -107,6 +108,12 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand -V 'Print version' cand --version 'Print version' } + &'zoxide;bookmark'= { + cand -h 'Print help' + cand --help 'Print help' + cand -V 'Print version' + cand --version 'Print version' + } ] $completions[$command] } diff --git a/contrib/completions/zoxide.fish b/contrib/completions/zoxide.fish index 96dd86e18..f29c8f950 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -32,6 +32,7 @@ complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "import" -d 'Import en complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "init" -d 'Generate shell configuration' complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "query" -d 'Search for a directory in the database' complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "remove" -d 'Remove a directory from the database' +complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "bookmark" complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand edit; and not __fish_seen_subcommand_from decrement delete increment reload" -s h -l help -d 'Print help' @@ -66,3 +67,5 @@ complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s h -l help -d 'Pr complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s V -l version -d 'Print version' +complete -c zoxide -n "__fish_zoxide_using_subcommand bookmark" -s h -l help -d 'Print help' +complete -c zoxide -n "__fish_zoxide_using_subcommand bookmark" -s V -l version -d 'Print version' diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index 0200591b0..cafd3dbc0 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -263,6 +263,28 @@ const completion: Fig.Spec = { template: "folders", }, }, + { + name: "bookmark", + options: [ + { + name: ["-h", "--help"], + description: "Print help", + }, + { + name: ["-V", "--version"], + description: "Print version", + }, + ], + args: [ + { + name: "bookmark_id", + }, + { + name: "path", + template: "folders", + }, + ] + }, ], options: [ { diff --git a/src/cmd/bookmark.rs b/src/cmd/bookmark.rs new file mode 100644 index 000000000..5c369219f --- /dev/null +++ b/src/cmd/bookmark.rs @@ -0,0 +1,8 @@ +use super::{Bookmark, Run}; +use anyhow::Result; + +impl Run for Bookmark { + fn run(&self) -> Result<()> {} +} + +impl Bookmark {} diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index cff7e790c..f92627726 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -47,6 +47,7 @@ pub enum Cmd { Init(Init), Query(Query), Remove(Remove), + Bookmark(Bookmark), } /// Add a new directory or increment its rank @@ -192,3 +193,11 @@ pub struct Remove { #[clap(value_hint = ValueHint::DirPath)] pub paths: Vec, } + +#[derive(Debug, Parser)] +#[clap(author, help_template = HelpTemplate)] +pub struct Bookmark { + pub bookmark_id: String, + #[clap(value_hint = ValueHint::DirPath)] + pub path: PathBuf, +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 5c1747429..8d03691be 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,4 +1,5 @@ mod add; +mod bookmark; mod cmd; mod edit; mod import; @@ -23,6 +24,7 @@ impl Run for Cmd { Cmd::Init(cmd) => cmd.run(), Cmd::Query(cmd) => cmd.run(), Cmd::Remove(cmd) => cmd.run(), + Cmd::Bookmark(cmd) => cmd.run(), } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index a19efe902..cb32067d8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,7 @@ mod dir; mod stream; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::{fs, io}; @@ -19,6 +20,10 @@ pub struct Database { #[borrows(bytes)] #[covariant] pub dirs: Vec>, + map_bytes: Vec, + #[borrows(map_bytes)] + #[covariant] + pub bookmarks: HashMap>, dirty: bool, } @@ -27,6 +32,7 @@ impl Database { pub fn open() -> Result { let data_dir = config::data_dir()?; + let bookmarks_dir: PathBuf = config::bookmarks_dir()?; Self::open_dir(data_dir) } @@ -35,15 +41,23 @@ impl Database { let path = data_dir.join("db.zo"); let path = fs::canonicalize(&path).unwrap_or(path); + match fs::read(&path) + match fs::read(&path) { - Ok(bytes) => Self::try_new(path, bytes, |bytes| Self::deserialize(bytes), false), + Ok(bytes) => Self::try_new( + path, + bytes, + |bytes| Self::deserialize(bytes), + |_| Self::deserialize_bookmarks(bytes), + 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(), false)) + Ok(Self::new(path, Vec::new(), |_| Vec::new(), |_| HashMap::new(), false)) } Err(e) => { Err(e).with_context(|| format!("could not read from database: {}", path.display())) From 5fbacb998d157d21cd862930c12910f172d337f1 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Sat, 15 Mar 2025 20:02:12 -0700 Subject: [PATCH 2/7] probably the wrong solution --- src/config.rs | 12 ++++++ src/db/mod.rs | 101 ++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0aeda5c5c..2609844bb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,18 @@ pub fn data_dir() -> Result { Ok(dir) } +pub fn bookmarks_dir() -> Result { + let dir = match env::var_os("_ZO_BOOKMARKS_DIR") { + Some(path) => PathBuf::from(path), + None => dirs::data_local_dir() + .context("could not find bookmarks directory, please set _ZO_BOOKMARKS_DIR manually")? + .join("zoxide"), + }; + + ensure!(dir.is_absolute(), "_ZO_BOOKMARKS_DIR must be an absolute path"); + Ok(dir) +} + pub fn echo() -> bool { env::var_os("_ZO_ECHO").is_some_and(|var| var == "1") } diff --git a/src/db/mod.rs b/src/db/mod.rs index cb32067d8..560e9e3a4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -33,35 +33,64 @@ impl Database { pub fn open() -> Result { let data_dir = config::data_dir()?; let bookmarks_dir: PathBuf = config::bookmarks_dir()?; - Self::open_dir(data_dir) + Self::open_dir(data_dir, bookmarks_dir) } - pub fn open_dir(data_dir: impl AsRef) -> Result { + pub fn open_dir(data_dir: impl AsRef, bookmarks_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); - match fs::read(&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) { - Ok(bytes) => Self::try_new( + match (fs::read(&path), fs::read(&bookmarks_path)) { + (Ok(bytes), Ok(bookmarks_bytes)) => Self::try_new( path, bytes, |bytes| Self::deserialize(bytes), - |_| Self::deserialize_bookmarks(bytes), + bookmarks_bytes, + |bookmarks_bytes| Self::deserialize_bookmarks(bookmarks_bytes), false, ), - Err(e) if e.kind() == io::ErrorKind::NotFound => { + (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(), |_| HashMap::new(), false)) + Ok(Self::new( + path, + Vec::new(), + |_| Vec::new(), + Vec::new(), + |_| HashMap::new(), + false, + )) } - Err(e) => { + (_, 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).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()) + }), } } @@ -243,6 +272,33 @@ impl Database { 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")? + } + version => { + bail!("unsupported version (got {version}, supports {})", Self::VERSION) + } + }; + + Ok(dirs) + } } #[cfg(test)] @@ -255,15 +311,18 @@ 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()).unwrap(); - db.add(path, 1.0, now); - db.add(path, 1.0, now); + 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); db.save().unwrap(); } { - let db = Database::open_dir(data_dir.path()).unwrap(); + let db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); assert_eq!(db.dirs().len(), 1); let dir = &db.dirs()[0]; @@ -279,22 +338,26 @@ 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()).unwrap(); - db.add(path, 1.0, now); + 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); db.save().unwrap(); } { - let mut db = Database::open_dir(data_dir.path()).unwrap(); - assert!(db.remove(path)); + let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); + assert!(db.remove(bookmarks_path)); db.save().unwrap(); } { - let mut db = Database::open_dir(data_dir.path()).unwrap(); + let mut db = Database::open_dir(data_dir.path(), bookmarks_dir.path()).unwrap(); assert!(db.dirs().is_empty()); - assert!(!db.remove(path)); + assert!(!db.remove(bookmarks_path)); db.save().unwrap(); } } From 797f424c54fc604eeae366d00b8f7a76e3600ea2 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Wed, 19 Mar 2025 15:06:13 -0700 Subject: [PATCH 3/7] feat: bookmark cmd fix: remove old bookmark dir fix: move stream test imports feat: bookmark completions --- contrib/completions/_zoxide | 16 ++++++ contrib/completions/_zoxide.ps1 | 8 +++ contrib/completions/zoxide.bash | 19 ++++++- contrib/completions/zoxide.elv | 7 +++ contrib/completions/zoxide.fish | 3 ++ contrib/completions/zoxide.ts | 22 ++++++++ src/cmd/bookmark.rs | 17 +++++++ src/cmd/cmd.rs | 9 ++++ src/cmd/mod.rs | 2 + src/cmd/query.rs | 22 ++++++++ src/db/mod.rs | 90 +++++++++++++++++++++++---------- src/db/stream.rs | 5 +- 12 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 src/cmd/bookmark.rs diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index 69882b41d..c4c343684 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -141,6 +141,16 @@ _arguments "${_arguments_options[@]}" : \ '--version[Print version]' \ '*::paths:_files -/' \ && ret=0 +;; +(bookmark) +_arguments "${_arguments_options[@]}" : \ +'-h[Print help]' \ +'--help[Print help]' \ +'-V[Print version]' \ +'--version[Print version]' \ +':bookmark_id:' \ +':path:_files -/' \ +&& ret=0 ;; esac ;; @@ -156,6 +166,7 @@ _zoxide_commands() { 'init:Generate shell configuration' \ 'query:Search for a directory in the database' \ 'remove:Remove a directory from the database' \ +'bookmark:Bookmarks a directory for quick querying' \ ) _describe -t commands 'zoxide commands' commands "$@" } @@ -164,6 +175,11 @@ _zoxide__add_commands() { local commands; commands=() _describe -t commands 'zoxide add commands' commands "$@" } +(( $+functions[_zoxide__bookmark_commands] )) || +_zoxide__bookmark_commands() { + local commands; commands=() + _describe -t commands 'zoxide bookmark commands' commands "$@" +} (( $+functions[_zoxide__edit_commands] )) || _zoxide__edit_commands() { local commands; commands=( diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index af15c66dd..b1335d005 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -31,6 +31,7 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('init', 'init', [CompletionResultType]::ParameterValue, 'Generate shell configuration') [CompletionResult]::new('query', 'query', [CompletionResultType]::ParameterValue, 'Search for a directory in the database') [CompletionResult]::new('remove', 'remove', [CompletionResultType]::ParameterValue, 'Remove a directory from the database') + [CompletionResult]::new('bookmark', 'bookmark', [CompletionResultType]::ParameterValue, 'bookmark') break } 'zoxide;add' { @@ -121,6 +122,13 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') break } + 'zoxide;bookmark' { + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') + [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version') + break + } }) $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | diff --git a/contrib/completions/zoxide.bash b/contrib/completions/zoxide.bash index 73dbd45f2..b47d110af 100644 --- a/contrib/completions/zoxide.bash +++ b/contrib/completions/zoxide.bash @@ -15,6 +15,9 @@ _zoxide() { zoxide,add) cmd="zoxide__add" ;; + zoxide,bookmark) + cmd="zoxide__bookmark" + ;; zoxide,edit) cmd="zoxide__edit" ;; @@ -49,7 +52,7 @@ _zoxide() { case "${cmd}" in zoxide) - opts="-h -V --help --version add edit import init query remove" + opts="-h -V --help --version add edit import init query remove bookmark" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -76,6 +79,20 @@ _zoxide() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + zoxide__bookmark) + opts="-h -V --help --version " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; zoxide__edit) opts="-h -V --help --version decrement delete increment reload" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/contrib/completions/zoxide.elv b/contrib/completions/zoxide.elv index 6183d37b4..0ef16864b 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -28,6 +28,7 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand init 'Generate shell configuration' cand query 'Search for a directory in the database' cand remove 'Remove a directory from the database' + cand bookmark 'bookmark' } &'zoxide;add'= { cand -h 'Print help' @@ -107,6 +108,12 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand -V 'Print version' cand --version 'Print version' } + &'zoxide;bookmark'= { + cand -h 'Print help' + cand --help 'Print help' + cand -V 'Print version' + cand --version 'Print version' + } ] $completions[$command] } diff --git a/contrib/completions/zoxide.fish b/contrib/completions/zoxide.fish index 96dd86e18..f29c8f950 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -32,6 +32,7 @@ complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "import" -d 'Import en complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "init" -d 'Generate shell configuration' complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "query" -d 'Search for a directory in the database' complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "remove" -d 'Remove a directory from the database' +complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "bookmark" complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand edit; and not __fish_seen_subcommand_from decrement delete increment reload" -s h -l help -d 'Print help' @@ -66,3 +67,5 @@ complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s h -l help -d 'Pr complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s V -l version -d 'Print version' +complete -c zoxide -n "__fish_zoxide_using_subcommand bookmark" -s h -l help -d 'Print help' +complete -c zoxide -n "__fish_zoxide_using_subcommand bookmark" -s V -l version -d 'Print version' diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index 0200591b0..cafd3dbc0 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -263,6 +263,28 @@ const completion: Fig.Spec = { template: "folders", }, }, + { + name: "bookmark", + options: [ + { + name: ["-h", "--help"], + description: "Print help", + }, + { + name: ["-V", "--version"], + description: "Print version", + }, + ], + args: [ + { + name: "bookmark_id", + }, + { + name: "path", + template: "folders", + }, + ] + }, ], options: [ { diff --git a/src/cmd/bookmark.rs b/src/cmd/bookmark.rs new file mode 100644 index 000000000..8037d51b4 --- /dev/null +++ b/src/cmd/bookmark.rs @@ -0,0 +1,17 @@ +use crate::db::Database; + +use super::{Bookmark, Run}; +use anyhow::Result; + +impl Run for Bookmark { + fn run(&self) -> Result<()> { + let mut db = crate::db::Database::open()?; + self.add_bookmark(&mut db).and(db.save()) + } +} + +impl Bookmark { + fn add_bookmark(&self, db: &mut Database) -> Result<()> { + Ok(db.add_bookmark(self.bookmark_id.clone(), self.path.clone())) + } +} diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index cff7e790c..f92627726 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -47,6 +47,7 @@ pub enum Cmd { Init(Init), Query(Query), Remove(Remove), + Bookmark(Bookmark), } /// Add a new directory or increment its rank @@ -192,3 +193,11 @@ pub struct Remove { #[clap(value_hint = ValueHint::DirPath)] pub paths: Vec, } + +#[derive(Debug, Parser)] +#[clap(author, help_template = HelpTemplate)] +pub struct Bookmark { + pub bookmark_id: String, + #[clap(value_hint = ValueHint::DirPath)] + pub path: PathBuf, +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 5c1747429..8d03691be 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,4 +1,5 @@ mod add; +mod bookmark; mod cmd; mod edit; mod import; @@ -23,6 +24,7 @@ impl Run for Cmd { Cmd::Init(cmd) => cmd.run(), Cmd::Query(cmd) => cmd.run(), Cmd::Remove(cmd) => cmd.run(), + Cmd::Bookmark(cmd) => cmd.run(), } } } diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 362d80a37..08f3c8b9d 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 a19efe902..39d50d6aa 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,11 +1,12 @@ mod dir; mod stream; +use std::collections::HashMap; 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}; @@ -18,7 +19,11 @@ pub struct Database { bytes: Vec, #[borrows(bytes)] #[covariant] - pub dirs: Vec>, + // 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, } @@ -36,14 +41,16 @@ impl Database { let path = fs::canonicalize(&path).unwrap_or(path); match fs::read(&path) { - Ok(bytes) => Self::try_new(path, bytes, |bytes| Self::deserialize(bytes), false), + 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(), false)) + Ok(Self::new(path, Vec::new(), |_| (Vec::new(), HashMap::new()), false)) } Err(e) => { Err(e).with_context(|| format!("could not read from database: {}", path.display())) @@ -57,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); @@ -66,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); @@ -80,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); } @@ -88,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); @@ -113,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; @@ -142,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; } @@ -153,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; } }); @@ -166,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)) }) }); @@ -184,10 +191,23 @@ impl Database { } pub fn dirs(&self) -> &[Dir] { - self.borrow_dirs() + &self.borrow_dirs().0 + } + + pub fn bookmarks(&self) -> &HashMap { + &self.borrow_dirs().1 + } + + pub fn get_bookmark(&self, id: &str) -> Option<&PathBuf> { + self.borrow_dirs().1.get(id) } - fn serialize(dirs: &[Dir<'_>]) -> Result> { + 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 = @@ -203,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 @@ -220,7 +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")? + 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) @@ -268,6 +305,7 @@ mod tests { { 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(); } diff --git a/src/db/stream.rs b/src/db/stream.rs index 4af7d7a93..7eb09a7ac 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -153,7 +153,7 @@ impl StreamOptions { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{collections::HashMap, path::PathBuf}; use rstest::rstest; @@ -180,7 +180,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)); From d0ce4d9d04cd2db9fae39edff31f16b9d5fde459 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Wed, 19 Mar 2025 15:53:32 -0700 Subject: [PATCH 4/7] docs: add bookmark description on man page --- contrib/completions/_zoxide | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index c4c343684..e507bcc09 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -166,7 +166,7 @@ _zoxide_commands() { 'init:Generate shell configuration' \ 'query:Search for a directory in the database' \ 'remove:Remove a directory from the database' \ -'bookmark:Bookmarks a directory for quick querying' \ +'bookmark:' \ ) _describe -t commands 'zoxide commands' commands "$@" } From a59208d2ee391677e4d5430687831b93ab263999 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Wed, 19 Mar 2025 15:58:39 -0700 Subject: [PATCH 5/7] fix: move add bookmark of return --- src/cmd/bookmark.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cmd/bookmark.rs b/src/cmd/bookmark.rs index 8037d51b4..f8f3bb02e 100644 --- a/src/cmd/bookmark.rs +++ b/src/cmd/bookmark.rs @@ -12,6 +12,7 @@ impl Run for Bookmark { impl Bookmark { fn add_bookmark(&self, db: &mut Database) -> Result<()> { - Ok(db.add_bookmark(self.bookmark_id.clone(), self.path.clone())) + db.add_bookmark(self.bookmark_id.clone(), self.path.clone()); + Ok(()) } } From ecc48a2380860b7634c1f0ae4878b980689f53f5 Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Wed, 19 Mar 2025 16:04:14 -0700 Subject: [PATCH 6/7] reformat --- src/cmd/bookmark.rs | 4 ++-- src/db/stream.rs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cmd/bookmark.rs b/src/cmd/bookmark.rs index f8f3bb02e..f206dc076 100644 --- a/src/cmd/bookmark.rs +++ b/src/cmd/bookmark.rs @@ -1,7 +1,7 @@ -use crate::db::Database; +use anyhow::Result; use super::{Bookmark, Run}; -use anyhow::Result; +use crate::db::Database; impl Run for Bookmark { fn run(&self) -> Result<()> { diff --git a/src/db/stream.rs b/src/db/stream.rs index 7eb09a7ac..38fd598e7 100644 --- a/src/db/stream.rs +++ b/src/db/stream.rs @@ -153,7 +153,8 @@ impl StreamOptions { #[cfg(test)] mod tests { - use std::{collections::HashMap, path::PathBuf}; + use std::collections::HashMap; + use std::path::PathBuf; use rstest::rstest; From d1d5ca126ca437ea03363cebb5b22578835b1f8c Mon Sep 17 00:00:00 2001 From: Azalea Colburn Date: Sun, 23 Mar 2025 08:47:49 -0700 Subject: [PATCH 7/7] chore: remove old bookmarks dir env var chore: remove old log --- src/config.rs | 12 ------------ src/db/mod.rs | 1 - 2 files changed, 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2609844bb..0aeda5c5c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,18 +19,6 @@ pub fn data_dir() -> Result { Ok(dir) } -pub fn bookmarks_dir() -> Result { - let dir = match env::var_os("_ZO_BOOKMARKS_DIR") { - Some(path) => PathBuf::from(path), - None => dirs::data_local_dir() - .context("could not find bookmarks directory, please set _ZO_BOOKMARKS_DIR manually")? - .join("zoxide"), - }; - - ensure!(dir.is_absolute(), "_ZO_BOOKMARKS_DIR must be an absolute path"); - Ok(dir) -} - pub fn echo() -> bool { env::var_os("_ZO_ECHO").is_some_and(|var| var == "1") } diff --git a/src/db/mod.rs b/src/db/mod.rs index 39d50d6aa..607456206 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -250,7 +250,6 @@ impl Database { 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"),