diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 6539c2e..d526b9b 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -1,11 +1,11 @@ use std::io::{self, Write}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use crate::cmd::{Query, Run}; use crate::config; use crate::db::{Database, Epoch, Stream, StreamOptions}; -use crate::error::BrokenPipeHandler; +use crate::error::{BrokenPipeHandler, SilentExit}; use crate::util::{self, Fzf, FzfChild}; impl Run for Query { @@ -31,16 +31,23 @@ impl Query { fn query_interactive(&self, stream: &mut Stream, now: Epoch) -> Result<()> { let mut fzf = Self::get_fzf()?; - let selection = loop { - match stream.next() { - Some(dir) if Some(dir.path.as_ref()) == self.exclude.as_deref() => continue, - Some(dir) => { - if let Some(selection) = fzf.write(dir, now)? { - break selection; + let selection = match (|| -> Result { + loop { + match stream.next() { + Some(dir) if Some(dir.path.as_ref()) == self.exclude.as_deref() => continue, + Some(dir) => { + if let Some(selection) = fzf.write(dir, now)? { + break Ok(selection); + } } + None => break fzf.wait(), } - None => break fzf.wait()?, } + })() { + Ok(selection) if selection.is_empty() => bail!(SilentExit { code: 1 }), + Ok(selection) => selection, + Err(err) if err.to_string() == "no match found" => bail!(SilentExit { code: 1 }), + Err(err) => return Err(err), }; if self.score { diff --git a/src/shell.rs b/src/shell.rs index 8812b1c..566b64b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -65,6 +65,22 @@ mod tests { .stderr(""); } + #[apply(opts)] + fn bash_init_guards_empty_completion( + cmd: Option<&str>, + hook: InitHook, + echo: bool, + resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Bash(&opts).render().unwrap(); + if cmd.is_some() { + assert!(source.contains("[[ -n ${__zoxide_result} ]] || return")); + } else { + assert!(!source.contains("[[ -n ${__zoxide_result} ]] || return")); + } + } + #[apply(opts)] fn bash_shellcheck(cmd: Option<&str>, hook: InitHook, echo: bool, resolve_symlinks: bool) { let opts = Opts { cmd, hook, echo, resolve_symlinks }; @@ -141,6 +157,18 @@ mod tests { .stderr(""); } + #[apply(opts)] + fn fish_init_guards_empty_completion( + cmd: Option<&str>, + hook: InitHook, + echo: bool, + resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Fish(&opts).render().unwrap(); + assert!(source.contains("and test -n \"$result\"")); + } + #[apply(opts)] fn fish_fishindent(cmd: Option<&str>, hook: InitHook, echo: bool, resolve_symlinks: bool) { let opts = Opts { cmd, hook, echo, resolve_symlinks }; diff --git a/templates/bash.txt b/templates/bash.txt index 1888fe3..cf639e2 100644 --- a/templates/bash.txt +++ b/templates/bash.txt @@ -178,18 +178,19 @@ if [[ ${BASH_VERSINFO[0]:-0} -eq 4 && ${BASH_VERSINFO[1]:-0} -ge 4 || ${BASH_VER # If there is a space after the last word, use interactive selection. elif [[ -z ${COMP_WORDS[-1]} ]]; then # shellcheck disable=SC2312 - __zoxide_result="$(\command zoxide query --exclude "$(__zoxide_pwd)" --interactive -- "{{ "${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-2}" }}")" && { - # In case the terminal does not respond to \e[5n or another - # mechanism steals the response, it is still worth completing - # the directory in the command line. - COMPREPLY=("${__zoxide_z_prefix}${__zoxide_result}/") + __zoxide_result="$(\command zoxide query --exclude "$(__zoxide_pwd)" --interactive -- "{{ "${COMP_WORDS[@]:1:${#COMP_WORDS[@]}-2}" }}")" || return + [[ -n ${__zoxide_result} ]] || return - # Note: We here call "bind" without prefixing "\builtin" to be - # compatible with frameworks like ble.sh, which emulates Bash's - # builtin "bind". - bind -x '"\e[0n": __zoxide_z_complete_helper' - \builtin printf '\e[5n' >/dev/tty - } + # In case the terminal does not respond to \e[5n or another + # mechanism steals the response, it is still worth completing + # the directory in the command line. + COMPREPLY=("${__zoxide_z_prefix}${__zoxide_result}/") + + # Note: We here call "bind" without prefixing "\builtin" to be + # compatible with frameworks like ble.sh, which emulates Bash's + # builtin "bind". + bind -x '"\e[0n": __zoxide_z_complete_helper' + \builtin printf '\e[5n' >/dev/tty fi } diff --git a/templates/fish.txt b/templates/fish.txt index f5d4aa4..5dd415c 100644 --- a/templates/fish.txt +++ b/templates/fish.txt @@ -101,6 +101,7 @@ function __zoxide_z_complete # If the last argument is empty, use interactive selection. set -l query $tokens[2..-1] set -l result (command zoxide query --exclude (__zoxide_pwd) --interactive -- $query) + and test -n "$result" and __zoxide_cd $result and builtin commandline --function cancel-commandline repaint end diff --git a/tests/query_interactive.rs b/tests/query_interactive.rs new file mode 100644 index 0000000..f523e25 --- /dev/null +++ b/tests/query_interactive.rs @@ -0,0 +1,49 @@ +#![cfg(not(windows))] + +use std::os::unix::fs::PermissionsExt; +use std::{env, fs}; + +use assert_cmd::Command; + +fn fake_path(script: &str) -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fzf"); + fs::write(&path, script).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + let path_env = format!("{}:{}", dir.path().display(), env::var("PATH").unwrap_or_default()); + (dir, path_env) +} + +#[test] +fn interactive_query_is_silent_when_fzf_reports_no_match() { + let home = tempfile::tempdir().unwrap(); + let (_bin_dir, path_env) = fake_path("#!/bin/sh\ncat >/dev/null\nexit 1\n"); + + Command::cargo_bin("zoxide") + .unwrap() + .env("HOME", home.path()) + .env("PATH", path_env) + .args(["query", "--interactive"]) + .assert() + .code(1) + .stdout("") + .stderr(""); +} + +#[test] +fn interactive_query_is_silent_when_fzf_returns_empty_output() { + let home = tempfile::tempdir().unwrap(); + let (_bin_dir, path_env) = fake_path("#!/bin/sh\ncat >/dev/null\nexit 0\n"); + + Command::cargo_bin("zoxide") + .unwrap() + .env("HOME", home.path()) + .env("PATH", path_env) + .args(["query", "--interactive"]) + .assert() + .code(1) + .stdout("") + .stderr(""); +}