From 1c4320609eecaf166c24d2cc78d581486a04b9f7 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:19:56 +0800 Subject: [PATCH 1/8] feat(shell): add browser-history command-buffer completion helpers Add read-only helpers for Bash/Zsh and PowerShell that cycle history from newest to oldest, then restore the original command line and cursor without consuming navigation history. Dispatcher and Nushell integration will follow. Refs #46 --- src/key_history.rs | 272 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 273 insertions(+) create mode 100644 src/key_history.rs diff --git a/src/key_history.rs b/src/key_history.rs new file mode 100644 index 0000000..7c10829 --- /dev/null +++ b/src/key_history.rs @@ -0,0 +1,272 @@ +use crate::cli::Shell; + +/// Shell-local, read-only completion of the browser history. Cycling restores +/// both the original line and cursor after the oldest remembered directory. +#[allow(dead_code)] // Wired into the ordered binding dispatcher separately. +pub fn render(shell: Shell) -> &'static str { + match shell { + Shell::Bash | Shell::Zsh => POSIX, + Shell::Pwsh => POWERSHELL, + Shell::Nu => "", + } +} + +const POSIX: &str = r#"function _cj_key_history_reset() { + unset _cj_key_history_original _cj_key_history_original_cursor + unset _cj_key_history_last _cj_key_history_last_cursor _cj_key_history_next + unset _cj_key_history_items +} +_cj_key_history_reset + +function _cj_key_history() { + local buffer="$1" cursor="$2" target quoted index remainder + # Bash 5+ and ZLE use character offsets; older Readline uses bytes. + if [[ -n "${BASH_VERSION-}" ]] && (( BASH_VERSINFO[0] < 5 )); then local LC_ALL=C; fi + _cj_key_buffer="$buffer" + _cj_key_cursor="$cursor" + if [[ -z "${_cj_key_history_next-}" || "$buffer" != "${_cj_key_history_last-}" || "$cursor" != "${_cj_key_history_last_cursor-}" ]]; then + _cj_key_history_reset + (( ${#_cj_history[@]} > 0 )) || return 1 + _cj_key_history_original="$buffer" + _cj_key_history_original_cursor="$cursor" + _cj_key_history_items=("${_cj_history[@]}") + _cj_key_history_next=${#_cj_key_history_items[@]} + fi + if (( _cj_key_history_next == 0 )); then + _cj_key_buffer="$_cj_key_history_original" + _cj_key_cursor="$_cj_key_history_original_cursor" + _cj_key_history_reset + return 0 + fi + index=$((_cj_key_history_next - 1)) + [[ -n "${ZSH_VERSION-}" ]] && index=$((index + 1)) + target="${_cj_key_history_items[$index]}" + _cj_key_history_next=$((_cj_key_history_next - 1)) + # Single quotes preserve spaces, newlines, and metacharacters literally. + quoted="'" + remainder="$target" + while [[ "$remainder" == *"'"* ]]; do + quoted="$quoted${remainder%%\'*}'\\''" + remainder="${remainder#*\'}" + done + quoted="$quoted$remainder'" + _cj_key_buffer="$_cj_key_history_original" + if [[ -n "$_cj_key_buffer" && "$_cj_key_buffer" != *[[:space:]] ]]; then + _cj_key_buffer="$_cj_key_buffer " + fi + _cj_key_buffer="$_cj_key_buffer$quoted" + _cj_key_cursor=${#_cj_key_buffer} + _cj_key_history_last="$_cj_key_buffer" + _cj_key_history_last_cursor="$_cj_key_cursor" + return 0 +}"#; + +const POWERSHELL: &str = r#"function global:Reset-CjKeyHistory { + $global:__cj_key_history_cycle = $null +} +Reset-CjKeyHistory + +function global:Invoke-CjKeyHistory { + param([string] $Buffer, [int] $Cursor) + $cycle = $global:__cj_key_history_cycle + if ($null -eq $cycle -or $Buffer -cne $cycle.LastBuffer -or $Cursor -ne $cycle.LastCursor) { + Reset-CjKeyHistory + $history = Get-Variable -Name __cj_history -Scope Global -ErrorAction SilentlyContinue + $items = @() + if ($null -ne $history) { $items = @($history.Value) } + if ($items.Count -eq 0) { return $null } + $cycle = @{ + OriginalBuffer = $Buffer; OriginalCursor = $Cursor + LastBuffer = $Buffer; LastCursor = $Cursor + Items = $items; Next = $items.Count - 1 + } + $global:__cj_key_history_cycle = $cycle + } + if ($cycle.Next -lt 0) { + $result = @{ Buffer = $cycle.OriginalBuffer; Cursor = $cycle.OriginalCursor } + Reset-CjKeyHistory + return $result + } + $target = [string] $cycle.Items[$cycle.Next] + $cycle.Next -= 1 + $completed = $cycle.OriginalBuffer + if ($completed.Length -gt 0 -and -not [char]::IsWhiteSpace($completed[$completed.Length - 1])) { + $completed += ' ' + } + $completed += "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($target) + "'" + $cycle.LastBuffer = $completed + $cycle.LastCursor = $completed.Length + return @{ Buffer = $completed; Cursor = $completed.Length } +}"#; + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::render; + use crate::cli::Shell; + + fn run(shell: Shell, script: &str) { + let (program, args): (&str, &[&str]) = match shell { + Shell::Bash => ("bash", &["--noprofile", "--norc", "-c"]), + Shell::Zsh => ("zsh", &["-f", "-c"]), + Shell::Pwsh => ( + "pwsh", + &["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + ), + Shell::Nu => unreachable!(), + }; + let source = format!("{}\n{script}", render(shell)); + let output = match Command::new(program).args(args).arg(source).output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + assert!( + std::env::var_os("CJ_REQUIRE_SHELLS").is_none() + && !(matches!(shell, Shell::Pwsh) + && std::env::var_os("CJ_REQUIRE_POWERSHELL").is_some()), + "required interpreter unavailable: {program}" + ); + eprintln!("skipping {program} history binding test: interpreter unavailable"); + return; + } + Err(error) => panic!("cannot run {program}: {error}"), + }; + assert!( + output.status.success(), + "{program}: status {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn posix_history_completion_is_reversible_and_read_only() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +_cj_before=$PWD +_cj_down_route='/remembered/down/route' +_cj_history=() +if _cj_key_history 'cd ' 1; then exit 10; fi +[[ $_cj_key_buffer == 'cd ' && $_cj_key_cursor == 1 ]] + +_cj_history=('/single') +_cj_key_history 'cd ' 1 +[[ $_cj_key_buffer == "cd '/single'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == 'cd ' && $_cj_key_cursor == 1 ]] + +_cj_history=('/oldest' '/middle' '/newest') +_cj_key_history 'cd' 0 +[[ $_cj_key_buffer == "cd '/newest'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "cd '/middle'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "cd '/oldest'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == cd && $_cj_key_cursor == 0 ]] +[[ ${#_cj_history[@]} == 3 ]] +[[ $PWD == "$_cj_before" && $_cj_down_route == /remembered/down/route ]] +"#, + ); + } + } + + #[test] + fn posix_edit_cursor_change_cancel_and_reinit_restart_history_cycle() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +_cj_history=('/old' '/new') +_cj_key_history 'cd ' 3 +_cj_key_history 'ls ' 3 +[[ $_cj_key_buffer == "ls '/new'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "ls '/old'" ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == 'ls ' && $_cj_key_cursor == 3 ]] + +_cj_key_history 'cd ' 3 +_cj_key_history "$_cj_key_buffer" 0 +[[ $_cj_key_buffer == "cd '/new' '/new'" ]] +_cj_key_history_reset +_cj_key_history 'cd ' 3 +[[ $_cj_key_buffer == "cd '/new'" ]] +# Reinitialization calls this same reset without changing browser history. +_cj_key_history_reset +[[ ${#_cj_history[@]} == 2 ]] +_cj_key_history 'cd ' 3 +[[ $_cj_key_buffer == "cd '/new'" ]] +"#, + ); + } + } + + #[test] + fn posix_history_quotes_special_paths_and_preserves_multiline_buffer() { + for shell in [Shell::Bash, Shell::Zsh] { + run( + shell, + r#"set -e +for target in '/space dir' "/apostrophe's" '/literal;$()`"*?[x]\' $'/line\nbreak' '/unicode/日本語‘’'; do + _cj_key_history_reset + _cj_history=("$target") + _cj_key_history 'set --' 6 + # Evaluation is only in this test, modeling normal Enter execution. + eval "$_cj_key_buffer" + [[ $# == 1 && $1 == "$target" ]] +done +original=$'printf first\ncd ' +_cj_key_history_reset +_cj_key_history "$original" 2 +[[ $_cj_key_buffer == "$original"* ]] +_cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" +[[ $_cj_key_buffer == "$original" && $_cj_key_cursor == 2 ]] +"#, + ); + } + } + + #[test] + fn powershell_history_cycles_resets_and_quotes_literals() { + run( + Shell::Pwsh, + r#"$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$before = (Get-Location).Path +$global:__cj_down_route = '/down/route' +$global:__cj_history = @() +if ($null -ne (Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 1)) { throw 'empty history completed' } +$global:__cj_history = @('/single') +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 1 +if ($r.Buffer -cne "cd '/single'") { throw 'single history not completed' } +$r = Invoke-CjKeyHistory -Buffer $r.Buffer -Cursor $r.Cursor +if ($r.Buffer -cne 'cd ' -or $r.Cursor -ne 1) { throw 'original cursor not restored' } +$global:__cj_history = @('/old', '/middle', '/new') +$r = Invoke-CjKeyHistory -Buffer 'cd' -Cursor 0 +foreach ($expected in @('/new', '/middle', '/old')) { + if ($r.Buffer -cne "cd '$expected'") { throw "wrong cycle: $($r.Buffer)" } + $r = Invoke-CjKeyHistory -Buffer $r.Buffer -Cursor $r.Cursor +} +if ($r.Buffer -cne 'cd' -or $r.Cursor -ne 0) { throw 'original not restored' } +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 3 +$r = Invoke-CjKeyHistory -Buffer 'ls ' -Cursor 3 +if ($r.Buffer -cne "ls '/new'") { throw 'editing failed to restart' } +Reset-CjKeyHistory +$r = Invoke-CjKeyHistory -Buffer 'cd ' -Cursor 3 +if ($r.Buffer -cne "cd '/new'") { throw 'cancellation failed to restart' } +if ($global:__cj_history.Count -ne 3 -or (Get-Location).Path -cne $before -or $global:__cj_down_route -cne '/down/route') { throw 'completion mutated navigation state' } +foreach ($target in @('/space dir', "/apostrophe's", '/literal;$()`"*?[x]\', "/line`nbreak", "/unicode/日本語$([char]0x2018)$([char]0x2019)$([char]0x201A)$([char]0x201B)")) { + Reset-CjKeyHistory + $global:__cj_history = @($target) + $r = Invoke-CjKeyHistory -Buffer '' -Cursor 0 + $literal = & ([scriptblock]::Create($r.Buffer)) + if ($literal -cne $target) { throw "path did not round-trip: $target" } +} +"#, + ); + } +} diff --git a/src/main.rs b/src/main.rs index bd20424..c3e35b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod completions; mod config; mod config_init; +mod key_history; mod mounts; mod path_bytes; mod resolver; From 6505544e33b0b959ee594188a11ae38b9e4da64c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:23:22 +0800 Subject: [PATCH 2/8] feat(shell): add runtime zoxide interactive launcher Launch zoxide interactively through the configured fzf alias using snapshotted executable paths. Add a private CLI protocol that distinguishes unavailable, empty, and cancelled outcomes without re-reading TOML. Refs #45 --- src/cli.rs | 103 ++++++++++++++++++++++++++++++++- src/key_zoxide.rs | 142 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 4 ++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/key_zoxide.rs diff --git a/src/cli.rs b/src/cli.rs index 404dfdf..794d865 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,6 +50,10 @@ pub enum Command { jump: bool, }, WorktreePaths0, + KeyBindingZoxide { + zoxide: PathBuf, + fzf: PathBuf, + }, Init { shell: Shell, setup_key_binding: bool, @@ -105,6 +109,7 @@ impl Cli { let mut relative = false; let mut jump = false; let mut worktree_paths0 = false; + let mut key_binding_zoxide = None; let mut setup_key_binding = false; let mut output = None; let mut preamp = false; @@ -152,6 +157,17 @@ impl Cli { jump = true; } else if options && text == Some("--worktree-paths0") { worktree_paths0 = true; + } else if options && text == Some("--internal-key-binding-zoxide") { + if key_binding_zoxide.is_some() { + return Err("--internal-key-binding-zoxide cannot be repeated".into()); + } + let zoxide = args + .next() + .ok_or("--internal-key-binding-zoxide requires zoxide and fzf paths")?; + let fzf = args + .next() + .ok_or("--internal-key-binding-zoxide requires zoxide and fzf paths")?; + 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 && matches!(text, Some("-o" | "--output")) { @@ -167,7 +183,26 @@ impl Cli { } } - let command = if worktree_paths0 { + let command = if let Some((zoxide, fzf)) = key_binding_zoxide { + if force_resolve + || !targets.is_empty() + || worktree_paths0 + || worktree + || jump + || format.is_some() + || relative + || resolver != ResolverOverride::Configured + || setup_key_binding + || preamp + || config_path.is_some() + || output.is_some() + { + return Err( + "--internal-key-binding-zoxide cannot be combined with another mode".into(), + ); + } + Command::KeyBindingZoxide { zoxide, fzf } + } else if worktree_paths0 { if force_resolve || !targets.is_empty() || worktree @@ -526,6 +561,72 @@ mod tests { assert!(Cli::parse(["--worktree-paths0", "--"].map(Into::into)).is_err()); } + #[test] + fn parses_hidden_key_binding_zoxide_protocol() { + assert_eq!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "/tools/zoxide", + "/tools/fzf" + ] + .map(Into::into) + ) + .unwrap() + .command, + Command::KeyBindingZoxide { + zoxide: "/tools/zoxide".into(), + fzf: "/tools/fzf".into() + } + ); + assert!(!HELP.contains("--internal-key-binding-zoxide")); + for extra in [ + "target", + "-w", + "-jw", + "--jump-worktree", + "--worktree-paths0", + "-z", + "--setup-key-binding", + "--preamp", + "--", + ] { + assert!( + Cli::parse( + ["--internal-key-binding-zoxide", "zoxide", "fzf", extra].map(Into::into) + ) + .is_err() + ); + } + assert!(Cli::parse(["--internal-key-binding-zoxide", "zoxide"].map(Into::into)).is_err()); + assert!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "zoxide", + "fzf", + "-C", + "config.toml" + ] + .map(Into::into) + ) + .is_err() + ); + assert!( + Cli::parse( + [ + "--internal-key-binding-zoxide", + "zoxide", + "fzf", + "-o", + "out" + ] + .map(Into::into) + ) + .is_err() + ); + } + #[test] fn jump_worktree_rejects_ignored_output_flags() { assert!(Cli::parse(["--jump-worktree", "-R"].map(Into::into)).is_err()); diff --git a/src/key_zoxide.rs b/src/key_zoxide.rs new file mode 100644 index 0000000..917a5f3 --- /dev/null +++ b/src/key_zoxide.rs @@ -0,0 +1,142 @@ +//! Runtime launcher for the snapshotted interactive zoxide configuration. +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Private stdout protocol separates fallback from cancellation without changing +/// the normal CLI's error handling. Stderr remains attached to the foreground +/// terminal while zoxide owns the interactive picker. +pub fn query_zoxide(zoxide: &Path, fzf: &Path) -> Result, String> { + let Some(zoxide) = executable(zoxide) else { + return Ok(format!( + "unavailable\nzoxide executable not found: {}", + zoxide.display() + ) + .into_bytes()); + }; + let Some(fzf) = executable(fzf) else { + return Ok( + format!("unavailable\nfzf executable not found: {}", fzf.display()).into_bytes(), + ); + }; + let candidates = Command::new(&zoxide) + .args(["query", "--list"]) + .output() + .map_err(|error| format!("cannot query zoxide: {error}"))?; + if !candidates.status.success() { + return Err(format!( + "zoxide query failed ({}): {}", + candidates.status, + String::from_utf8_lossy(&candidates.stderr).trim() + )); + } + if candidates.stdout.is_empty() { + return Ok(b"empty\n".to_vec()); + } + let shim = FzfAlias::new(&fzf)?; + let mut paths = vec![shim.0.clone()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let path = + std::env::join_paths(paths).map_err(|error| format!("cannot set picker PATH: {error}"))?; + let output = Command::new(zoxide) + .args(["query", "--interactive"]) + .env("PATH", path) + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .map_err(|error| format!("cannot launch interactive zoxide query: {error}"))?; + if output.status.code() == Some(130) { + return Ok(b"cancelled\n".to_vec()); + } + if !output.status.success() { + return Err(format!( + "interactive zoxide query failed ({})", + output.status + )); + } + let target = crate::path_bytes::without_line_ending(&output.stdout); + if target.is_empty() { + // No selection after opening the picker must not run the next behavior. + return Ok(b"cancelled\n".to_vec()); + } + if target.contains(&0) { + return Err("interactive zoxide returned a path containing NUL".into()); + } + let mut result = b"selected\n".to_vec(); + result.extend_from_slice(target); + Ok(result) +} + +fn executable(program: &Path) -> Option { + let candidates = if program.components().count() > 1 || program.is_absolute() { + vec![program.to_path_buf()] + } else { + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(program)) + .collect() + }; + for candidate in candidates { + #[cfg(windows)] + let candidate = if candidate.extension().is_none() { + candidate.with_extension("exe") + } else { + candidate + }; + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + continue; + } + } + if let Ok(path) = dunce::canonicalize(candidate) { + return Some(path); + } + } + None +} + +struct FzfAlias(PathBuf); +impl FzfAlias { + fn new(fzf: &Path) -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..100 { + let id = NEXT.fetch_add(1, Ordering::Relaxed); + let directory = + std::env::temp_dir().join(format!("cj-fzf-{}-{id}", std::process::id())); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("cannot create fzf launcher directory: {error}")), + } + let alias = Self(directory); + #[cfg(unix)] + let result = std::os::unix::fs::symlink(fzf, alias.0.join("fzf")); + #[cfg(not(unix))] + let result = std::fs::hard_link(fzf, alias.0.join("fzf.exe")) + .or_else(|_| std::fs::copy(fzf, alias.0.join("fzf.exe")).map(|_| ())); + result.map_err(|error| format!("cannot create fzf launcher: {error}"))?; + return Ok(alias); + } + Err("cannot allocate fzf launcher directory".into()) + } +} +impl Drop for FzfAlias { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/src/main.rs b/src/main.rs index c3e35b0..d7f2702 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod completions; mod config; mod config_init; mod key_history; +mod key_zoxide; mod mounts; mod path_bytes; mod resolver; @@ -95,6 +96,9 @@ fn execute(cli: Cli) -> Result { Command::Version => Ok(Execution::stdout( concat!("cj ", env!("CARGO_PKG_VERSION")).into(), )), + Command::KeyBindingZoxide { zoxide, fzf } => { + key_zoxide::query_zoxide(&zoxide, &fzf).map(Execution::raw) + } Command::Resolve { targets, resolver } => { let config = config::Config::load(config_path.as_deref())?; let navigation = resolver::NavigationContext::from_process()?; From 68dad1b5c62c4c599fb57e93b5fdda5054f51a93 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:27:10 +0800 Subject: [PATCH 3/8] feat(shell): implement foreground configured key-binding widgets Add ordered zoxide and cj behaviors that append quoted paths without changing directories. Distinguish cancellation, errors, and fallback while preserving native PowerShell streams. Support Bash 4+ cursor versions and remove owned stale bindings during reload. Refs #45 Refs #46 --- src/key_bindings.rs | 291 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/shell.rs | 80 ++---------- 3 files changed, 299 insertions(+), 73 deletions(-) create mode 100644 src/key_bindings.rs diff --git a/src/key_bindings.rs b/src/key_bindings.rs new file mode 100644 index 0000000..0ceb6a9 --- /dev/null +++ b/src/key_bindings.rs @@ -0,0 +1,291 @@ +//! Foreground line-editor widgets for ordered directory completion. +use crate::cli::Shell; +use crate::config::{Config, KeyBinding, KeyBindingBehavior}; + +pub fn render(shell: Shell, chord: Option, config: &Config) -> String { + let cleanup = cleanup(shell); + let Some(chord @ (KeyBinding::CtrlO | KeyBinding::AltO)) = chord else { + return cleanup.into(); + }; + if config.key_binding_behaviors().is_empty() || shell == Shell::Nu { + return cleanup.into(); + } + let history = config + .key_binding_behaviors() + .contains(&KeyBindingBehavior::Cj); + let helper = if history { + crate::key_history::render(shell) + } else { + "" + }; + let widget = match shell { + Shell::Bash | Shell::Zsh => render_posix(shell, chord, config, history), + Shell::Pwsh => render_powershell(chord, config, history), + Shell::Nu => unreachable!(), + }; + format!("{cleanup}\n{helper}\n{widget}") +} + +fn cleanup(shell: Shell) -> &'static str { + match shell { + Shell::Bash => { + r#"if [[ -n "${_cj_bound_key-}" ]]; then + while IFS= read -r _cj_existing_binding; do + if [[ "$_cj_existing_binding" == "\"$_cj_bound_key\" \"${_cj_bound_widget-}\"" || "$_cj_existing_binding" == "\"$_cj_bound_key\": \"${_cj_bound_widget-}\"" ]]; then + \builtin bind -r "$_cj_bound_key" 2>/dev/null || : + fi + done < <(\builtin bind -X 2>/dev/null) +fi +unset _cj_bound_key _cj_bound_widget _cj_existing_binding"# + } + Shell::Zsh => { + r#"if [[ -n "${_cj_bound_key-}" && "$(\builtin bindkey "$_cj_bound_key" 2>/dev/null)" == "\"$_cj_bound_key\" ${_cj_bound_widget-}" ]]; then + \builtin bindkey -r "$_cj_bound_key" +fi +unset _cj_bound_key _cj_bound_widget"# + } + Shell::Pwsh => { + r#"$__cj_previous_chord = Get-Variable -Name __cj_bound_chord -Scope Global -ValueOnly -ErrorAction SilentlyContinue +if ($__cj_previous_chord -and (Get-Command Get-PSReadLineKeyHandler -ErrorAction SilentlyContinue)) { + $__cj_previous_handler = Get-PSReadLineKeyHandler -Chord $__cj_previous_chord + if ($__cj_previous_handler.Function -eq 'cj directory' -and $__cj_previous_handler.Description -eq 'cj managed directory completion') { + Remove-PSReadLineKeyHandler -Chord $__cj_previous_chord + } +} +Remove-Variable -Name __cj_bound_chord -Scope Global -ErrorAction SilentlyContinue +Remove-Variable -Name __cj_previous_chord, __cj_previous_handler -ErrorAction SilentlyContinue"# + } + Shell::Nu => "", + } +} + +fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool) -> String { + let behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "zoxide", + KeyBindingBehavior::Cj => "cj", + }) + .collect::>() + .join(" "); + let zoxide = quote(&config.programs.zoxide.to_string_lossy()); + let fzf = quote(&config.programs.fzf.to_string_lossy()); + let read_buffer = if shell == Shell::Bash { + "local _cj_buffer=\"${READLINE_LINE-}\" _cj_cursor=\"${READLINE_POINT-0}\"\n if (( BASH_VERSINFO[0] < 5 )); then local LC_ALL=C; fi" + } else { + "emulate -L zsh\n local _cj_buffer=\"$BUFFER\" _cj_cursor=\"$CURSOR\"" + }; + let apply = if shell == Shell::Bash { + "READLINE_LINE=\"$_cj_key_buffer\" READLINE_POINT=\"$_cj_key_cursor\"" + } else { + "BUFFER=\"$_cj_key_buffer\" CURSOR=\"$_cj_key_cursor\"" + }; + let reset = if history { + "_cj_key_history_reset" + } else { + ":" + }; + let resume = if history { + format!( + r#"if [[ -n "${{_cj_key_history_next-}}" && "$_cj_buffer" == "${{_cj_key_history_last-}}" && "$_cj_cursor" == "${{_cj_key_history_last_cursor-}}" ]]; then + _cj_key_history "$_cj_buffer" "$_cj_cursor" + {apply} + return 0 + fi + _cj_key_history_reset"# + ) + } else { + String::new() + }; + // A sentinel prevents command substitution from stripping newlines in paths. + let body = format!( + r#"_cj_key_widget() {{ + {read_buffer} + {resume} + local _cj_behavior _cj_reply _cj_status _cj_target _cj_quoted _cj_quote _cj_separator _cj_missing='' _cj_empty='no matching directories' + for _cj_behavior in {behaviors}; do + case "$_cj_behavior" in + cj) + if _cj_key_history "$_cj_buffer" "$_cj_cursor"; then + {apply} + return 0 + fi + _cj_empty='no directory history' + ;; + zoxide) + if [[ -n "${{ZSH_VERSION-}}" ]]; then \builtin zle -I 2>/dev/null || :; fi + if _cj_reply="$(\command cj --internal-key-binding-zoxide {zoxide} {fzf}; _cj_status=$?; printf '.'; exit "$_cj_status")"; then + _cj_status=0 + else + _cj_status=$? + fi + _cj_reply="${{_cj_reply%.}}" + if (( _cj_status != 0 )); then + {reset} + return 0 + fi + case "$_cj_reply" in + selected$'\n'*) + _cj_target="${{_cj_reply#*$'\n'}}" + {reset} + [[ "$_cj_target" != -* ]] || _cj_target="./$_cj_target" + _cj_quote="'\\''" + _cj_quoted="'${{_cj_target//\'/$_cj_quote}}'" + _cj_separator='' + [[ -z "$_cj_buffer" || "$_cj_buffer" == *[[:space:]] ]] || _cj_separator=' ' + _cj_key_buffer="$_cj_buffer$_cj_separator$_cj_quoted" + _cj_key_cursor=${{#_cj_key_buffer}} + {apply} + return 0 + ;; + unavailable$'\n'*) _cj_missing="${{_cj_reply#*$'\n'}}" ;; + empty$'\n') ;; + cancelled$'\n') + {reset} + return 0 + ;; + *) + printf '%s\n' 'cj: invalid interactive zoxide response' >&2 + {reset} + return 0 + ;; + esac + ;; + esac + done + printf 'cj: %s\n' "${{_cj_missing:-$_cj_empty}}" >&2 + return 0 +}}"# + ); + let binding = match (shell, chord) { + (Shell::Bash, KeyBinding::CtrlO) => r#"\builtin bind -x '"\C-o":_cj_key_widget'"#, + (Shell::Bash, _) => r#"\builtin bind -x '"\eo":_cj_key_widget'"#, + (Shell::Zsh, KeyBinding::CtrlO) => { + "\\builtin zle -N _cj_key_widget\n\\builtin bindkey '^O' _cj_key_widget" + } + (Shell::Zsh, _) => { + "\\builtin zle -N _cj_key_widget\n\\builtin bindkey '^[o' _cj_key_widget" + } + _ => unreachable!(), + }; + let key = match (shell, chord) { + (Shell::Bash, KeyBinding::CtrlO) => r"\C-o", + (Shell::Bash, _) => r"\eo", + (Shell::Zsh, KeyBinding::CtrlO) => "^O", + (Shell::Zsh, _) => "^[o", + _ => unreachable!(), + }; + 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") + } else { + format!("{body}\n{binding}") + } +} + +fn render_powershell(chord: KeyBinding, config: &Config, history: bool) -> String { + let quote = |value: &str| format!("'{}'", value.replace('\'', "''")); + let zoxide = quote(&config.programs.zoxide.to_string_lossy()); + let fzf = quote(&config.programs.fzf.to_string_lossy()); + let behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "'zoxide'", + KeyBindingBehavior::Cj => "'cj'", + }) + .collect::>() + .join(", "); + let reset = if history { "Reset-CjKeyHistory" } else { "" }; + let resume = if history { + r#"$cycle = $global:__cj_key_history_cycle + if ($null -ne $cycle -and $buffer -ceq $cycle.LastBuffer -and $cursor -eq $cycle.LastCursor) { + $result = Invoke-CjKeyHistory -Buffer $buffer -Cursor $cursor + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $result.Buffer) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($result.Cursor) + return + } + Reset-CjKeyHistory"# + } else { + "" + }; + let chord = if chord == KeyBinding::CtrlO { + "Ctrl+o" + } else { + "Alt+o" + }; + format!( + r#"function global:Invoke-CjKeyWidget {{ + $buffer = ''; $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$buffer, [ref]$cursor) + {resume} + $missing = $null + $empty = 'no matching directories' + foreach ($behavior in @({behaviors})) {{ + if ($behavior -eq 'cj') {{ + $result = Invoke-CjKeyHistory -Buffer $buffer -Cursor $cursor + if ($null -ne $result) {{ + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $result.Buffer) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($result.Cursor) + return + }} + $empty = 'no directory history' + continue + }} + # PowerShell's native pipeline captures stderr even inside PSReadLine. + # Redirect only the private stdout protocol; leave the picker on the tty. + $process = [System.Diagnostics.Process]::new() + try {{ + $process.StartInfo.FileName = $script:__cj_executable.Path + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false) + $process.StartInfo.WorkingDirectory = (Get-Location).ProviderPath + foreach ($argument in @('--internal-key-binding-zoxide', {zoxide}, {fzf})) {{ + $process.StartInfo.ArgumentList.Add($argument) + }} + [void]$process.Start() + $raw = $process.StandardOutput.ReadToEnd() + $process.WaitForExit() + $status = $process.ExitCode + }} catch {{ + [Console]::Error.WriteLine("cj: cannot launch interactive zoxide query: $_") + {reset}; return + }} finally {{ + $process.Dispose() + }} + if ($status -ne 0) {{ [Console]::Error.WriteLine("cj: interactive zoxide query failed (exit $status)"); {reset}; return }} + $reply = $raw -split "`n", 2 + if ($reply.Count -ne 2) {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide response'); {reset}; return }} + switch ($reply[0]) {{ + 'selected' {{ + if ($reply[1].Length -eq 0) {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide path'); {reset}; return }} + {reset} + $separator = if ($buffer.Length -eq 0 -or [char]::IsWhiteSpace($buffer[$buffer.Length - 1])) {{ '' }} else {{ ' ' }} + $target = [string]$reply[1] + if ($target.StartsWith('-')) {{ $target = './' + $target }} + $quoted = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($target) + $updated = $buffer + $separator + "'" + $quoted + "'" + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $buffer.Length, $updated) + [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($updated.Length) + return + }} + 'unavailable' {{ $missing = $reply[1] }} + 'empty' {{ }} + 'cancelled' {{ {reset}; return }} + default {{ [Console]::Error.WriteLine('cj: invalid interactive zoxide response'); {reset}; return }} + }} + }} + if ($missing) {{ [Console]::Error.WriteLine("cj: $missing") }} else {{ [Console]::Error.WriteLine("cj: $empty") }} +}} +if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) {{ + Set-PSReadLineKeyHandler -Chord '{chord}' -BriefDescription 'cj directory' -Description 'cj managed directory completion' -ScriptBlock {{ Invoke-CjKeyWidget }} + $global:__cj_bound_chord = '{chord}' +}}"# + ) +} diff --git a/src/main.rs b/src/main.rs index d7f2702..1af887f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod completions; mod config; mod config_init; +mod key_bindings; mod key_history; mod key_zoxide; mod mounts; diff --git a/src/shell.rs b/src/shell.rs index c03ac53..06ad79d 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -235,50 +235,8 @@ elif [[ -n "${{ZSH_VERSION-}}" ]]; then _cj_register_cd_completion fi"# ); - Ok(match binding { - Some(KeyBinding::CtrlO) => format!("{wrapper}\n\n{}", render_binding(shell, &config, true)), - Some(KeyBinding::AltO) => format!("{wrapper}\n\n{}", render_binding(shell, &config, false)), - Some(KeyBinding::None) | None => wrapper, - }) -} - -fn render_binding(shell: Shell, config: &str, control: bool) -> String { - let command = format!("\\command cj{config} --jump-worktree"); - match shell { - Shell::Bash => { - let key = if control { r#"\C-o"# } else { r#"\eo"# }; - format!( - r#"_cj_worktree_widget() {{ - local target - target="$({command})" || return - [[ -n "$target" ]] || return - \builtin cd -- "$target" - unset _cj_down_route -}} -\builtin bind -x '"{key}":_cj_worktree_widget'"# - ) - } - Shell::Zsh => { - let key = if control { "^O" } else { "^[o" }; - format!( - r#"_cj_worktree_widget() {{ - local target _cj_status - target="$({command})" - _cj_status=$? - if (( _cj_status == 0 )) && [[ -n "$target" ]]; then - \builtin cd -- "$target" - _cj_status=$? - (( _cj_status == 0 )) && unset _cj_down_route - fi - \builtin zle reset-prompt - return "$_cj_status" -}} -\builtin zle -N _cj_worktree_widget -\builtin bindkey '{key}' _cj_worktree_widget"# - ) - } - Shell::Nu | Shell::Pwsh => unreachable!("bindings are POSIX-shell only"), - } + let widget = crate::key_bindings::render(shell, binding, settings); + Ok(format!("{wrapper}\n{widget}")) } fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result { @@ -628,31 +586,8 @@ if (Test-Path Function:TabExpansion2) {{ }} }}"# ); - Ok(match binding { - Some(KeyBinding::CtrlO) => { - format!("{wrapper}\n\n{}", render_powershell_binding("Ctrl+o")) - } - Some(KeyBinding::AltO) => { - format!("{wrapper}\n\n{}", render_powershell_binding("Alt+o")) - } - Some(KeyBinding::None) | None => wrapper, - }) -} - -fn render_powershell_binding(chord: &str) -> String { - format!( - r#"if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) {{ - Set-PSReadLineKeyHandler -Chord '{chord}' -BriefDescription 'cj worktree' -ScriptBlock {{ - $configArgs = $script:__cj_config - $executable = $script:__cj_executable.Path - $target = @(& $executable @configArgs --jump-worktree) - if (($LASTEXITCODE -eq 0) -and ($target.Count -eq 1) -and -not [string]::IsNullOrEmpty($target[0])) {{ - Microsoft.PowerShell.Management\Set-Location -LiteralPath $target[0] -ErrorAction Stop - $global:__cj_down_route = $null - }} - }} -}}"# - ) + let widget = crate::key_bindings::render(Shell::Pwsh, binding, settings); + Ok(format!("{wrapper}\n{widget}")) } fn absolute(path: &Path) -> Result { @@ -726,12 +661,11 @@ mod tests { &Config::default(), ) .unwrap(); - assert!(bash.contains(r#"\builtin bind -x '"\C-o":_cj_worktree_widget'"#)); - assert!(bash.contains("\\command cj --jump-worktree")); + assert!(bash.contains(r#"\builtin bind -x '"\C-o":_cj_key_widget'"#)); + assert!(bash.contains("--internal-key-binding-zoxide")); let zsh = render(Shell::Zsh, None, Some(KeyBinding::AltO), &Config::default()).unwrap(); - assert!(zsh.contains("\\builtin bindkey '^[o' _cj_worktree_widget")); - assert!(zsh.contains("\\builtin zle reset-prompt")); + assert!(zsh.contains("\\builtin bindkey '^[o' _cj_key_widget")); let powershell = render( Shell::Pwsh, From 6ae36f3ac7584f2073974fb575687499650afd3d Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:27:49 +0800 Subject: [PATCH 4/8] test(shell): add runtime zoxide and ordered-dispatch regression tests Exercise real zoxide interaction and snapshot custom programs and options. Cover quoting, empty or missing fallbacks, cancellation, and errors. Refs #45 --- tests/key_bindings.rs | 375 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 tests/key_bindings.rs diff --git a/tests/key_bindings.rs b/tests/key_bindings.rs new file mode 100644 index 0000000..2a6701a --- /dev/null +++ b/tests/key_bindings.rs @@ -0,0 +1,375 @@ +#![cfg(unix)] +mod support; + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use support::{TempDir, assert_success, cj, write_executable}; + +struct Fixture { + temp: TempDir, + zoxide: std::path::PathBuf, + fzf: std::path::PathBuf, +} +impl Fixture { + fn new() -> Self { + let temp = TempDir::new("key-binding"); + let zoxide = temp.path().join("custom zoxide's executable"); + let fzf = temp.path().join("custom fzf's executable"); + write_executable( + &zoxide, + r#" +case "$*" in + 'query --list') printf '%s' "${CJ_TEST_CANDIDATES-/somewhere}"; exit "${CJ_TEST_LIST_STATUS-0}" ;; + 'query --interactive') + printf '%s\n' "$*" >> "$CJ_TEST_LOG" + fzf + ;; + *) echo 'unexpected zoxide arguments' >&2; exit 2 ;; +esac"#, + ); + write_executable( + &fzf, + r#" +printf '%s' "${_ZO_FZF_OPTS-}" > "$CJ_TEST_OPTS" +printf '%s' "${CJ_TEST_PICK-}" +if [ "${CJ_TEST_PICK_STATUS-0}" = 7 ]; then echo 'picker exploded' >&2; fi +exit "${CJ_TEST_PICK_STATUS-0}""#, + ); + Self { temp, zoxide, fzf } + } + fn command(&self) -> Command { + let mut command = cj(self.temp.path(), self.temp.path()); + command + .args(["--internal-key-binding-zoxide"]) + .arg(&self.zoxide) + .arg(&self.fzf); + self.environment(&mut command); + command + } + fn environment(&self, command: &mut Command) { + command + .env("CJ_TEST_LOG", self.temp.path().join("calls")) + .env("CJ_TEST_OPTS", self.temp.path().join("opts")); + } + fn source(&self, shell: &str, behaviors: &str) -> String { + self.source_with_chord(shell, behaviors, "ctrl-o") + } + fn source_with_chord(&self, shell: &str, behaviors: &str, chord: &str) -> String { + let config = self.temp.path().join("config.toml"); + let text = format!( + "[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = '{chord}', behaviors = [{behaviors}] }}\nlinux = {{ key = '{chord}', behaviors = [{behaviors}] }}\nwindows = {{ key = '{chord}', behaviors = [{behaviors}] }}\n", + serde_json::to_string(self.zoxide.to_str().unwrap()).unwrap(), + serde_json::to_string(self.fzf.to_str().unwrap()).unwrap() + ); + fs::write(&config, text).unwrap(); + let output = cj(self.temp.path(), self.temp.path()) + .arg("-C") + .arg(&config) + .args(["init", shell, "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&output); + // Keypresses use the initialized settings even when the file disappears. + fs::remove_file(config).unwrap(); + String::from_utf8(output.stdout).unwrap() + } + fn widget( + &self, + shell: &str, + behaviors: &str, + status: &str, + candidates: &str, + prefix: &str, + selection: &str, + ) -> Output { + let source = self.source(shell, behaviors); + let script = self.temp.path().join("widget.sh"); + let (line, cursor) = if shell == "bash" { + ("READLINE_LINE", "READLINE_POINT") + } else { + ("BUFFER", "CURSOR") + }; + fs::write( + &script, + format!( + r#"{source} +_cj_history=('/old history' '/new history') +{line}="$CJ_TEST_PREFIX" +{cursor}=1 +_cj_key_widget +printf '%s\0%s\0%s\0' "${line}" "${cursor}" "$PWD" +printf '%s\0' "${{_cj_history[@]}}" +"# + ), + ) + .unwrap(); + let mut command = Command::new(shell); + command + .arg(&script) + .current_dir(self.temp.path()) + .env( + "PATH", + format!( + "{}:{}", + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("CJ_TEST_PREFIX", prefix) + .env("CJ_TEST_PICK", selection) + .env("CJ_TEST_PICK_STATUS", status) + .env("CJ_TEST_CANDIDATES", candidates); + self.environment(&mut command); + command.output().unwrap() + } +} +fn shells() -> impl Iterator { + ["bash", "zsh"] + .into_iter() + .filter(|shell| Command::new(shell).arg("--version").output().is_ok()) +} +fn legacy_bash() -> bool { + let output = Command::new("bash") + .args(["-c", "printf '%s' \"${BASH_VERSINFO[0]}\""]) + .output() + .unwrap(); + String::from_utf8(output.stdout) + .unwrap() + .parse::() + .unwrap() + < 5 +} +fn fields(output: &Output) -> Vec<&str> { + std::str::from_utf8(&output.stdout) + .unwrap() + .split('\0') + .collect() +} + +#[test] +fn helper_runs_configured_programs_and_preserves_zoxide_options_and_path() { + let fixture = Fixture::new(); + let path = "/space's dir/Unicode 台灣/$literal\n\n"; + let output = fixture + .command() + .env("CJ_TEST_PICK", format!("{path}\n")) + .env("_ZO_FZF_OPTS", "--height=80% --no-sort") + .output() + .unwrap(); + assert_success(&output); + assert_eq!(output.stdout, format!("selected\n{path}").as_bytes()); + assert_eq!( + fs::read_to_string(fixture.temp.path().join("opts")).unwrap(), + "--height=80% --no-sort" + ); + assert_eq!( + fs::read_to_string(fixture.temp.path().join("calls")).unwrap(), + "query --interactive\n" + ); +} + +#[test] +fn helper_distinguishes_missing_empty_cancelled_and_failed_sources() { + let fixture = Fixture::new(); + let empty = fixture + .command() + .env("CJ_TEST_CANDIDATES", "") + .output() + .unwrap(); + assert_success(&empty); + assert_eq!(empty.stdout, b"empty\n"); + assert!(!fixture.temp.path().join("calls").exists()); + let cancelled = fixture + .command() + .env("CJ_TEST_PICK_STATUS", "130") + .output() + .unwrap(); + assert_success(&cancelled); + assert_eq!(cancelled.stdout, b"cancelled\n"); + let failed = fixture + .command() + .env("CJ_TEST_PICK_STATUS", "7") + .output() + .unwrap(); + assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stderr).contains("picker exploded")); + let list_failure = fixture + .command() + .env("CJ_TEST_LIST_STATUS", "3") + .output() + .unwrap(); + assert!(!list_failure.status.success()); + assert!(String::from_utf8_lossy(&list_failure.stderr).contains("zoxide query failed")); + fs::remove_file(&fixture.fzf).unwrap(); + let missing = fixture.command().output().unwrap(); + assert_success(&missing); + assert!( + String::from_utf8_lossy(&missing.stdout) + .starts_with("unavailable\nfzf executable not found:") + ); +} + +#[test] +fn widgets_append_quoted_paths_and_preserve_cwd_and_history() { + for shell in shells() { + for (prefix, target) in [ + ("code --wait", "/space's dir/台灣;$()"), + ("", "/selected"), + ("cd ", "-leading-dash"), + ("open café", "/last\n\n"), + ] { + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'zoxide', 'cj'", + "0", + "/candidate", + prefix, + &format!("{target}\n"), + ); + assert_success(&output); + let fields = fields(&output); + let target = if target.starts_with('-') { + format!("./{target}") + } else { + target.into() + }; + let separator = if prefix.is_empty() || prefix.ends_with(' ') { + "" + } else { + " " + }; + let expected = format!("{prefix}{separator}'{}'", target.replace('\'', "'\\''")); + assert_eq!(fields[0], expected, "{shell}"); + let cursor = if shell == "bash" && legacy_bash() { + expected.len() + } else { + expected.chars().count() + }; + assert_eq!(fields[1], cursor.to_string()); + assert_eq!(fields[2], fixture.temp.path().to_str().unwrap()); + assert_eq!(&fields[3..5], ["/old history", "/new history"]); + } + } +} + +#[test] +fn cancellation_and_query_failure_stop_the_chain_without_edits() { + for shell in shells() { + for status in ["130", "7"] { + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'zoxide', 'cj'", + status, + "/candidate", + "code original", + "", + ); + assert_success(&output); + let fields = fields(&output); + assert_eq!(&fields[..2], ["code original", "1"], "{shell}, {status}"); + assert_eq!(fields[2], fixture.temp.path().to_str().unwrap()); + if status == "7" { + assert!(String::from_utf8_lossy(&output.stderr).contains("picker exploded")); + } + } + } +} + +#[test] +fn unavailable_or_empty_source_falls_through_in_configured_order() { + for shell in shells() { + for missing in [false, true] { + let fixture = Fixture::new(); + if missing { + fs::remove_file(&fixture.fzf).unwrap(); + } + let output = fixture.widget(shell, "'zoxide', 'cj'", "0", "", "code", ""); + assert_success(&output); + assert_eq!(fields(&output)[0], "code '/new history'", "{shell}"); + assert!(!fixture.temp.path().join("calls").exists()); + } + let fixture = Fixture::new(); + let output = fixture.widget( + shell, + "'cj', 'zoxide'", + "0", + "/candidate", + "code", + "/selected\n", + ); + assert_success(&output); + assert_eq!(fields(&output)[0], "code '/new history'"); + assert!(!fixture.temp.path().join("calls").exists()); + } +} + +#[test] +fn missing_dependency_is_diagnostic_when_no_fallback_edits() { + for shell in shells() { + let fixture = Fixture::new(); + fs::remove_file(&fixture.zoxide).unwrap(); + let output = fixture.widget(shell, "'zoxide'", "0", "", "cd", ""); + assert_success(&output); + assert_eq!(&fields(&output)[..2], ["cd", "1"]); + assert!(String::from_utf8_lossy(&output.stderr).contains("zoxide executable not found:")); + } +} + +#[test] +fn installed_zoxide_owns_interactive_selection_and_cancel_status() { + if Command::new("zoxide").arg("--version").output().is_err() { + return; + } + let fixture = Fixture::new(); + let directory = fixture.temp.path().join("real zoxide's 台灣 directory"); + fs::create_dir(&directory).unwrap(); + let data = fixture.temp.path().join("zoxide-data"); + let added = Command::new("zoxide") + .arg("add") + .arg(&directory) + .env("_ZO_DATA_DIR", &data) + .env_remove("_ZO_EXCLUDE_DIRS") + .output() + .unwrap(); + assert_success(&added); + write_executable( + &fixture.fzf, + r#" +printf '%s' "${FZF_DEFAULT_OPTS-}" > "$CJ_TEST_OPTS" +if [ "${CJ_TEST_CANCEL-0}" = 1 ]; then exit 130; fi +tr '\000' '\n' | head -n 1 +"#, + ); + for cancel in [false, true] { + let mut command = cj(fixture.temp.path(), fixture.temp.path()); + command + .arg("--internal-key-binding-zoxide") + .arg("zoxide") + .arg(&fixture.fzf) + .env("_ZO_DATA_DIR", &data) + .env_remove("_ZO_EXCLUDE_DIRS") + .env("_ZO_FZF_OPTS", "--height=80%") + .env("CJ_TEST_CANCEL", if cancel { "1" } else { "0" }); + fixture.environment(&mut command); + let output = command.output().unwrap(); + assert_success(&output); + if cancel { + assert_eq!(output.stdout, b"cancelled\n"); + } else { + assert_eq!( + output.stdout, + format!("selected\n{}", directory.display()).as_bytes() + ); + } + assert_eq!( + fs::read_to_string(fixture.temp.path().join("opts")).unwrap(), + "--height=80%" + ); + } +} From 73847ce81e6afa1e7733b7267c4464a1a396f856 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:28:28 +0800 Subject: [PATCH 5/8] test(shell): add key binding editor regression coverage Add real Bash Readline and Zsh ZLE tests for Unicode cursor placement, literal insertion, and buffer preservation on cancellation and errors. Ensure directory and history state remain untouched. Cover PowerShell dispatch ordering and owned-binding reload behavior. Refs #45 Refs #46 --- tests/key_binding_editors.rs | 281 +++++++++++++++++++++++++++++++++++ tests/key_bindings.rs | 151 +++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 tests/key_binding_editors.rs diff --git a/tests/key_binding_editors.rs b/tests/key_binding_editors.rs new file mode 100644 index 0000000..c8240d0 --- /dev/null +++ b/tests/key_binding_editors.rs @@ -0,0 +1,281 @@ +#![cfg(unix)] + +mod support; + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use support::{TempDir, assert_success, cj, write_executable}; + +#[test] +fn real_line_editors_cycle_history_and_restore_the_original_line() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let bash = env::split_paths(&env::var_os("PATH").unwrap_or_default()) + .map(|path| path.join("bash")) + .find(|path| { + Command::new(path) + .args(["-c", "(( BASH_VERSINFO[0] >= 4 ))"]) + .status() + .is_ok_and(|status| status.success()) + }); + for shell in ["bash", "zsh"] { + if shell == "bash" && bash.is_none() { + assert!( + env::var_os("CJ_REQUIRE_SHELLS").is_none(), + "Bash 4+ required for widget tests" + ); + continue; + } + let temp = TempDir::new("key-editors-雪"); + let config = temp.path().join("config.toml"); + fs::write(&config, "[key-bindings]\nmacos = { key = 'ctrl-o', behaviors = ['cj'] }\nlinux = { key = 'ctrl-o', behaviors = ['cj'] }\n").unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", shell, "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let mut script = String::from_utf8(generated.stdout).unwrap(); + script.push_str( + r#" +_cj_history=("$CJ_TEST_OLDER" "$CJ_TEST_NEWER") +_test_record() { + printf '%s\000' "$1" "$2" "$PWD" "${_cj_history[@]}" >> "$CJ_TEST_RESULTS" +} + +"#, + ); + if shell == "bash" { + script.push_str( + r#" +_test_widget() { + _cj_key_widget + _test_record "$READLINE_LINE" "$READLINE_POINT" +} +bind -x '"\C-o":_test_widget' +set -o emacs +PS1='CJ_EDITOR_READY> ' +"#, + ); + } else { + script.push_str( + r#" +zle-line-init() { + BUFFER='code ' + CURSOR=5 + for attempt in 1 2 3; do + zle _cj_key_widget + _test_record "$BUFFER" "$CURSOR" + done + exit +} +zle -N zle-line-init +test_buffer='' +vared test_buffer +"#, + ); + } + let source = temp.path().join("editor.sh"); + fs::write(&source, script).unwrap(); + let results = temp.path().join("results"); + let older = temp.path().join("older 'quoted' 雪"); + let newer = temp.path().join("-newer folder"); + let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +if [[ "$CJ_TEST_SHELL" == bash ]]; then + zpty -b cj_editor "$CJ_TEST_BASH" --noprofile --rcfile "$CJ_TEST_SCRIPT" -i || exit 1 + for attempt in {1..200}; do + if zpty -r cj_editor startup; then + [[ "$startup" == *'CJ_EDITOR_READY> '* ]] && break + fi + sleep 0.02 + done + zpty -w -n cj_editor $'code \x0f\x0f\x0f\x15exit\n' +else + zpty -b cj_editor zsh -fi "$CJ_TEST_SCRIPT" || exit 1 +fi +for attempt in {1..200}; do + while zpty -r cj_editor output; do print -rn -- "$output"; done + zpty -t cj_editor || break + sleep 0.05 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm") + .env("INPUTRC", "/dev/null") + .env("CJ_TEST_SHELL", shell) + .env( + "CJ_TEST_BASH", + bash.as_deref().unwrap_or(std::path::Path::new("bash")), + ) + .env("CJ_TEST_SCRIPT", source) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_OLDER", &older) + .env("CJ_TEST_NEWER", &newer) + .output() + .unwrap(); + assert_success(&output); + let bytes = fs::read(&results).unwrap_or_else(|error| { + panic!( + "{shell}: {error}; {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let fields: Vec<_> = bytes + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .map(|field| std::str::from_utf8(field).unwrap()) + .collect(); + assert_eq!(fields.len(), 15, "{shell}: {fields:?}"); + for (index, record) in fields.chunks_exact(5).enumerate() { + let expected = match index { + 0 => format!("code '{}'", newer.to_str().unwrap()), + 1 => format!("code '{}'", older.to_str().unwrap().replace('\'', "'\\''")), + _ => "code ".into(), + }; + assert_eq!(record[0], expected, "{shell}"); + let cursor = expected.chars().count(); + assert_eq!(record[1], cursor.to_string(), "{shell}"); + assert_eq!(record[2], temp.path().to_str().unwrap(), "{shell}"); + assert_eq!(record[3], older.to_str().unwrap(), "{shell}"); + assert_eq!(record[4], newer.to_str().unwrap(), "{shell}"); + } + } +} + +#[test] +fn real_zle_picker_preserves_the_line_on_cancellation_and_errors() { + if Command::new("zsh").arg("--version").output().is_err() { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + let temp = TempDir::new("key-zle-picker"); + let zoxide = temp.path().join("zoxide"); + let fzf = temp.path().join("fzf"); + write_executable( + &zoxide, + "if [ \"$2\" = --list ]; then printf '/candidate\\n'; else fzf; fi", + ); + write_executable( + &fzf, + r#" +test -t 0 < /dev/tty +case "$CJ_TEST_PICK_STATUS" in + 0) printf '%s\n' "$CJ_TEST_SELECTION" ;; + 7) printf '%s\n' 'durable picker failure' >&2; exit 7 ;; + *) exit 130 ;; +esac +"#, + ); + let config = temp.path().join("config.toml"); + fs::write(&config, format!("[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = ['zoxide', 'cj'] }}\nlinux = {{ key = 'ctrl-o', behaviors = ['zoxide', 'cj'] }}\n", serde_json::to_string(zoxide.to_str().unwrap()).unwrap(), serde_json::to_string(fzf.to_str().unwrap()).unwrap())).unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", "zsh", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let mut source = String::from_utf8(generated.stdout).unwrap(); + source.push_str( + r#" +_cj_history=('/fallback must not be used') +zle-line-init() { + local outcome + for outcome in 0 130 7; do + export CJ_TEST_PICK_STATUS=$outcome + BUFFER='code ' + CURSOR=2 + zle _cj_key_widget + printf '%s\000' "$BUFFER" "$CURSOR" "$PWD" "${_cj_history[@]}" >> "$CJ_TEST_RESULTS" + done + exit +} +zle -N zle-line-init +test_buffer='' +vared test_buffer +"#, + ); + let script = temp.path().join("widget.zsh"); + fs::write(&script, source).unwrap(); + fs::remove_file(&config).unwrap(); + let results = temp.path().join("results"); + let selection = temp.path().join("space's 雪 directory"); + let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +zpty -b cj_editor zsh -fi "$CJ_TEST_SCRIPT" || exit 1 +for attempt in {1..200}; do + while zpty -r cj_editor output; do print -rn -- "$output"; done + zpty -t cj_editor || break + sleep 0.05 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm") + .env("CJ_TEST_SCRIPT", &script) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_SELECTION", &selection) + .output() + .unwrap(); + assert_success(&output); + assert!( + String::from_utf8_lossy(&output.stdout).contains("durable picker failure"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let bytes = fs::read(results).unwrap(); + let fields: Vec<_> = bytes + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| std::str::from_utf8(value).unwrap()) + .collect(); + assert_eq!(fields.len(), 12); + let quoted = format!( + "code '{}'", + selection.to_str().unwrap().replace('\'', "'\\''") + ); + for (index, record) in fields.chunks_exact(4).enumerate() { + assert_eq!(record[0], if index == 0 { "ed } else { "code " }); + assert_eq!( + record[1], + if index == 0 { + quoted.chars().count().to_string() + } else { + "2".into() + } + ); + assert_eq!(record[2], temp.path().to_str().unwrap()); + assert_eq!(record[3], "/fallback must not be used"); + } +} diff --git a/tests/key_bindings.rs b/tests/key_bindings.rs index 2a6701a..1a63bdf 100644 --- a/tests/key_bindings.rs +++ b/tests/key_bindings.rs @@ -373,3 +373,154 @@ tr '\000' '\n' | head -n 1 ); } } + +#[test] +fn powershell_dispatcher_preserves_buffers_and_orders_behaviors() { + if Command::new("pwsh").arg("-Version").output().is_err() { + return; + } + // Exercise PowerShell parsing and dispatch with an in-memory line-editor + // adapter. The native editor lifecycle is covered by the PTY tests. + for (behaviors, status, candidates, expected) in [ + ("'zoxide', 'cj'", "0", "/candidate", "code '/a''s 雪/‘’'"), + ("'zoxide', 'cj'", "130", "/candidate", "code"), + ("'zoxide', 'cj'", "7", "/candidate", "code"), + ("'zoxide', 'cj'", "0", "", "code '/new history'"), + ("'cj', 'zoxide'", "0", "/candidate", "code '/new history'"), + ] { + let fixture = Fixture::new(); + let source = fixture.source("powershell", behaviors); + let script = fixture.temp.path().join("widget.ps1"); + fs::write(&script, format!(r#" +Import-Module Microsoft.PowerShell.Utility +Import-Module Microsoft.PowerShell.Management +$ErrorActionPreference = 'Stop' +$PSModuleAutoLoadingPreference = 'None' +Add-Type -TypeDefinition @' +namespace Microsoft.PowerShell {{ + public static class PSConsoleReadLine {{ + public static string Buffer = "code"; + public static int Cursor = 1; + public static void GetBufferState(ref string buffer, ref int cursor) {{ buffer = Buffer; cursor = Cursor; }} + public static void Replace(int start, int length, string text) {{ Buffer = text; }} + public static void SetCursorPosition(int cursor) {{ Cursor = cursor; }} + }} +}} +'@ +{source} +$global:__cj_history = @('/old history', '/new history') +Invoke-CjKeyWidget +[Console]::WriteLine([Microsoft.PowerShell.PSConsoleReadLine]::Buffer) +[Console]::WriteLine([Microsoft.PowerShell.PSConsoleReadLine]::Cursor) +[Console]::WriteLine((Get-Location).Path) +"#)).unwrap(); + let mut command = Command::new("pwsh"); + command + .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"]) + .arg(&script) + .current_dir(fixture.temp.path()) + .env( + "PATH", + format!( + "{}:{}", + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .env("CJ_TEST_PICK", "/a's 雪/‘’\n") + .env("CJ_TEST_PICK_STATUS", status) + .env("CJ_TEST_CANDIDATES", candidates); + fixture.environment(&mut command); + let output = command.output().unwrap(); + assert_success(&output); + let text = String::from_utf8_lossy(&output.stdout); + let lines: Vec<_> = text.lines().collect(); + let expected = expected.replace('‘', "‘‘").replace('’', "’’"); + assert_eq!( + lines[0], + expected, + "{status}, {behaviors}, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + lines[1], + if status == "130" || status == "7" { + 1 + } else { + expected.encode_utf16().count() + } + .to_string() + ); + assert_eq!(lines[2], fixture.temp.path().to_str().unwrap()); + } +} + +#[test] +fn sourcing_again_updates_only_the_binding_owned_by_cj() { + let bash = if Path::new("/opt/homebrew/bin/bash").exists() { + "/opt/homebrew/bin/bash" + } else { + "bash" + }; + for (shell, executable) in [("bash", bash), ("zsh", "zsh")] { + if Command::new(executable).arg("--version").output().is_err() { + continue; + } + if shell == "bash" + && !Command::new(executable) + .args(["-c", "(( BASH_VERSINFO[0] >= 4 ))"]) + .status() + .unwrap() + .success() + { + continue; + } + let fixture = Fixture::new(); + let first = fixture.source_with_chord(shell, "'cj'", "ctrl-o"); + let second = fixture.source_with_chord(shell, "'cj'", "alt-o"); + let disabled = fixture.source_with_chord(shell, "", "alt-o"); + let query = if shell == "bash" { + "bind -X 2>/dev/null" + } else { + "bindkey '^O'; bindkey '^[o'" + }; + let replace = if shell == "bash" { + r#"bind '"\C-o":beginning-of-line'"# + } else { + "bindkey '^O' beginning-of-line" + }; + let query_user = if shell == "bash" { + "bind -q beginning-of-line" + } else { + "bindkey '^O'" + }; + 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(); + assert_success(&output); + let sections = fields(&output); + assert!( + sections[0].contains("_cj_key_widget"), + "{shell}: {}", + sections[0] + ); + if shell == "bash" { + assert!(!sections[0].contains("\\C-o")); + assert!(sections[0].contains("\\eo")); + } else { + assert!(sections[0].contains("\"^O\" undefined-key")); + } + assert!( + !sections[1].contains("_cj_key_widget"), + "{shell}: {}", + sections[1] + ); + assert!(sections[2].contains("beginning-of-line")); + if shell == "bash" { + assert!(sections[2].contains("\\C-o")); + } + } +} From 63f183956ce0b096da5fbc2cee56913d78ed3728 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:31:40 +0800 Subject: [PATCH 6/8] feat(shell): complete foreground shell support Add Nushell Reedline command-buffer widgets and initialize history once through the exported widget environment. Preserve terminal streams and add native PowerShell PSReadLine regression coverage. Refs #45 Refs #46 --- src/cli.rs | 7 +- src/key_bindings.rs | 10 +- src/key_bindings_nu.rs | 153 ++++++++++++++++++++++++ src/key_zoxide.rs | 4 +- src/main.rs | 1 + src/shell.rs | 13 +- tests/key_binding_powershell.rs | 202 ++++++++++++++++++++++++++++++++ 7 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 src/key_bindings_nu.rs create mode 100644 tests/key_binding_powershell.rs diff --git a/src/cli.rs b/src/cli.rs index 794d865..82086cc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -345,11 +345,6 @@ fn parse_init( ); } let shell = parse_shell(&targets[1])?; - if options.setup_key_binding && shell == Shell::Nu { - return Err( - "--setup-key-binding is currently supported for bash, zsh, and PowerShell".into(), - ); - } Ok(Command::Init { shell, setup_key_binding: options.setup_key_binding, @@ -692,7 +687,7 @@ mod tests { #[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_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 { diff --git a/src/key_bindings.rs b/src/key_bindings.rs index 0ceb6a9..f98a1ad 100644 --- a/src/key_bindings.rs +++ b/src/key_bindings.rs @@ -7,9 +7,15 @@ pub fn render(shell: Shell, chord: Option, config: &Config) -> Strin let Some(chord @ (KeyBinding::CtrlO | KeyBinding::AltO)) = chord else { return cleanup.into(); }; - if config.key_binding_behaviors().is_empty() || shell == Shell::Nu { + if config.key_binding_behaviors().is_empty() { return cleanup.into(); } + if shell == Shell::Nu { + return format!( + "{cleanup}\n{}", + crate::key_bindings_nu::render(chord, config) + ); + } let history = config .key_binding_behaviors() .contains(&KeyBindingBehavior::Cj); @@ -55,7 +61,7 @@ if ($__cj_previous_chord -and (Get-Command Get-PSReadLineKeyHandler -ErrorAction Remove-Variable -Name __cj_bound_chord -Scope Global -ErrorAction SilentlyContinue Remove-Variable -Name __cj_previous_chord, __cj_previous_handler -ErrorAction SilentlyContinue"# } - Shell::Nu => "", + Shell::Nu => crate::key_bindings_nu::cleanup(), } } diff --git a/src/key_bindings_nu.rs b/src/key_bindings_nu.rs new file mode 100644 index 0000000..17febd8 --- /dev/null +++ b/src/key_bindings_nu.rs @@ -0,0 +1,153 @@ +//! Nushell's host-command event runs the picker in the foreground while keeping +//! the pending command in Reedline, without executing or recording that command. +use crate::config::{Config, KeyBinding, KeyBindingBehavior}; + +pub fn cleanup() -> &'static str { + r#"export-env { + $env.__cj_history = [] + $env.__cj_key_cycle = null + $env.__cj_key_resume = false + $env.config.keybindings = ($env.config.keybindings | where name != 'cj-directory') +}"# +} + +pub fn render(chord: KeyBinding, config: &Config) -> String { + let behaviors = config + .key_binding_behaviors() + .iter() + .map(|behavior| match behavior { + KeyBindingBehavior::Zoxide => "'zoxide'", + KeyBindingBehavior::Cj => "'cj'", + }) + .collect::>() + .join(" "); + let zoxide = quote(&config.programs.zoxide.to_string_lossy()); + let fzf = quote(&config.programs.fzf.to_string_lossy()); + // Substitute only template tokens, never tokens inside configured paths. + SOURCE + .split('@') + .map(|part| match part { + "BEHAVIORS" => behaviors.as_str(), + "ZOXIDE" => zoxide.as_str(), + "FZF" => fzf.as_str(), + "MODIFIER" if chord == KeyBinding::CtrlO => "control", + "MODIFIER" => "alt", + text => text, + }) + .collect() +} + +fn quote(value: &str) -> String { + let mut hashes = "#".to_owned(); + while value.contains(&format!("'{hashes}")) { + hashes.push('#'); + } + format!("r{hashes}'{value}'{hashes}") +} + +const SOURCE: &str = r#"def _cj-key-quote [value: string] { + mut hashes = '#' + while ($value | str contains ("'" + $hashes)) { $hashes += '#' } + 'r' + $hashes + "'" + $value + "'" + $hashes +} + +def _cj-key-append [buffer: string, target: string] { + let path = if ($target | str starts-with '-') { './' + $target } else { $target } + let separator = if ($buffer | is-empty) or ($buffer =~ '\s$') { '' } else { ' ' } + $buffer + $separator + (_cj-key-quote $path) +} + +def --env _cj-key-history [buffer: string, cursor: int] { + mut cycle = ($env.__cj_key_cycle? | default null) + if ($cycle == null) or ($buffer != $cycle.last_buffer) or ($cursor != $cycle.last_cursor) { + $env.__cj_key_cycle = null + let items = ($env.__cj_history? | default []) + if ($items | is-empty) { return null } + $cycle = {original_buffer: $buffer, original_cursor: $cursor, + last_buffer: $buffer, last_cursor: $cursor, + items: $items, next: (($items | length) - 1)} + } + if $cycle.next < 0 { + $env.__cj_key_cycle = null + return {buffer: $cycle.original_buffer, cursor: $cycle.original_cursor} + } + let completed = (_cj-key-append $cycle.original_buffer ($cycle.items | get $cycle.next)) + let position = ($completed | str length --grapheme-clusters) + $cycle.next = $cycle.next - 1 + $cycle.last_buffer = $completed + $cycle.last_cursor = $position + $env.__cj_key_cycle = $cycle + {buffer: $completed, cursor: $position} +} + +export def --env _cj-key-widget [] { + let buffer = (commandline) + let cursor = (commandline get-cursor) + # Host commands run pre_prompt too. Retain this cycle for that prompt only. + $env.__cj_key_resume = true + let behaviors = [@BEHAVIORS@] + let cycle = ($env.__cj_key_cycle? | default null) + if ($cycle != null) and ($buffer == $cycle.last_buffer) and ($cursor == $cycle.last_cursor) { + let result = (_cj-key-history $buffer $cursor) + commandline edit --replace $result.buffer + commandline set-cursor $result.cursor + return + } + $env.__cj_key_cycle = null + mut missing = '' + mut empty = 'no matching directories' + for behavior in $behaviors { + if $behavior == 'cj' { + let result = (_cj-key-history $buffer $cursor) + if $result != null { + commandline edit --replace $result.buffer + commandline set-cursor $result.cursor + return + } + $empty = 'no directory history' + continue + } + # Decode explicitly: implicit external string capture trims a final LF. + # Keep stderr attached to the terminal while the picker is foreground. + let reply = (try { + ^cj --internal-key-binding-zoxide @ZOXIDE@ @FZF@ | into binary | decode utf-8 + } catch { null }) + if $reply == null { return } + if ($reply | str starts-with "selected\n") { + let target = ($reply | str substring 9..) + let completed = (_cj-key-append $buffer $target) + commandline edit --replace $completed + commandline set-cursor --end + return + } + if ($reply | str starts-with "unavailable\n") { + $missing = ($reply | str substring 12..) + } else if $reply == "cancelled\n" { + return + } else if $reply != "empty\n" { + print --stderr 'cj: invalid interactive zoxide response' + return + } + } + print --stderr ('cj: ' + (if ($missing | is-empty) { $empty } else { $missing })) +} + +export-env { + $env.__cj_history = [] + $env.__cj_key_cycle = null + $env.__cj_key_resume = false + if not ($env.__cj_key_prompt_hook? | default false) { + $env.config.hooks.pre_prompt ++= [{|| + if ($env.__cj_key_resume? | default false) { + $env.__cj_key_resume = false + } else { + $env.__cj_key_cycle = null + } + }] + $env.__cj_key_prompt_hook = true + } + $env.config.keybindings = ($env.config.keybindings | where name != 'cj-directory') + $env.config.keybindings ++= [{name: cj-directory, modifier: @MODIFIER@, + keycode: char_o, mode: [emacs vi_insert vi_normal], + event: {send: executehostcommand, cmd: '_cj-key-widget'}}] +}"#; diff --git a/src/key_zoxide.rs b/src/key_zoxide.rs index 917a5f3..f3605af 100644 --- a/src/key_zoxide.rs +++ b/src/key_zoxide.rs @@ -112,7 +112,9 @@ impl FzfAlias { let id = NEXT.fetch_add(1, Ordering::Relaxed); let directory = std::env::temp_dir().join(format!("cj-fzf-{}-{id}", std::process::id())); - let mut builder = std::fs::DirBuilder::new(); + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + let mut builder = builder; #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; diff --git a/src/main.rs b/src/main.rs index 1af887f..7daf8e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod completions; mod config; mod config_init; mod key_bindings; +mod key_bindings_nu; mod key_history; mod key_zoxide; mod mounts; diff --git a/src/shell.rs b/src/shell.rs index 06ad79d..3737a40 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -11,7 +11,12 @@ pub fn render( ) -> Result { match shell { Shell::Bash | Shell::Zsh => render_posix(shell, config_path, binding, config), - Shell::Nu => render_nu(config_path, config), + Shell::Nu => render_nu(config_path, config).map(|wrapper| { + format!( + "{wrapper}\n{}", + crate::key_bindings::render(shell, binding, config) + ) + }), Shell::Pwsh => render_powershell(config_path, binding, config), } } @@ -258,11 +263,7 @@ fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result>() .join(", "); Ok(format!( - r#"export-env {{ - $env.__cj_history = [] -}} - -def _cj-is-repeated [value: string, ticker: string] {{ + r#"def _cj-is-repeated [value: string, ticker: string] {{ ($value | str length) > 0 and (($value | split chars | all {{ |char| $char == $ticker }})) }} diff --git a/tests/key_binding_powershell.rs b/tests/key_binding_powershell.rs new file mode 100644 index 0000000..d22a6ec --- /dev/null +++ b/tests/key_binding_powershell.rs @@ -0,0 +1,202 @@ +#![cfg(unix)] + +mod support; + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use support::{TempDir, assert_success, cj, write_executable}; + +#[test] +fn real_psreadline_keypresses_preserve_buffers_history_and_cwd() { + let pwsh = env::var_os("CJ_TEST_PWSH").unwrap_or_else(|| "pwsh".into()); + if Command::new(&pwsh).arg("-Version").output().is_err() + || Command::new("zsh").arg("--version").output().is_err() + { + assert!(env::var_os("CJ_REQUIRE_SHELLS").is_none()); + return; + } + for (behaviors, picker_status, candidates, snapshots) in [ + ("'zoxide', 'cj'", "0", "/candidate", 1), + ("'cj', 'zoxide'", "0", "/candidate", 3), + ("'zoxide', 'cj'", "130", "/candidate", 1), + ("'zoxide', 'cj'", "7", "/candidate", 1), + ("'zoxide', 'cj'", "0", "", 3), + ] { + let temp = TempDir::new("psreadline-雪"); + let zoxide = temp.path().join("custom zoxide's executable"); + let fzf = temp.path().join("custom fzf's executable"); + write_executable( + &zoxide, + r#" +case "$*" in + 'query --list') printf '%s' "$CJ_TEST_CANDIDATES" ;; + 'query --interactive') printf 'called\n' >> "$CJ_TEST_CALLS"; fzf ;; + *) exit 2 ;; +esac +"#, + ); + write_executable( + &fzf, + r#" +[ -t 0 ] || { echo 'picker requires a foreground terminal' >&2; exit 2; } +[ -t 2 ] || { echo 'picker stderr requires a foreground terminal' >&2; exit 2; } +printf '%s\n' "$CJ_TEST_PICK" +if [ "$CJ_TEST_PICK_STATUS" = 7 ]; then echo 'picker exploded' >&2; fi +exit "$CJ_TEST_PICK_STATUS" +"#, + ); + let config = temp.path().join("config.toml"); + fs::write(&config, format!( + "[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nlinux = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\n", + serde_json::to_string(zoxide.to_str().unwrap()).unwrap(), + serde_json::to_string(fzf.to_str().unwrap()).unwrap(), + )).unwrap(); + let generated = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", "powershell", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + let source = temp.path().join("editor.ps1"); + let mut script = String::from( + "Import-Module PSReadLine\n$ErrorActionPreference = 'Stop'\nSet-PSReadLineOption -EditMode Emacs -PredictionSource None -HistorySaveStyle SaveNothing\n", + ); + script.push_str(std::str::from_utf8(&generated.stdout).unwrap()); + script.push_str(r#" +$global:__cj_history = @($env:CJ_TEST_OLDER, $env:CJ_TEST_NEWER) +$global:__cj_down_route = '/unchanged/down/route' +$global:__cj_test_records = 0 +# Only this independent observation chord is test-owned. Ctrl+o stays bound to +# the generated handler and is delivered through the terminal event loop. +Set-PSReadLineKeyHandler -Chord 'Ctrl+g' -ScriptBlock { + $buffer = ''; $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$buffer, [ref]$cursor) + $record = @{ Buffer = $buffer; Cursor = $cursor; Cwd = (Get-Location).Path; History = @($global:__cj_history); Down = $global:__cj_down_route } + [IO.File]::AppendAllText($env:CJ_TEST_RESULTS, ($record | ConvertTo-Json -Compress) + "`n") + $global:__cj_test_records++ + if ($global:__cj_test_records -eq [int]$env:CJ_TEST_SNAPSHOTS) { + [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() + [Microsoft.PowerShell.PSConsoleReadLine]::Insert('exit') + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } +} +function global:prompt { + [IO.File]::WriteAllText($env:CJ_TEST_READY, 'ready') + 'cj-test> ' +} +"#); + fs::write(&source, script).unwrap(); + let launcher = temp.path().join("launch-pwsh"); + write_executable( + &launcher, + r#" +stty rows 40 cols 200 +exec "$CJ_TEST_PWSH" -NoLogo -NoProfile -NoExit -File "$CJ_TEST_SCRIPT" +"#, + ); + let results = temp.path().join("results.jsonl"); + let older = temp.path().join("older 'quoted' 雪"); + let newer = temp.path().join("-newer folder"); + let target = "/selected 'quote' 雪/‘’/$literal;$(no-execution)\nlast\n\n"; + let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let output = Command::new("zsh") + .args([ + "-fc", + r#" +zmodload zsh/zpty || exit 1 +zpty -b cj_editor "$CJ_TEST_LAUNCHER" || exit 1 +sent=0 +for attempt in {1..400}; do + while zpty -r cj_editor output; do + print -rn -- "$output" + # .NET Console requests a cursor report while PSReadLine initializes. + [[ "$output" == *$'\e[6n'* ]] && zpty -w -n cj_editor $'\e[1;1R' + done + if [[ -f "$CJ_TEST_READY" && $sent == 0 ]]; then + sleep 0.1 + zpty -w -n cj_editor 'code ' + for count in {1..$CJ_TEST_SNAPSHOTS}; do zpty -w -n cj_editor $'\x0f\x07'; done + sent=1 + fi + zpty -t cj_editor || break + sleep 0.025 +done +timed_out=0 +zpty -t cj_editor && timed_out=1 +while zpty -r cj_editor output; do print -rn -- "$output"; done +zpty -d cj_editor +exit $timed_out +"#, + ]) + .current_dir(temp.path()) + .env("PATH", env::join_paths(paths).unwrap()) + .env("TERM", "xterm-256color") + .env("CJ_TEST_PWSH", &pwsh) + .env("CJ_TEST_SCRIPT", &source) + .env("CJ_TEST_LAUNCHER", &launcher) + .env("CJ_TEST_RESULTS", &results) + .env("CJ_TEST_READY", temp.path().join("ready")) + .env("CJ_TEST_CALLS", temp.path().join("calls")) + .env("CJ_TEST_SNAPSHOTS", snapshots.to_string()) + .env("CJ_TEST_OLDER", &older) + .env("CJ_TEST_NEWER", &newer) + .env("CJ_TEST_PICK", target) + .env("CJ_TEST_PICK_STATUS", picker_status) + .env("CJ_TEST_CANDIDATES", candidates) + .output() + .unwrap(); + assert_success(&output); + let records: Vec = fs::read_to_string(&results) + .unwrap_or_else(|error| panic!("{error}: {}", String::from_utf8_lossy(&output.stdout))) + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), snapshots); + for (index, record) in records.iter().enumerate() { + let chosen = if snapshots == 3 { + match index { + 0 => Some(newer.to_str().unwrap()), + 1 => Some(older.to_str().unwrap()), + _ => None, + } + } else if picker_status == "0" { + Some(target) + } else { + None + }; + let expected = chosen.map_or_else( + || "code ".into(), + |path| { + format!( + "code '{}'", + path.replace('\'', "''") + .replace('‘', "‘‘") + .replace('’', "’’") + ) + }, + ); + assert_eq!(record["Buffer"], expected, "{behaviors}, {picker_status}"); + assert_eq!(record["Cursor"], expected.encode_utf16().count()); + assert_eq!(record["Cwd"], temp.path().to_str().unwrap()); + assert_eq!( + record["History"], + serde_json::json!([older.to_str().unwrap(), newer.to_str().unwrap()]) + ); + assert_eq!(record["Down"], "/unchanged/down/route"); + } + assert_eq!(temp.path().join("calls").exists(), snapshots == 1); + if picker_status == "7" { + assert!(String::from_utf8_lossy(&output.stdout).contains("picker exploded")); + assert!( + String::from_utf8_lossy(&output.stdout).contains("interactive zoxide query failed"), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + } +} From 9058d256062711823d6cf2f6f5f28c807cc7ed5e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:32:52 +0800 Subject: [PATCH 7/8] test(shell): add real Nushell Reedline PTY regressions Verify foreground terminal ownership, literal multiline paths, history cycling, and restoration of the original Unicode cursor position. Cover cancellation and error preservation, including fallback behavior. Refs #45 Refs #46 --- tests/key_binding_nu.rs | 177 ++++++++++++++++++++++++++++ tests/support/nu_key_binding_pty.py | 114 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 tests/key_binding_nu.rs create mode 100644 tests/support/nu_key_binding_pty.py diff --git a/tests/key_binding_nu.rs b/tests/key_binding_nu.rs new file mode 100644 index 0000000..1cc3d27 --- /dev/null +++ b/tests/key_binding_nu.rs @@ -0,0 +1,177 @@ +#![cfg(unix)] + +mod support; + +use std::fs; +use std::path::Path; +use std::process::Command; + +use serde_json::{Value, json}; +use support::{TempDir, assert_success, cj, write_executable}; + +fn available() -> bool { + let available = Command::new("nu").arg("--version").output().is_ok(); + assert!(available || std::env::var_os("CJ_REQUIRE_SHELLS").is_none()); + available +} + +fn run(root: &Path, behaviors: &str, history: Value, actions: Value) -> Vec { + let config = root.join("config.toml"); + let zoxide = toml_path(&root.join("zoxide")); + let fzf = toml_path(&root.join("picker")); + fs::write(&config, format!( + "[programs]\nzoxide = '{zoxide}'\nfzf = '{fzf}'\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nlinux = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nwindows = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\n" + )).unwrap(); + let generated = cj(root, root) + .arg("-C") + .arg(&config) + .args(["init", "nu", "--setup-key-binding"]) + .output() + .unwrap(); + assert_success(&generated); + fs::write(root.join("init.nu"), generated.stdout).unwrap(); + fs::write( + root.join("actions.json"), + json!({"history": history, "actions": actions}).to_string(), + ) + .unwrap(); + let binary = Path::new(env!("CARGO_BIN_EXE_cj")); + let mut paths = vec![binary.parent().unwrap().to_owned()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let output = Command::new("python3") + .arg("-c") + .arg(include_str!("support/nu_key_binding_pty.py")) + .arg(root) + .env("PATH", std::env::join_paths(paths).unwrap()) + .output() + .unwrap(); + assert_success(&output); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn toml_path(path: &Path) -> String { + assert!(!path.to_string_lossy().contains('\'')); + path.to_string_lossy().into_owned() +} + +#[test] +fn nushell_history_widget_cycles_and_cancels_in_reedline() { + if !available() { + return; + } + let temp = TempDir::new("nu-history-widget"); + let history = json!(["/old path's", "/new雪"]); + let rows = run( + temp.path(), + "'cj'", + history.clone(), + json!([ + {"send": "echo é\u{1b}[D\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{f}", "record": true}, + {"send": "\u{3}"}, + {"send": "echo é\u{1b}[D\u{f}", "record": true}, + {"send": "X\u{f}", "record": true} + ]), + ); + assert_eq!(rows[0]["buffer"], "echo é r#'/new雪'#"); + assert_eq!(rows[1]["buffer"], "echo é r#'/old path's'#"); + assert_eq!(rows[2]["buffer"], "echo é"); + assert_eq!(rows[2]["cursor"], 5); + assert_eq!(rows[3]["buffer"], rows[0]["buffer"]); + assert_eq!(rows[4]["buffer"], rows[0]["buffer"]); + assert_eq!(rows[5]["buffer"], "echo é r#'/new雪'#X r#'/new雪'#"); + for row in rows { + assert_eq!(row["history"], history); + assert_eq!(row["cwd"], temp.path().to_string_lossy().as_ref()); + assert_eq!(row["route"], "/sentinel/down/route"); + } +} + +#[test] +fn nushell_widget_picker_owns_foreground_terminal_and_preserves_literal_path() { + if !available() { + return; + } + let temp = TempDir::new("nu-picker-widget"); + write_executable( + &temp.path().join("zoxide"), + r#"if [ "$2" = --list ]; then printf '/candidate\n'; else exec fzf; fi"#, + ); + let target = "/a path's '$()雪\nend\n"; + let picker = format!( + "exec python3 -c {}", + shell_quote(&format!( + "import os,sys; fd=os.open('/dev/tty',os.O_RDWR); assert os.tcgetpgrp(fd)==os.getpgrp(); assert os.isatty(2); sys.stdout.write({target:?}+'\\n')" + )) + ); + write_executable(&temp.path().join("picker"), &picker); + let rows = run( + temp.path(), + "'zoxide', 'cj'", + json!(["/fallback"]), + json!([ + {"send":"echo base\u{f}","record":true} + ]), + ); + assert_eq!(rows[0]["buffer"], format!("echo base r#'{target}'#")); + assert_eq!(rows[0]["cwd"], temp.path().to_string_lossy().as_ref()); + assert_eq!(rows[0]["history"], json!(["/fallback"])); +} + +#[test] +fn nushell_widget_empty_missing_cancel_and_failure_preserve_buffer() { + if !available() { + return; + } + for (label, script, behaviors, expected) in [ + ("empty-history", None, "'cj'", "echo base"), + ( + "missing-fallback", + None, + "'zoxide', 'cj'", + "echo base r#'/fallback'#", + ), + ( + "empty-fallback", + Some("exit 0"), + "'zoxide', 'cj'", + "echo base r#'/fallback'#", + ), + ( + "cancel", + Some("if [ \"$2\" = --list ]; then printf '/candidate\\n'; else exit 130; fi"), + "'zoxide', 'cj'", + "echo base", + ), + ("failure", Some("exit 2"), "'zoxide', 'cj'", "echo base"), + ] { + let temp = TempDir::new(&format!("nu-widget-{label}")); + if let Some(script) = script { + write_executable(&temp.path().join("zoxide"), script); + write_executable(&temp.path().join("picker"), "exit 0"); + } + let history = if label == "empty-history" { + json!([]) + } else { + json!(["/fallback"]) + }; + let rows = run( + temp.path(), + behaviors, + history.clone(), + json!([ + {"send":"echo base\u{f}","record":true} + ]), + ); + assert_eq!(rows[0]["buffer"], expected, "{label}"); + assert_eq!(rows[0]["history"], history, "{label}"); + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/tests/support/nu_key_binding_pty.py b/tests/support/nu_key_binding_pty.py new file mode 100644 index 0000000..8f0cde9 --- /dev/null +++ b/tests/support/nu_key_binding_pty.py @@ -0,0 +1,114 @@ +"""Exercise Nushell's actual Reedline keybinding in an isolated terminal.""" +import fcntl +import json +import os +import pathlib +import pty +import select +import signal +import struct +import sys +import termios +import time + +root = pathlib.Path(sys.argv[1]) +actions = json.loads((root / "actions.json").read_text()) +records = root / "records.jsonl" +records.unlink(missing_ok=True) +config = root / "terminal.nu" + + +def quote(value): + hashes = "#" + while "'" + hashes in str(value): + hashes += "#" + return "r" + hashes + "'" + str(value) + "'" + hashes + + +config.write_text( + f"""use {quote(root / 'init.nu')} * +if '__cj_history' not-in ($env | columns) {{ error make {{msg: 'cj history was not initialized'}} }} +$env.config.show_banner = false +$env.config.shell_integration.osc133 = false +$env.config.edit_mode = 'emacs' +$env.PROMPT_COMMAND = {{|| 'CJPTY> '}} +$env.PROMPT_COMMAND_RIGHT = {{|| ''}} +$env.PROMPT_INDICATOR = '' +$env.__cj_history = {json.dumps(actions['history'], ensure_ascii=False)} +$env.CJ_INTERNAL_DOWN_ROUTE = '/sentinel/down/route' +def --env _cj-test-widget [] {{ + _cj-key-widget + let row = {{buffer: (commandline), cursor: (commandline get-cursor), + history: $env.__cj_history, cwd: $env.PWD, route: $env.CJ_INTERNAL_DOWN_ROUTE}} + (($row | to json -r) + (char nl)) | save --append {quote(records)} +}} +$env.config.keybindings ++= [{{name: cj-test, modifier: control, keycode: char_o, + mode: emacs, event: {{send: executehostcommand, cmd: '_cj-test-widget'}}}}] +""" +) + +pid, fd = pty.fork() +if pid == 0: + os.environ["TERM"] = "xterm-256color" + os.environ["XDG_CONFIG_HOME"] = str(root / "xdg") + os.environ["HOME"] = str(root) + os.chdir(root) + os.execvp("nu", ["nu", "--no-history", "--config", str(config)]) + +fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 150, 0, 0)) +transcript = bytearray() + + +def pump(): + if select.select([fd], [], [], 0.02)[0]: + try: + data = os.read(fd, 65536) + except OSError: + return + transcript.extend(data) + # Reedline requests cursor position while repainting after host commands. + if b"\x1b[6n" in data: + os.write(fd, b"\x1b[1;1R") + + +def wait_until(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + pump() + if predicate(): + return + raise AssertionError(transcript.decode(errors="replace")) + + +def rows(): + if not records.exists(): + return [] + try: + return [json.loads(line) for line in records.read_text().splitlines()] + except (json.JSONDecodeError, UnicodeDecodeError): + return [] + + +try: + wait_until(lambda: b"CJPTY> " in transcript) + count = 0 + for action in actions["actions"]: + before = len(transcript) + os.write(fd, action["send"].encode()) + if action.get("record", False): + count += 1 + wait_until(lambda: len(rows()) >= count) + # Wait for the prompt hook before delivering the next key sequence. + wait_until(lambda: b"\x1b[?2004h" in transcript[before:]) + else: + # Ctrl+C and Enter create another prompt; wait for its redraw. + wait_until(lambda: b"\x1b[?2004h" in transcript[before:]) + print(json.dumps(rows(), ensure_ascii=False), flush=True) +finally: + (root / "terminal.log").write_bytes(transcript) + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(fd) + os.waitpid(pid, 0) From 9237a388dbcb8c9292d07c28390c9ad7f7570db6 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Thu, 10 Sep 2026 07:41:08 +0800 Subject: [PATCH 8/8] test(shell): gate POSIX history completion tests to Unix Avoid invoking the Windows bash.exe WSL stub when no Linux distribution is installed. Keep PowerShell tests enabled on Windows with no production behavior changes. Refs #45 --- src/key_history.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/key_history.rs b/src/key_history.rs index 7c10829..69ebd83 100644 --- a/src/key_history.rs +++ b/src/key_history.rs @@ -140,6 +140,8 @@ mod tests { ); } + // Windows may expose a bash.exe WSL launcher without an installed distribution. + #[cfg(unix)] #[test] fn posix_history_completion_is_reversible_and_read_only() { for shell in [Shell::Bash, Shell::Zsh] { @@ -174,6 +176,7 @@ _cj_key_history "$_cj_key_buffer" "$_cj_key_cursor" } } + #[cfg(unix)] #[test] fn posix_edit_cursor_change_cancel_and_reinit_restart_history_cycle() { for shell in [Shell::Bash, Shell::Zsh] { @@ -205,6 +208,7 @@ _cj_key_history 'cd ' 3 } } + #[cfg(unix)] #[test] fn posix_history_quotes_special_paths_and_preserves_multiline_buffer() { for shell in [Shell::Bash, Shell::Zsh] {