Feature: zoxide edit subcommand

Allows editing on the backing db by opening up $EDITOR with a
human-editable format. Upon exit of the editor, changes are validated
and written back to the DB.

Fixes: #453
This commit is contained in:
James Falcon 2022-09-21 23:26:18 -05:00
parent 9d9bcfcac2
commit 635a97e812
13 changed files with 317 additions and 7 deletions

54
Cargo.lock generated
View File

@ -181,6 +181,20 @@ dependencies = [
"os_str_bytes",
]
[[package]]
name = "console"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89eab4d20ce20cea182308bca13088fecea9c05f6776cf287205d41a0ed3c847"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"terminal_size",
"unicode-width",
"winapi",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.11"
@ -191,6 +205,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "dialoguer"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1"
dependencies = [
"console",
"tempfile",
"zeroize",
]
[[package]]
name = "difflib"
version = "0.4.0"
@ -235,6 +260,12 @@ version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]]
name = "fastrand"
version = "1.8.0"
@ -674,6 +705,16 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "terminal_size"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "termtree"
version = "0.2.4"
@ -730,6 +771,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf"
[[package]]
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]]
name = "version_check"
version = "0.9.4"
@ -814,6 +861,12 @@ dependencies = [
"shell-words",
]
[[package]]
name = "zeroize"
version = "1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f"
[[package]]
name = "zoxide"
version = "0.8.3"
@ -825,6 +878,7 @@ dependencies = [
"clap",
"clap_complete",
"clap_complete_fig",
"dialoguer",
"dirs",
"dunce",
"fastrand",

View File

@ -23,11 +23,13 @@ anyhow = "1.0.32"
askama = { version = "0.11.0", default-features = false }
bincode = "1.3.1"
clap = { version = "3.1.0", features = ["derive"] }
dialoguer = "0.10.2"
dirs = "4.0.0"
dunce = "1.0.1"
fastrand = "1.7.0"
glob = "0.3.0"
serde = { version = "1.0.116", features = ["derive"] }
tempfile = "3.1.0"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.24.1", default-features = false, features = [
@ -47,7 +49,6 @@ clap_complete_fig = "3.1.0"
assert_cmd = "2.0.0"
rstest = { version = "0.15.0", default-features = false }
rstest_reuse = "0.4.0"
tempfile = "3.1.0"
[features]
default = []

View File

@ -37,6 +37,14 @@ _arguments "${_arguments_options[@]}" \
'*::paths:_files -/' \
&& ret=0
;;
(edit)
_arguments "${_arguments_options[@]}" \
'-h[Print help information]' \
'--help[Print help information]' \
'-V[Print version information]' \
'--version[Print version information]' \
&& ret=0
;;
(import)
_arguments "${_arguments_options[@]}" \
'--from=[Application to import from]:FROM:(autojump z)' \
@ -97,6 +105,7 @@ esac
_zoxide_commands() {
local commands; commands=(
'add:Add a new directory or increment its rank' \
'edit:Modify list of paths and rankings in default editor' \
'import:Import entries from another application' \
'init:Generate shell configuration' \
'query:Search for a directory in the database' \
@ -109,6 +118,11 @@ _zoxide__add_commands() {
local commands; commands=()
_describe -t commands 'zoxide add commands' commands "$@"
}
(( $+functions[_zoxide__edit_commands] )) ||
_zoxide__edit_commands() {
local commands; commands=()
_describe -t commands 'zoxide edit commands' commands "$@"
}
(( $+functions[_zoxide__import_commands] )) ||
_zoxide__import_commands() {
local commands; commands=()

View File

@ -26,6 +26,7 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock {
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
[CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a new directory or increment its rank')
[CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Modify list of paths and rankings in default editor')
[CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import entries from another application')
[CompletionResult]::new('init', 'init', [CompletionResultType]::ParameterValue, 'Generate shell configuration')
[CompletionResult]::new('query', 'query', [CompletionResultType]::ParameterValue, 'Search for a directory in the database')
@ -39,6 +40,13 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock {
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
break
}
'zoxide;edit' {
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
break
}
'zoxide;import' {
[CompletionResult]::new('--from', 'from', [CompletionResultType]::ParameterName, 'Application to import from')
[CompletionResult]::new('--merge', 'merge', [CompletionResultType]::ParameterName, 'Merge into existing database')

View File

@ -15,6 +15,9 @@ _zoxide() {
add)
cmd+="__add"
;;
edit)
cmd+="__edit"
;;
import)
cmd+="__import"
;;
@ -34,7 +37,7 @@ _zoxide() {
case "${cmd}" in
zoxide)
opts="-h -V --help --version add import init query remove"
opts="-h -V --help --version add edit import init query remove"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
@ -61,6 +64,20 @@ _zoxide() {
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
zoxide__edit)
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__import)
opts="-h -V --from --merge --help --version <PATH>"
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then

View File

@ -23,6 +23,7 @@ set edit:completion:arg-completer[zoxide] = {|@words|
cand -V 'Print version information'
cand --version 'Print version information'
cand add 'Add a new directory or increment its rank'
cand edit 'Modify list of paths and rankings in default editor'
cand import 'Import entries from another application'
cand init 'Generate shell configuration'
cand query 'Search for a directory in the database'
@ -34,6 +35,12 @@ set edit:completion:arg-completer[zoxide] = {|@words|
cand -V 'Print version information'
cand --version 'Print version information'
}
&'zoxide;edit'= {
cand -h 'Print help information'
cand --help 'Print help information'
cand -V 'Print version information'
cand --version 'Print version information'
}
&'zoxide;import'= {
cand --from 'Application to import from'
cand --merge 'Merge into existing database'

View File

@ -1,12 +1,15 @@
complete -c zoxide -n "__fish_use_subcommand" -s h -l help -d 'Print help information'
complete -c zoxide -n "__fish_use_subcommand" -s V -l version -d 'Print version information'
complete -c zoxide -n "__fish_use_subcommand" -f -a "add" -d 'Add a new directory or increment its rank'
complete -c zoxide -n "__fish_use_subcommand" -f -a "edit" -d 'Modify list of paths and rankings in default editor'
complete -c zoxide -n "__fish_use_subcommand" -f -a "import" -d 'Import entries from another application'
complete -c zoxide -n "__fish_use_subcommand" -f -a "init" -d 'Generate shell configuration'
complete -c zoxide -n "__fish_use_subcommand" -f -a "query" -d 'Search for a directory in the database'
complete -c zoxide -n "__fish_use_subcommand" -f -a "remove" -d 'Remove a directory from the database'
complete -c zoxide -n "__fish_seen_subcommand_from add" -s h -l help -d 'Print help information'
complete -c zoxide -n "__fish_seen_subcommand_from add" -s V -l version -d 'Print version information'
complete -c zoxide -n "__fish_seen_subcommand_from edit" -s h -l help -d 'Print help information'
complete -c zoxide -n "__fish_seen_subcommand_from edit" -s V -l version -d 'Print version information'
complete -c zoxide -n "__fish_seen_subcommand_from import" -l from -d 'Application to import from' -r -f -a "{autojump ,z }"
complete -c zoxide -n "__fish_seen_subcommand_from import" -l merge -d 'Merge into existing database'
complete -c zoxide -n "__fish_seen_subcommand_from import" -s h -l help -d 'Print help information'

View File

@ -21,6 +21,20 @@ const completion: Fig.Spec = {
template: "folders",
},
},
{
name: "edit",
description: "Modify list of paths and rankings in default editor",
options: [
{
name: ["-h", "--help"],
description: "Print help information",
},
{
name: ["-V", "--version"],
description: "Print version information",
},
],
},
{
name: "import",
description: "Import entries from another application",

View File

@ -24,6 +24,7 @@ const ENV_HELP: &str = "ENVIRONMENT VARIABLES:
)]
pub enum Cmd {
Add(Add),
Edit(Edit),
Import(Import),
Init(Init),
Query(Query),
@ -37,6 +38,10 @@ pub struct Add {
pub paths: Vec<PathBuf>,
}
/// Modify list of paths and rankings in default editor
#[derive(Debug, Parser)]
pub struct Edit {}
/// Import entries from another application
#[derive(Debug, Parser)]
pub struct Import {

181
src/cmd/edit.rs Normal file
View File

@ -0,0 +1,181 @@
use crate::cmd::{Edit, Run};
use crate::db::{db_path, Database, DatabaseFile, Epoch, Rank};
use crate::util::{rename, resolve_path};
use crate::{config, util};
use anyhow::Result;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use core::mem;
use dialoguer::{Editor, Input};
use tempfile::tempdir;
const HEADER: &str = "\
# Blank lines and lines prepended with '#' are ignored; Line order is insignificant
# last_accessed,rank,path
";
enum ValidationResult {
Success,
Retry,
Exit,
}
impl Run for Edit {
fn run(&self) -> Result<()> {
let temp_dir = tempdir()?;
let temp_dir_path = temp_dir.path();
while let Some(db_edits) = get_db_edits()? {
let mut db_file = DatabaseFile::new(temp_dir_path);
let mut db = db_file.open()?;
let result = validate_db(&mut db, db_edits);
match result {
ValidationResult::Success => {
db.save()?;
mem::drop(db);
mem::drop(db_file);
rename(db_path(temp_dir_path), db_path(config::data_dir()?))?;
return Ok(());
}
ValidationResult::Exit => break,
ValidationResult::Retry => continue,
}
}
println!("Zoxide database not altered");
Ok(())
}
}
fn get_db_edits() -> Result<Option<String>> {
let data_dir = config::data_dir()?;
let mut db = DatabaseFile::new(data_dir);
let mut db = db.open()?;
let mut stream = db.stream(util::current_time().unwrap());
let mut to_edit = String::from(HEADER);
while let Some(dir) = stream.next() {
writeln!(&mut to_edit, "{},{},{}", dir.last_accessed, dir.rank, dir.path)?;
}
Ok(Editor::new().edit(&to_edit)?)
}
fn validate_db(db: &mut Database, db_edits: String) -> ValidationResult {
let lines = db_edits.lines();
let mut errors: Vec<(usize, String)> = Vec::new();
let mut warnings: Vec<(usize, String)> = Vec::new();
for (index, line) in lines.enumerate() {
let line_number = index + 1;
let first_char = line.trim().chars().next();
if let Some(first_char) = first_char {
if first_char == '#' {
continue;
}
} else {
continue;
}
let mut split = line.split(',');
let (last_accessed_txt, rank_txt, path_txt) = (split.next(), split.next(), split.next());
if split.next().is_some() {
errors.push((line_number, "Too many values on line".to_string()));
}
let last_accessed: Option<Epoch> = match last_accessed_txt {
Some(value) => match value.trim().parse::<Epoch>() {
Ok(value) => Some(value),
Err(e) => {
errors.push((line_number, e.to_string()));
None
}
},
None => {
errors.push((line_number, "Cannot parse 'last_accessed' field".to_string()));
None
}
};
let rank: Option<Rank> = match rank_txt {
Some(value) => match value.trim().parse::<Rank>() {
Ok(value) => Some(value),
Err(e) => {
errors.push((line_number, e.to_string()));
None
}
},
None => {
errors.push((line_number, "Cannot parse 'rank' field".to_string()));
None
}
};
let path: Option<String> = match path_txt {
Some(value) => {
if value.trim() != value {
warnings.push((line_number, "path contains trailing whitespace".to_string()));
}
match resolve_path(&PathBuf::from(value)) {
Ok(v) => {
if v.to_str().unwrap() != value {
errors.push((line_number, "path must be an absolute path".to_string()));
}
Some(value.to_string())
}
Err(e) => {
errors.push((line_number, e.to_string()));
None
}
}
}
None => {
errors.push((line_number, "Cannot parse 'path' field".to_string()));
None
}
};
if let (Some(path), Some(last_accessed), Some(rank)) = (path, last_accessed, rank) {
db.add_raw(&path, last_accessed, rank);
}
}
let has_warnings = !warnings.is_empty();
let has_errors = !errors.is_empty();
if has_warnings {
println!("Warnings:");
for (line_num, warning) in warnings {
println!("{line_num}: {warning}");
}
println!();
}
if has_errors {
println!("Errors:");
for (line_num, error) in errors {
println!("line {line_num}: {error}");
}
println!();
}
if has_warnings || has_errors {
println!("You may:");
println!("(e)dit the file again");
println!("e(x)it without saving changes");
if !has_errors {
println!("(s)ave changes and exit (DANGER!)");
}
let selection = Input::new()
.with_prompt("Choice")
.validate_with(|input: &String| -> Result<(), &str> {
if input == "e" || input == "x" || (input == "s" && !has_errors) {
Ok(())
} else {
Err("Invalid selection.")
}
})
.interact()
.unwrap();
return match selection.as_str() {
"e" => ValidationResult::Retry,
"s" => ValidationResult::Success,
"x" => ValidationResult::Exit,
i => panic!("Expected 'e', 's', or 'x'. Received {i}"), // We already validated input above
};
}
ValidationResult::Success
}

View File

@ -1,5 +1,6 @@
mod add;
mod cmd;
mod edit;
mod import;
mod init;
mod query;
@ -17,6 +18,7 @@ impl Run for Cmd {
fn run(&self) -> Result<()> {
match self {
Cmd::Add(cmd) => cmd.run(),
Cmd::Edit(cmd) => cmd.run(),
Cmd::Import(cmd) => cmd.run(),
Cmd::Init(cmd) => cmd.run(),
Cmd::Query(cmd) => cmd.run(),

View File

@ -32,15 +32,19 @@ impl<'file> Database<'file> {
/// Adds a new directory or increments its rank. Also updates its last accessed time.
pub fn add<S: AsRef<str>>(&mut self, path: S, now: Epoch) {
self.add_raw(path, now, 1.0)
}
pub fn add_raw<S: AsRef<str>>(&mut self, path: S, last_accessed: Epoch, rank: Rank) {
let path = path.as_ref();
match self.dirs.iter_mut().find(|dir| dir.path == path) {
None => {
self.dirs.push(Dir { path: path.to_string().into(), last_accessed: now, rank: 1.0 });
self.dirs.push(Dir { path: path.to_string().into(), last_accessed, rank });
}
Some(dir) => {
dir.last_accessed = now;
dir.rank += 1.0;
dir.last_accessed = last_accessed;
dir.rank += rank;
}
};
@ -140,7 +144,7 @@ impl DatabaseFile {
}
}
fn db_path<P: AsRef<Path>>(data_dir: P) -> PathBuf {
pub fn db_path<P: AsRef<Path>>(data_dir: P) -> PathBuf {
const DB_FILENAME: &str = "db.zo";
data_dir.as_ref().join(DB_FILENAME)
}

View File

@ -160,7 +160,7 @@ fn tmpfile<P: AsRef<Path>>(dir: P) -> Result<(File, PathBuf)> {
}
/// Similar to [`fs::rename`], but retries on Windows.
fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
const MAX_ATTEMPTS: usize = 5;
let from = from.as_ref();
let to = to.as_ref();