diff --git a/README.md b/README.md index 335a1c0..e2e52ae 100644 --- a/README.md +++ b/README.md @@ -144,24 +144,25 @@ cj -w --format json cj -w --relative ``` -Jump to a worktree interactively with fzf: +Choose a worktree with Tab completion: ```console -cj -jw # print the selected worktree path -cj --jump-worktree # long form of the same command -cd -jw # select a worktree and change directory cd -jw # complete a worktree path, then press Enter +cd --jump-worktree # long form of the same completion trigger ``` -`-w/--worktree` only lists worktrees; `-jw/--jump-worktree` selects exactly one. -The `cd` form requires the generated shell integration so the parent shell can -perform the directory change. In Bash, Zsh, Nushell, and PowerShell, pressing Tab +`cj -w/--worktree` lists worktrees. The `cd` completion requires the generated +shell integration so the parent shell can perform the directory change. +In Bash, Zsh, Nushell, and PowerShell, pressing Tab immediately after `-jw` or `--jump-worktree` offers only this repository's worktree paths through the shell's native completion system. A completion frontend such as fzf-tab may render those candidates with fzf, but cj does not launch a nested picker during completion and works without fzf. Choosing a candidate only -inserts its path; press Enter to change directory. Running `cd -jw` directly -remains the explicit interactive fzf picker. +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: @@ -172,8 +173,8 @@ eval "$(cj init zsh --setup-key-binding)" The default picker key is Ctrl+O on macOS and Windows, and Alt+O on Linux. Pressing it invokes the same interactive -worktree jump as `cd -jw`. The jump requires `fzf`; ordinary directory jumps and -worktree listing do not. +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. ## Configuration diff --git a/src/shell.rs b/src/shell.rs index 23c53ef..f398808 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -79,7 +79,8 @@ function cd() {{ _cj_nav_arg= ;; -jw|--jump-worktree) - _cj_nav_arg= + printf '%s\n' 'cj: type cd -jw and press Tab to select a worktree' >&2 + return 2 ;; -Z|--no-zoxide) if [[ $# -eq 2 ]]; then @@ -112,7 +113,7 @@ function cd() {{ _cj_nav_mode=down fi case "${{1-}}" in - -z|--zoxide|-Z|--no-zoxide|-jw|--jump-worktree) _cj_args=("$@") ;; + -z|--zoxide|-Z|--no-zoxide) _cj_args=("$@") ;; *) _cj_args=(-- "$@") ;; esac _cj_before="$(\builtin pwd -P)" || return @@ -312,6 +313,10 @@ export def --env --wrapped __cj_cd [...args: string] {{ return }} + if ($args.0 in ['-jw' '--jump-worktree']) {{ + error make {{ msg: 'cj: type cd -jw and press Tab to select a worktree' }} + }} + if (($args.0 == '-r') or ($args.0 == '--raw')) {{ if ($args | length) != 2 {{ error make {{ msg: 'cj: --raw requires exactly one target' }} @@ -345,7 +350,7 @@ export def --env --wrapped __cj_cd [...args: string] {{ $env.CJ_INTERNAL_DOWN_ROUTE = '' return }} - }} else if ($args.0 | str starts-with '-') and (not ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide' '-jw' '--jump-worktree'])) {{ + }} else if ($args.0 | str starts-with '-') and (not ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide'])) {{ if ($args | length) != 1 {{ error make {{ msg: 'cj: Nushell cd accepts one target' }} }} cd $args.0 $env.CJ_INTERNAL_DOWN_ROUTE = '' @@ -354,7 +359,7 @@ export def --env --wrapped __cj_cd [...args: string] {{ let nav_arg = if (($args | length) == 1) {{ $args.0 }} else if (($args | length) == 2) and (($args.0 == '-Z') or ($args.0 == '--no-zoxide')) {{ $args.1 }} else {{ '' }} let before = ($env.PWD | path expand) - let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide' '-jw' '--jump-worktree']) {{ $args }} else {{ ['--'] | append $args }} + let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide']) {{ $args }} else {{ ['--'] | append $args }} let result = with-env {{ CJ_INTERNAL_DOWN_ROUTE: $env.CJ_INTERNAL_DOWN_ROUTE }} {{ ^cj ...$config ...$invoke_args | complete }} @@ -476,6 +481,10 @@ function global:cd {{ }} $first = [string]$cjArgs[0] + if (($first -ceq '-jw') -or ($first -ceq '--jump-worktree')) {{ + throw 'cj: type cd -jw and press Tab to select a worktree' + }} + if (($first -ceq '-r') -or ($first -ceq '--raw')) {{ if ($cjArgs.Count -ne 2) {{ throw 'cj: --raw requires exactly one target' }} Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[1] -ErrorAction Stop @@ -503,11 +512,11 @@ function global:cd {{ $global:__cj_down_route = $null return }} - if ($first.StartsWith('-') -and -not (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide') -or ($first -ceq '-jw') -or ($first -ceq '--jump-worktree'))) {{ throw "cj: unsupported PowerShell cd option: $first" }} + if ($first.StartsWith('-') -and -not (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ throw "cj: unsupported PowerShell cd option: $first" }} $navArg = if ($cjArgs.Count -eq 1) {{ $first }} elseif (($cjArgs.Count -eq 2) -and (($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ [string]$cjArgs[1] }} else {{ '' }} $before = (Microsoft.PowerShell.Management\Get-Location).ProviderPath - [string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide') -or ($first -ceq '-jw') -or ($first -ceq '--jump-worktree')) {{ $cjArgs }} else {{ @('--') + $cjArgs }} + [string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide')) {{ $cjArgs }} else {{ @('--') + $cjArgs }} $hadRoute = Test-Path Env:CJ_INTERNAL_DOWN_ROUTE $oldRoute = $env:CJ_INTERNAL_DOWN_ROUTE $configArgs = $script:__cj_config @@ -694,22 +703,11 @@ mod tests { } #[test] - fn wrappers_forward_jump_worktree_to_cj() { - let bash = render(Shell::Bash, None, None, &Config::default()).unwrap(); - assert!(bash.contains("-jw|--jump-worktree)")); - assert!(bash.contains("-jw|--jump-worktree) _cj_args=(\"$@\")")); - assert!(!bash.contains("pick-worktree")); - - let nu = render(Shell::Nu, None, None, &Config::default()).unwrap(); - assert!(nu.contains("'-jw' '--jump-worktree'")); - assert!(nu.contains("^cj ...$config ...$invoke_args | complete")); - assert!(!nu.contains("pick-worktree")); - - let powershell = render(Shell::Pwsh, None, None, &Config::default()).unwrap(); - assert!(powershell.contains("($first -ceq '-jw')")); - assert!(powershell.contains("($first -ceq '--jump-worktree')")); - assert!(powershell.contains("[string[]]$invokeArgs")); - assert!(!powershell.contains("pick-worktree")); + fn wrappers_explain_that_jump_tokens_require_tab() { + for shell in [Shell::Bash, Shell::Zsh, Shell::Nu, Shell::Pwsh] { + let source = render(shell, None, None, &Config::default()).unwrap(); + assert!(source.contains("type cd -jw and press Tab to select a worktree")); + } } #[test] diff --git a/tests/external_integration.rs b/tests/external_integration.rs index ca3cc50..681aebd 100644 --- a/tests/external_integration.rs +++ b/tests/external_integration.rs @@ -225,8 +225,8 @@ fn fzf_cancellation_and_failure_are_controlled() { } #[test] -fn generated_wrappers_jump_to_the_selected_worktree() { - let fixture = PickerFixture::new("fzf-shell-jump"); +fn generated_wrappers_require_tab_for_worktree_selection() { + let fixture = PickerFixture::new("worktree-tab-only"); let binary_dir = fixture.git.temp.path().join("binary dir"); fs::create_dir_all(&binary_dir).expect("create binary directory"); symlink(env!("CARGO_BIN_EXE_cj"), binary_dir.join("cj")).expect("link cj binary"); @@ -248,7 +248,7 @@ fn generated_wrappers_jump_to_the_selected_worktree() { let mut command = shell_with_setup( shell, &init.stdout, - "eval \"$1\"; cd \"$2\"; printf '%s\\n' \"$PWD\"", + "eval \"$1\"; cd \"$2\"; result=$?; printf '%s\\n' \"$PWD\"; exit \"$result\"", ); command .arg(flag) @@ -264,32 +264,22 @@ fn generated_wrappers_jump_to_the_selected_worktree() { Err(error) => panic!("cannot run {shell}: {error}"), }; exercised += 1; - assert_success(&output); - assert_eq!(output.stdout, path_output(&fixture.git.linked)); - assert!(output.stderr.is_empty()); + assert!( + !output.status.success(), + "{shell} accepted {flag} without Tab" + ); + assert_eq!(output.stdout, path_output(&fixture.git.main_nested)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("type cd -jw and press Tab to select a worktree") + ); } - - let mut command = shell_with_setup( - shell, - &init.stdout, - "eval \"$1\"; cd -jw || :; printf '%s\\n' \"$PWD\"", - ); - command - .current_dir(&fixture.git.main_nested) - .env("PATH", &path) - .env("CJ_FAKE_ARGS", &fixture.args) - .env("CJ_FAKE_STDIN", &fixture.stdin) - .env("CJ_FAKE_MODE", "cancel"); - let output = match command.output() { - Ok(output) => output, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => panic!("cannot run {shell}: {error}"), - }; - assert_success(&output); - assert_eq!(output.stdout, path_output(&fixture.git.main_nested)); - assert!(output.stderr.is_empty()); } assert!(exercised > 0, "neither bash nor zsh is available"); + assert!( + !fixture.args.exists() && !fixture.stdin.exists(), + "executing the Tab token must not invoke fzf" + ); } #[test] diff --git a/tests/windows_external_integration.rs b/tests/windows_external_integration.rs index d9077a8..69f27ce 100644 --- a/tests/windows_external_integration.rs +++ b/tests/windows_external_integration.rs @@ -101,7 +101,7 @@ fn powershell_wrapper_changes_its_calling_session_with_zoxide() { } #[test] -fn powershell_wrapper_jumps_to_the_selected_worktree() { +fn powershell_wrapper_requires_tab_for_worktree_selection() { let git = GitFixture::new("windows-powershell-fzf"); let tool = copy_tool(git.temp.path(), "fzf.exe"); let config = git.temp.path().join("fzf config.toml"); @@ -118,26 +118,43 @@ fn powershell_wrapper_jumps_to_the_selected_worktree() { assert_success(&init); fs::write(&source, init.stdout).expect("write PowerShell integration"); - let output = Command::new("pwsh") + for flag in ["-jw", "--jump-worktree"] { + let output = Command::new("pwsh") .args([ "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", - ". $env:CJ_SOURCE; cd -jw; [Console]::Out.Write((Get-Location).ProviderPath)", + ". $env:CJ_SOURCE; $result = 0; try { cd $env:CJ_JUMP_FLAG } catch { [Console]::Error.Write($_.Exception.Message); $result = 2 }; [Console]::Out.Write((Get-Location).ProviderPath); exit $result", ]) .current_dir(&git.main_nested) - .env("CJ_SOURCE", source) + .env("CJ_SOURCE", &source) + .env("CJ_JUMP_FLAG", flag) .env("CJ_FAKE_TOOL", "fzf") .env("CJ_FAKE_MODE", "success") .env("CJ_FAKE_SELECTION", "1") - .env("CJ_FAKE_ARGS", args) - .env("CJ_FAKE_STDIN", stdin) + .env("CJ_FAKE_ARGS", &args) + .env("CJ_FAKE_STDIN", &stdin) .env("PATH", path_with_cj()) .output() .expect("run PowerShell integration"); - assert_success(&output); - assert_eq!(output.stdout, git.linked.as_os_str().as_encoded_bytes()); + assert!( + !output.status.success(), + "PowerShell accepted {flag} without Tab" + ); + assert_eq!( + output.stdout, + git.main_nested.as_os_str().as_encoded_bytes() + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("type cd -jw and press Tab to select a worktree") + ); + } + assert!( + !args.exists() && !stdin.exists(), + "executing the Tab token must not invoke fzf" + ); } #[test]