Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 45 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ For PowerShell 7:

```powershell
$CjConfig = Join-Path (Split-Path -Parent $PROFILE) 'cj'
cj init powershell --setup-key-binding -o (Join-Path $CjConfig 'init.ps1')
cj init powershell -o (Join-Path $CjConfig 'init.ps1')
cj completions powershell -o (Join-Path $CjConfig 'completions.ps1')

# Add these lines to $PROFILE:
Expand All @@ -89,8 +89,12 @@ $CjConfig = Join-Path (Split-Path -Parent $PROFILE) 'cj'
Restart the shell or source its configuration after making the change.
`pwsh` is accepted as an input alias for `powershell`; generated source and
completion candidates use the canonical `powershell` name.
On PowerShell, the optional picker binding is installed through PSReadLine when
that module is available and is otherwise skipped without changing the profile.
`cj init` installs the configured directory completion binding by default on
Bash, Zsh, Nushell, and PowerShell. The Bash widget requires Bash 4 or newer;
macOS's system Bash 3 still supports the `cd` wrapper and Tab completion.
PowerShell installs the widget when PSReadLine is available. Use
`cj init <shell> --no-setup-key-binding` to generate the wrapper and completions
without installing the widget.

## Jumping

Expand Down Expand Up @@ -173,19 +177,41 @@ inserts its path; press Enter to change directory. Pressing Enter while the jump
token is still present shows a reminder to use Tab and leaves the directory unchanged.

The standalone `cj -jw` / `cj --jump-worktree` command remains available to print
a path selected through fzf; it also powers the optional worktree key binding.

To install an fzf worktree jump key together with the `cd` wrapper, add the setup
flag to shell initialization:
a worktree path selected through fzf.

The key installed by `cj init` completes a directory from history. Its default
chord is <kbd>Ctrl</kbd>+<kbd>O</kbd> on macOS and Windows, and
<kbd>Alt</kbd>+<kbd>O</kbd> on Linux. Each OS's `key-bindings` entry has an ordered
`behaviors` list, defaulting to `["zoxide", "cj"]`. `zoxide` opens its interactive
directory picker using fzf. `cj` cycles through cj's browser-style directory
history without external dependencies. Both append a safely quoted directory to
the current command line; press Enter to execute it. The key itself never changes
the current directory.

Behaviors run from left to right until one supplies a directory. A missing
dependency or empty candidate source falls through to the next behavior.
Cancelling a picker stops the chain and preserves the command line, cursor, and
current directory; other errors stop with a diagnostic. Reorder the list to
change which behavior is tried first, or use a single entry to select one source.

After a cj history completion, repeated presses cycle from newest to oldest,
then restore the original line and cursor. Editing the line starts a new cycle.
Completion itself does not consume history. Dependency availability is checked
on each invocation, so installing or removing zoxide or fzf needs no regeneration.
The binding preserves zoxide settings such as `_ZO_FZF_OPTS`.

Regenerate shell integration after upgrading to receive the default binding:

```bash
eval "$(cj init zsh --setup-key-binding)"
cj init zsh -o ~/.config/cj/init.zsh
source ~/.config/cj/init.zsh
```

The default picker key is <kbd>Ctrl</kbd>+<kbd>O</kbd> on macOS and Windows, and
<kbd>Alt</kbd>+<kbd>O</kbd> on Linux. Pressing it invokes the same interactive
worktree picker as `cj -jw` and changes directory to the selected path. The key
binding requires `fzf`; ordinary directory jumps and worktree listing do not.
To keep the `cd` wrapper and Tab completion without the directory widget, add
`--no-setup-key-binding`. The old `--setup-key-binding` flag is still accepted as
a compatibility no-op; existing initialization commands can keep it or remove it.
The two flags cannot be combined. An empty `behaviors` list in the current OS's
`key-bindings` entry disables the widget in configuration.

## Configuration

Expand Down Expand Up @@ -228,11 +254,12 @@ onedrive-work = { provider = "onedrive", account = "business" }

Program values may be executable names found on `PATH` or explicit executable
paths. Key binding keys accept `ctrl-o`, `alt-o`, or `none`. Behaviors run from
left to right: `zoxide` uses its interactive picker and `cj` uses cj's picker.
An unavailable picker or an empty result falls through to the next behavior;
cancellation leaves the command line unchanged, and other errors stop the chain.
Use `["cj", "zoxide"]` to prefer cj, `["cj"]` or `["zoxide"]` to use one
picker, or `[]` to disable the binding. Unknown or duplicate behaviors are errors.
left to right: `zoxide` uses its interactive picker and `cj` cycles through
browser-style directory history. Missing dependencies or an empty candidate source
fall through; cancellation preserves the command line, cursor, and directory,
and other errors stop the chain. Use `["cj", "zoxide"]` to prefer cj,
`["cj"]` or `["zoxide"]` for one source, or `[]` to disable the binding.
Unknown or duplicate behaviors are errors.
The legacy strings (for example, `linux = "alt-o"`) remain valid and use
`["zoxide", "cj"]`; `"none"` disables the binding. Omitted keys retain the
platform default, and omitted behaviors retain the default chain.
Expand Down Expand Up @@ -263,7 +290,7 @@ Use `-C` or `--config` to select a different file. When generating shell setup,
the selected config path is embedded safely in the generated wrapper:

```bash
cj -C "$HOME/.config/cj/work.toml" init zsh --setup-key-binding \
cj -C "$HOME/.config/cj/work.toml" init zsh \
-o "$HOME/.config/cj/init.zsh"
```

Expand Down
82 changes: 75 additions & 7 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Usage:
cj [OPTIONS] --jump-worktree
cj [OPTIONS] config init [--preamp]
cj [OPTIONS] mounts scan [--format table|json]
cj [OPTIONS] init <bash|zsh|nu|powershell> [--setup-key-binding] [-o PATH]
cj [OPTIONS] init <bash|zsh|nu|powershell> [--no-setup-key-binding] [-o PATH]
cj [OPTIONS] completions <bash|zsh|nu|powershell> [-o PATH]

Options:
Expand All @@ -23,7 +23,8 @@ Options:
-f, --format <FORMAT> Output format: table or json [default: table]
-R, --relative Render worktree paths relative to the current directory
-jw, --jump-worktree Select a worktree with fzf and print its path
--setup-key-binding Include the OS-specific fzf binding in shell setup
--no-setup-key-binding Omit the configured directory binding from init
--setup-key-binding Accepted for compatibility; init installs the binding by default
-o, --output <PATH> Write generated shell source and create parent directories
--preamp Add unambiguous mounts to a new config
-h, --help Print help
Expand Down Expand Up @@ -111,6 +112,7 @@ impl Cli {
let mut worktree_paths0 = false;
let mut key_binding_zoxide = None;
let mut setup_key_binding = false;
let mut no_setup_key_binding = false;
let mut output = None;
let mut preamp = false;
let mut targets = Vec::new();
Expand Down Expand Up @@ -170,6 +172,8 @@ impl Cli {
key_binding_zoxide = Some((PathBuf::from(zoxide), PathBuf::from(fzf)));
} else if options && text == Some("--setup-key-binding") {
setup_key_binding = true;
} else if options && text == Some("--no-setup-key-binding") {
no_setup_key_binding = true;
} else if options && matches!(text, Some("-o" | "--output")) {
output = Some(PathBuf::from(
args.next().ok_or("--output requires a path")?,
Expand All @@ -183,6 +187,19 @@ impl Cli {
}
}

if setup_key_binding && no_setup_key_binding {
return Err("--setup-key-binding and --no-setup-key-binding cannot be used together; omit both for the default binding or use only --no-setup-key-binding to disable it".into());
}
if (setup_key_binding || no_setup_key_binding)
&& (force_resolve
|| resolver != ResolverOverride::Configured
|| targets.first().and_then(|arg| arg.to_str()) != Some("init"))
{
return Err(
"--setup-key-binding and --no-setup-key-binding can only be used with init".into(),
);
}

let command = if let Some((zoxide, fzf)) = key_binding_zoxide {
if force_resolve
|| !targets.is_empty()
Expand Down Expand Up @@ -248,7 +265,7 @@ impl Cli {
parse_init(
&targets,
ShellOptions {
setup_key_binding,
setup_key_binding: !no_setup_key_binding,
output: output.clone(),
},
worktree,
Expand Down Expand Up @@ -341,7 +358,7 @@ fn parse_init(
}
if targets.len() != 2 {
return Err(
"usage: cj init <bash|zsh|nu|powershell> [--setup-key-binding] [-o PATH]".into(),
"usage: cj init <bash|zsh|nu|powershell> [--no-setup-key-binding] [-o PATH]".into(),
);
}
let shell = parse_shell(&targets[1])?;
Expand Down Expand Up @@ -668,7 +685,7 @@ mod tests {
Cli::parse(["init", name].map(Into::into)).unwrap().command,
Command::Init {
shell,
setup_key_binding: false,
setup_key_binding: true,
output: None,
}
);
Expand All @@ -684,10 +701,61 @@ mod tests {
}
}

#[test]
fn init_binding_defaults_and_compatibility_flags() {
for name in ["bash", "zsh", "nu", "powershell", "pwsh"] {
let default = Cli::parse(["init", name].map(Into::into)).unwrap();
let legacy = Cli::parse(["init", name, "--setup-key-binding"].map(Into::into)).unwrap();
assert_eq!(default, legacy);
assert!(matches!(
Cli::parse(["init", name, "--no-setup-key-binding"].map(Into::into))
.unwrap()
.command,
Command::Init {
setup_key_binding: false,
..
}
));
}
for flags in [
["--setup-key-binding", "--no-setup-key-binding"],
["--no-setup-key-binding", "--setup-key-binding"],
] {
let error =
Cli::parse(["init", "bash", flags[0], flags[1]].map(Into::into)).unwrap_err();
assert!(error.contains("cannot be used together"));
assert!(error.contains("omit both"));
}
}

#[test]
fn both_binding_flags_require_init() {
for flag in ["--setup-key-binding", "--no-setup-key-binding"] {
for args in [
vec![flag],
vec!["target", flag],
vec!["completions", "bash", flag],
vec!["config", "init", flag],
vec!["mounts", "scan", flag],
vec!["--worktree", flag],
vec!["--worktree-paths0", flag],
vec!["-z", "init", flag],
vec![flag, "--", "init", "bash"],
] {
let error = Cli::parse(args.into_iter().map(Into::into)).unwrap_err();
assert!(error.contains("can only be used with init"), "{error}");
}
assert!(matches!(
Cli::parse(["--", flag].map(Into::into)).unwrap().command,
Command::Resolve { targets, .. } if targets == [flag]
));
}
}

#[test]
fn rejects_unsupported_shell_setup_combinations() {
assert!(Cli::parse(["init", "fish"].map(Into::into)).is_err());
assert!(Cli::parse(["init", "nu", "--setup-key-binding"].map(Into::into)).is_ok());

assert_eq!(
Cli::parse(["init", "powershell", "--setup-key-binding"].map(Into::into)),
Ok(Cli {
Expand Down Expand Up @@ -734,7 +802,7 @@ mod tests {
parsed,
Command::Init {
shell: Shell::Zsh,
setup_key_binding: false,
setup_key_binding: true,
output: Some("nested/source.zsh".into()),
}
);
Expand Down
7 changes: 4 additions & 3 deletions src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ fn bash(destinations: &[String]) -> String {
config:init) candidates=(--preamp "${{common_flags[@]}}") ;;
mounts:) candidates=(scan "${{common_flags[@]}}") ;;
mounts:scan) candidates=(-f --format "${{common_flags[@]}}") ;;
init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --setup-key-binding "${{common_flags[@]}}"); fi ;;
init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); fi ;;
completions:) if [[ -n "$command_value" ]]; then candidates=(-o --output "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output "${{common_flags[@]}}"); fi ;;
*) candidates=("${{commands[@]}}" "${{destinations[@]}}" "${{flags[@]}}") ;;
esac; fi
Expand Down Expand Up @@ -234,7 +234,7 @@ _cj_complete() {{
config:init) candidates=(--preamp "${{common_flags[@]}}") ;;
mounts:) candidates=(scan "${{common_flags[@]}}") ;;
mounts:scan) candidates=(-f --format "${{common_flags[@]}}") ;;
init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --setup-key-binding "${{common_flags[@]}}"); fi ;;
init:) if [[ -n "$command_value" ]]; then candidates=(-o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output --no-setup-key-binding --setup-key-binding "${{common_flags[@]}}"); fi ;;
completions:) if [[ -n "$command_value" ]]; then candidates=(-o --output "${{common_flags[@]}}"); else candidates=("${{shells[@]}}" -o --output "${{common_flags[@]}}"); fi ;;
*) candidates=("${{commands[@]}}" "${{destinations[@]}}" "${{flags[@]}}") ;;
esac; fi
Expand Down Expand Up @@ -285,6 +285,7 @@ export extern cj [

export extern "cj init" [
shell: string@"nu-complete cj shells"
--no-setup-key-binding
--setup-key-binding
--output(-o): path
--config(-C): path
Expand Down Expand Up @@ -393,7 +394,7 @@ fn powershell(destinations: &[String]) -> String {
'config:init' {{ @('--preamp') + $commonFlags }}
'mounts:' {{ @('scan') + $commonFlags }}
'mounts:scan' {{ @('-f', '--format') + $commonFlags }}
'init:' {{ if ($commandValue) {{ @('-o', '--output', '--setup-key-binding') + $commonFlags }} else {{ $shells + @('-o', '--output', '--setup-key-binding') + $commonFlags }} }}
'init:' {{ if ($commandValue) {{ @('-o', '--output', '--no-setup-key-binding', '--setup-key-binding') + $commonFlags }} else {{ $shells + @('-o', '--output', '--no-setup-key-binding', '--setup-key-binding') + $commonFlags }} }}
'completions:' {{ if ($commandValue) {{ @('-o', '--output') + $commonFlags }} else {{ $shells + @('-o', '--output') + $commonFlags }} }}
default {{ $commands + $destinations + $flags }}
}} }}
Expand Down
4 changes: 3 additions & 1 deletion src/key_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool)
};
let binding = format!("{binding}\n_cj_bound_key='{key}'\n_cj_bound_widget=_cj_key_widget");
if shell == Shell::Bash {
format!("{body}\nif (( BASH_VERSINFO[0] >= 4 )); then\n {binding}\nfi")
format!(
"{body}\nif [[ $- == *i* ]] && (( BASH_VERSINFO[0] >= 4 )); then\n {binding}\nfi"
)
} else {
format!("{body}\n{binding}")
}
Expand Down
43 changes: 38 additions & 5 deletions tests/key_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,13 +458,42 @@ Invoke-CjKeyWidget
}
}

#[test]
fn sourcing_again_updates_only_the_binding_owned_by_cj() {
let bash = if Path::new("/opt/homebrew/bin/bash").exists() {
fn bash_program() -> &'static str {
if Path::new("/opt/homebrew/bin/bash").exists() {
"/opt/homebrew/bin/bash"
} else {
"bash"
};
}
}

#[test]
fn default_init_is_quiet_in_noninteractive_bash() {
let temp = TempDir::new("noninteractive-init");
let generated = cj(temp.path(), temp.path())
.args(["init", "bash"])
.output()
.unwrap();
assert_success(&generated);
let source = String::from_utf8(generated.stdout).unwrap();
let output = Command::new(bash_program())
.args(["--noprofile", "--norc", "-c"])
.arg(format!(
"{source}\n{source}\ndeclare -F cd _cj_key_widget >/dev/null && [[ -z ${{_cj_bound_key-}} ]]"
))
.output()
.unwrap();
assert_success(&output);
assert!(output.stdout.is_empty());
assert!(
output.stderr.is_empty(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}

#[test]
fn sourcing_again_updates_only_the_binding_owned_by_cj() {
let bash = bash_program();
for (shell, executable) in [("bash", bash), ("zsh", "zsh")] {
if Command::new(executable).arg("--version").output().is_err() {
continue;
Expand Down Expand Up @@ -499,7 +528,11 @@ fn sourcing_again_updates_only_the_binding_owned_by_cj() {
};
let script = fixture.temp.path().join("reload.sh");
fs::write(&script, format!("{first}\n{second}\n{query}\nprintf '\\0'\n{disabled}\n{query}\nprintf '\\0'\n{first}\n{replace}\n{disabled}\n{query_user}\n")).unwrap();
let output = Command::new(executable).arg(&script).output().unwrap();
let mut command = Command::new(executable);
if shell == "bash" {
command.args(["--noprofile", "--norc", "-i"]);
}
let output = command.arg(&script).output().unwrap();
assert_success(&output);
let sections = fields(&output);
assert!(
Expand Down
Loading
Loading