commit ed801c80ee30041bf9386204a9acb402750f085d Author: Stephen Waits Date: Tue Apr 28 21:22:21 2026 -0600 feat: initial implementation of jjf A TUI revision picker for jujutsu (jj). Wraps any jj subcommand with an inline fuzzy-finder over jj log, then forwards the picked revision(s) as -r '' to the underlying jj invocation. Features: - Inline viewport (no full-screen alt-screen takeover) - Smart prefix matching: typing the bold portion of a change ID jumps to that revision instantly - Multi-select via Tab; emitted as -r 'A|B|C' revset union - Preflight detection of subcommands that don't accept -r - Optional shell integration (jjf init ) that rewrites shell history so up-arrow recalls the resolved jj command Hand-rolled ANSI rendering to /dev/tty; only crossterm, nucleo-matcher, anyhow, and libc as runtime dependencies. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad6a6c3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,356 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "jjf" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "libc", + "nucleo-matcher", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..15bd4c1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "jjf" +version = "0.1.0" +edition = "2024" + +[dependencies] +crossterm = "0.29" +nucleo-matcher = "0.3" +anyhow = "1" +libc = "0.2" + +[profile.release] +lto = "thin" +strip = true diff --git a/src/init/bash.sh b/src/init/bash.sh new file mode 100644 index 0000000..431b1da --- /dev/null +++ b/src/init/bash.sh @@ -0,0 +1,18 @@ +jjf() { + # Meta-commands and bare flags bypass the picker and run the binary directly. + if [ $# -eq 0 ] || [ "$1" = "init" ] || [ "${1#-}" != "$1" ]; then + command jjf "$@" + return $? + fi + local _jjf_cmd + _jjf_cmd="$(command jjf --emit "$@")" || return $? + [ -n "$_jjf_cmd" ] || return 130 + # Replace the original `jjf …` history entry with the resolved jj command. + local _jjf_last_offset + _jjf_last_offset=$(history 1 2>/dev/null | awk '{print $1}') + if [ -n "$_jjf_last_offset" ]; then + history -d "$_jjf_last_offset" 2>/dev/null || true + fi + history -s -- "$_jjf_cmd" + sh -c "$_jjf_cmd" +} diff --git a/src/init/fish.fish b/src/init/fish.fish new file mode 100644 index 0000000..5c28553 --- /dev/null +++ b/src/init/fish.fish @@ -0,0 +1,32 @@ +function jjf + set -e _jjf_pending_swap + # Meta-commands and bare flags bypass the picker and run the binary directly. + if test (count $argv) -eq 0 + command jjf + return $status + end + if test "$argv[1]" = init; or string match -q -- '-*' $argv[1] + command jjf $argv + return $status + end + set -l _jjf_cmd (command jjf --emit $argv) + set -l _jjf_status $status + if test $_jjf_status -ne 0 + return $_jjf_status + end + if test -z "$_jjf_cmd" + return 130 + end + builtin history append -- $_jjf_cmd + # Signal the postexec hook to remove the literal `jjf …` entry that fish + # is about to record (fish records function invocations *after* return). + set -g _jjf_pending_swap 1 + sh -c $_jjf_cmd +end + +function _jjf_postexec --on-event fish_postexec + if test "$_jjf_pending_swap" = "1" + builtin history delete --exact --case-sensitive -- $argv[1] + set -e _jjf_pending_swap + end +end diff --git a/src/init/mod.rs b/src/init/mod.rs new file mode 100644 index 0000000..4e9c3b8 --- /dev/null +++ b/src/init/mod.rs @@ -0,0 +1,14 @@ +pub const BASH: &str = include_str!("bash.sh"); +pub const ZSH: &str = include_str!("zsh.sh"); +pub const FISH: &str = include_str!("fish.fish"); +pub const NU: &str = include_str!("nu.nu"); + +pub fn snippet(shell: &str) -> Option<&'static str> { + match shell { + "bash" => Some(BASH), + "zsh" => Some(ZSH), + "fish" => Some(FISH), + "nu" | "nushell" => Some(NU), + _ => None, + } +} diff --git a/src/init/nu.nu b/src/init/nu.nu new file mode 100644 index 0000000..140dcbc --- /dev/null +++ b/src/init/nu.nu @@ -0,0 +1,18 @@ +def --env jjf [...args] { + # Meta-commands and bare flags bypass the picker and run the binary directly. + if ($args | is-empty) { + ^jjf + return + } + let first = $args.0 + if $first == "init" or ($first | str starts-with "-") { + ^jjf ...$args + return + } + let result = (^jjf --emit ...$args | complete) + if $result.exit_code != 0 { exit $result.exit_code } + let cmd = ($result.stdout | str trim) + if ($cmd | is-empty) { exit 130 } + [{command: $cmd, start_timestamp: (date now | format date "%+")}] | history import + ^sh -c $cmd +} diff --git a/src/init/zsh.sh b/src/init/zsh.sh new file mode 100644 index 0000000..c9a526f --- /dev/null +++ b/src/init/zsh.sh @@ -0,0 +1,25 @@ +jjf() { + # Meta-commands and bare flags bypass the picker and run the binary directly. + if [ $# -eq 0 ] || [ "$1" = "init" ] || [ "${1#-}" != "$1" ]; then + command jjf "$@" + return $? + fi + local _jjf_cmd + _jjf_cmd="$(command jjf --emit "$@")" || return $? + [ -n "$_jjf_cmd" ] || return 130 + print -s -- "$_jjf_cmd" + sh -c "$_jjf_cmd" +} + +# Suppress zsh from recording `jjf …` invocations; jjf appends the resolved +# jj command itself via `print -s`. +autoload -Uz add-zsh-hook 2>/dev/null +_jjf_zshaddhistory() { + case "$1" in + jjf\ *|jjf$'\n'*) return 1 ;; + esac + return 0 +} +if typeset -f add-zsh-hook >/dev/null; then + add-zsh-hook zshaddhistory _jjf_zshaddhistory +fi diff --git a/src/jj.rs b/src/jj.rs new file mode 100644 index 0000000..ec9320a --- /dev/null +++ b/src/jj.rs @@ -0,0 +1,183 @@ +use std::process::{Command, ExitStatus, Stdio}; + +use anyhow::{Context, Result}; + +const SEP: u8 = 0x1f; + +const TEMPLATE: &str = concat!( + "\"\\x1f\" ++ ", + "change_id.short() ++ \"\\x1f\" ++ ", + "change_id.shortest().prefix() ++ \"\\x1f\" ++ ", + "commit_id.short() ++ \"\\x1f\" ++ ", + "commit_id.shortest().prefix() ++ \"\\x1f\" ++ ", + "builtin_log_oneline", +); + +pub struct Row { + pub change_id_short: String, + pub change_id_prefix: String, + pub commit_id_short: String, + pub commit_id_prefix: String, + pub plain: String, + pub styled: Vec, +} + +pub fn capture_log() -> Result> { + let output = Command::new("jj") + .args([ + "--ignore-working-copy", + "log", + "--color=always", + "--limit", + "1000", + "-T", + TEMPLATE, + ]) + .stderr(Stdio::inherit()) + .output() + .context("failed to spawn jj — is it installed and on PATH?")?; + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + let mut rows = Vec::new(); + for raw in output.stdout.split(|&b| b == b'\n') { + if raw.is_empty() { + continue; + } + if let Some(row) = parse_row(raw) { + rows.push(row); + } + } + Ok(rows) +} + +fn parse_row(bytes: &[u8]) -> Option { + let mut parts: Vec<&[u8]> = Vec::with_capacity(6); + let mut start = 0; + for (i, &b) in bytes.iter().enumerate() { + if b == SEP { + parts.push(&bytes[start..i]); + start = i + 1; + if parts.len() == 5 { + break; + } + } + } + if parts.len() < 5 { + return None; + } + parts.push(&bytes[start..]); + + let graph = parts[0]; + let change_id_short = strip_ansi(parts[1]); + let change_id_prefix = strip_ansi(parts[2]); + let commit_id_short = strip_ansi(parts[3]); + let commit_id_prefix = strip_ansi(parts[4]); + let payload = parts[5]; + + if change_id_short.is_empty() { + return None; + } + + let mut styled = Vec::with_capacity(graph.len() + payload.len()); + styled.extend_from_slice(graph); + styled.extend_from_slice(payload); + + let plain = strip_ansi(&styled); + + Some(Row { + change_id_short, + change_id_prefix, + commit_id_short, + commit_id_prefix, + plain, + styled, + }) +} + +/// Strip CSI escape sequences (ESC `[` … letter) from a byte slice. +/// Non-CSI bytes pass through; result is decoded as UTF-8 (lossy) so the +/// graph chrome (`@`, `○`, `◆`) survives. +pub fn strip_ansi(bytes: &[u8]) -> String { + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') { + i += 2; + while i < bytes.len() && !bytes[i].is_ascii_alphabetic() { + i += 1; + } + if i < bytes.len() { + i += 1; + } + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn supports_revisions(subcommand: &str) -> Result { + let output = Command::new("jj") + .arg(subcommand) + .arg("--help") + .output() + .context("failed to spawn jj for preflight")?; + if !output.status.success() { + // unknown subcommand or other error — let the main flow surface jj's error + return Ok(true); + } + let help = String::from_utf8_lossy(&output.stdout); + Ok(help.contains("REVSET")) +} + +pub fn exec(subcommand: &str, passthrough: &[String], ids: &[String]) -> Result { + let revset = ids.join("|"); + let status = Command::new("jj") + .arg(subcommand) + .args(passthrough) + .arg("-r") + .arg(&revset) + .status() + .context("failed to spawn jj")?; + Ok(status) +} + +pub fn command_line(subcommand: &str, passthrough: &[String], ids: &[String]) -> String { + let mut parts: Vec = Vec::with_capacity(4 + passthrough.len()); + parts.push("jj".into()); + parts.push(subcommand.into()); + for a in passthrough { + parts.push(shell_quote(a)); + } + if !ids.is_empty() { + parts.push("-r".into()); + parts.push(shell_quote(&ids.join("|"))); + } + parts.join(" ") +} + +pub fn shell_quote(s: &str) -> String { + if s.is_empty() { + return "''".into(); + } + if s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-./=:@,".contains(&b)) + { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push_str(r"'\''"); + } else { + out.push(c); + } + } + out.push('\''); + out +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..73a5b80 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,153 @@ +mod init; +mod jj; +mod tui; + +use std::process::ExitCode; + +use anyhow::{Context, Result}; + +fn main() -> ExitCode { + match real_main() { + Ok(code) => ExitCode::from(code), + Err(e) => { + eprintln!("jjf: {e:#}"); + ExitCode::from(1) + } + } +} + +fn real_main() -> Result { + let mut args = std::env::args().skip(1); + let Some(first) = args.next() else { + usage(); + return Ok(2); + }; + + match first.as_str() { + "init" => run_init(args), + "--emit" => match args.next() { + Some(sub) => { + let passthrough: Vec = args.collect(); + run_pick(&sub, &passthrough, /* emit = */ true) + } + None => { + eprintln!("usage: jjf --emit [args...]"); + Ok(2) + } + }, + sub => { + let passthrough: Vec = args.collect(); + run_pick(sub, &passthrough, /* emit = */ false) + } + } +} + +fn usage() { + eprintln!("usage: jjf [args...]"); + eprintln!(" jjf --emit [args...] (print resolved command, don't run)"); + eprintln!(" jjf init (print shell wrapper)"); +} + +fn run_init(mut args: impl Iterator) -> Result { + let Some(shell) = args.next() else { + eprintln!("usage: jjf init "); + return Ok(2); + }; + match init::snippet(&shell) { + Some(s) => { + print!("{s}"); + Ok(0) + } + None => { + eprintln!("jjf: unknown shell '{shell}' — supported: bash, zsh, fish, nu"); + Ok(2) + } + } +} + +fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result { + if !jj::supports_revisions(subcommand)? { + eprintln!( + "jjf: 'jj {subcommand}' doesn't take revisions — run 'jj {subcommand}' directly." + ); + return Ok(2); + } + + let rows = jj::capture_log()?; + if rows.is_empty() { + eprintln!("jjf: no revisions in default revset"); + return Ok(0); + } + + // In --emit mode, fish/bash/zsh/nu wrappers capture our stdout via $(...). + // crossterm's cursor::position() writes its DSR query to stdout; if stdout is + // a pipe, the query never reaches the terminal and ratatui times out. Redirect + // fd 1 to /dev/tty for the picker phase, then restore it before printing. + let saved_stdout = if emit { + Some(redirect_stdout_to_tty()?) + } else { + None + }; + + let tui_result = tui::run(rows); + + if let Some(saved) = saved_stdout { + restore_stdout(saved)?; + } + + let Some(ids) = tui_result? else { + return Ok(130); + }; + if ids.is_empty() { + return Ok(130); + } + + if emit { + let cmd = jj::command_line(subcommand, passthrough, &ids); + println!("{cmd}"); + Ok(0) + } else { + echo_command(subcommand, passthrough, &ids); + let status = jj::exec(subcommand, passthrough, &ids)?; + Ok(status.code().unwrap_or(1) as u8) + } +} + +#[cfg(unix)] +fn redirect_stdout_to_tty() -> Result { + use std::os::fd::AsRawFd; + let saved = unsafe { libc::dup(libc::STDOUT_FILENO) }; + if saved < 0 { + return Err(std::io::Error::last_os_error()).context("dup stdout"); + } + let tty = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .context("open /dev/tty for stdout redirect")?; + if unsafe { libc::dup2(tty.as_raw_fd(), libc::STDOUT_FILENO) } < 0 { + unsafe { libc::close(saved) }; + return Err(std::io::Error::last_os_error()).context("dup2 tty -> stdout"); + } + Ok(saved) +} + +#[cfg(unix)] +fn restore_stdout(saved: libc::c_int) -> Result<()> { + if unsafe { libc::dup2(saved, libc::STDOUT_FILENO) } < 0 { + unsafe { libc::close(saved) }; + return Err(std::io::Error::last_os_error()).context("dup2 saved -> stdout"); + } + unsafe { libc::close(saved) }; + Ok(()) +} + +fn echo_command(subcommand: &str, passthrough: &[String], ids: &[String]) { + use std::io::IsTerminal; + let cmd = jj::command_line(subcommand, passthrough, ids); + if std::io::stdout().is_terminal() { + println!("\x1b[2m$\x1b[0m {cmd}"); + } else { + println!("{cmd}"); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..8ae15b6 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,511 @@ +use std::collections::HashSet; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; + +use crate::jj::Row; + +// SGR constants +const SGR_RESET: &[u8] = b"\x1b[0m"; +const SGR_RESET_SHORT: &[u8] = b"\x1b[m"; +const SGR_DIM: &[u8] = b"\x1b[2m"; +const SGR_BOLD: &[u8] = b"\x1b[1m"; +const SGR_FG_CYAN: &[u8] = b"\x1b[36m"; +const SGR_FG_YELLOW: &[u8] = b"\x1b[33m"; +const SGR_BG_CURSOR: &[u8] = b"\x1b[48;5;237m"; +const ERASE_TO_EOL: &[u8] = b"\x1b[K"; +const ERASE_TO_END: &[u8] = b"\x1b[J"; +const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; +const HIDE_CURSOR: &[u8] = b"\x1b[?25l"; + +struct App { + rows: Vec, + filter: String, + cursor: usize, + view_offset: usize, + selected: HashSet, + filtered: Vec, + matcher: Matcher, + last_height: usize, +} + +impl App { + fn new(rows: Vec) -> Self { + let n = rows.len(); + Self { + rows, + filter: String::new(), + cursor: 0, + view_offset: 0, + selected: HashSet::new(), + filtered: (0..n).collect(), + matcher: Matcher::new(Config::DEFAULT), + last_height: 0, + } + } + + fn refilter(&mut self) { + if self.filter.is_empty() { + self.filtered = (0..self.rows.len()).collect(); + } else { + let App { + rows, + matcher, + filter, + .. + } = self; + let mut hbuf: Vec = Vec::new(); + let mut nbuf: Vec = Vec::new(); + let q_lower = filter.to_lowercase(); + let mut scored: Vec<(usize, u32)> = rows + .iter() + .enumerate() + .filter_map(|(i, row)| { + score_row(row, filter, &q_lower, matcher, &mut hbuf, &mut nbuf).map(|s| (i, s)) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + self.filtered = scored.into_iter().map(|(i, _)| i).collect(); + } + self.cursor = 0; + self.view_offset = 0; + } + + fn move_cursor(&mut self, delta: isize) { + let len = self.filtered.len(); + if len == 0 { + return; + } + let mut c = self.cursor as isize + delta; + if c < 0 { + c = 0; + } + let max = len as isize - 1; + if c > max { + c = max; + } + self.cursor = c as usize; + self.adjust_view(); + } + + fn jump_top(&mut self) { + self.cursor = 0; + self.adjust_view(); + } + + fn jump_bottom(&mut self) { + if !self.filtered.is_empty() { + self.cursor = self.filtered.len() - 1; + self.adjust_view(); + } + } + + fn adjust_view(&mut self) { + if self.last_height == 0 { + return; + } + if self.cursor < self.view_offset { + self.view_offset = self.cursor; + } else if self.cursor >= self.view_offset + self.last_height { + self.view_offset = self.cursor + 1 - self.last_height; + } + } + + fn toggle_select(&mut self) { + if let Some(&row_idx) = self.filtered.get(self.cursor) + && !self.selected.insert(row_idx) + { + self.selected.remove(&row_idx); + } + } + + fn pending_ids(&self) -> Vec { + if self.selected.is_empty() { + if let Some(&row_idx) = self.filtered.get(self.cursor) { + return vec![short_id(&self.rows[row_idx])]; + } + return Vec::new(); + } + let mut indices: Vec = self.selected.iter().copied().collect(); + indices.sort_unstable(); + indices + .into_iter() + .map(|i| short_id(&self.rows[i])) + .collect() + } +} + +fn short_id(row: &Row) -> String { + if row.change_id_prefix.is_empty() { + row.change_id_short.clone() + } else { + row.change_id_prefix.clone() + } +} + +fn score_row( + row: &Row, + query: &str, + q_lower: &str, + matcher: &mut Matcher, + hbuf: &mut Vec, + nbuf: &mut Vec, +) -> Option { + let mut best: u32 = 0; + let mut hit = false; + + let cp = row.change_id_prefix.to_lowercase(); + let cs = row.change_id_short.to_lowercase(); + let kp = row.commit_id_prefix.to_lowercase(); + let ks = row.commit_id_short.to_lowercase(); + + if !cp.is_empty() && cp.starts_with(q_lower) { + best = best.max(1_000_000); + hit = true; + } + if cs.starts_with(q_lower) { + best = best.max(100_000); + hit = true; + } + if !kp.is_empty() && kp.starts_with(q_lower) { + best = best.max(10_000); + hit = true; + } + if ks.starts_with(q_lower) { + best = best.max(1_000); + hit = true; + } + + let h = Utf32Str::new(&row.plain, hbuf); + let n = Utf32Str::new(query, nbuf); + if let Some(score) = matcher.fuzzy_match(h, n) { + best = best.max(score as u32); + hit = true; + } + + if hit { Some(best) } else { None } +} + +pub fn run(rows: Vec) -> Result>> { + install_panic_hook(); + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .context("open /dev/tty")?; + crossterm::terminal::enable_raw_mode().context("enable raw mode")?; + + let mut viewport_y: u16 = 0; + let result = (|| { + let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((80, 24)); + let max_height = (term_rows / 2).clamp(8, 20); + let needed = (rows.len() as u16).saturating_add(2); + let viewport_height = needed.min(max_height).max(4); + + viewport_y = allocate_viewport(&mut tty, viewport_height, term_rows)?; + tty.write_all(HIDE_CURSOR)?; + tty.flush()?; + + run_loop(&mut tty, rows, viewport_y, viewport_height, term_cols) + })(); + + let _ = cleanup_at(&mut tty, viewport_y); + let _ = crossterm::terminal::disable_raw_mode(); + result +} + +fn allocate_viewport(tty: &mut File, viewport_height: u16, term_rows: u16) -> Result { + let (_, mut cursor_row) = crossterm::cursor::position().context("query cursor position")?; + let bottom_excl = cursor_row + viewport_height; + if bottom_excl > term_rows { + let scroll = bottom_excl - term_rows; + for _ in 0..scroll { + tty.write_all(b"\n")?; + } + tty.flush()?; + cursor_row = cursor_row.saturating_sub(scroll); + } + Ok(cursor_row) +} + +fn cleanup_at(tty: &mut File, viewport_y: u16) -> std::io::Result<()> { + write!(tty, "\x1b[{};1H", viewport_y + 1)?; + tty.write_all(ERASE_TO_END)?; + tty.write_all(SHOW_CURSOR)?; + tty.flush() +} + +fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = crossterm::terminal::disable_raw_mode(); + if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") { + let _ = tty.write_all(SHOW_CURSOR); + let _ = tty.flush(); + } + original(info); + })); +} + +fn run_loop( + tty: &mut File, + rows: Vec, + viewport_y: u16, + viewport_height: u16, + term_cols: u16, +) -> Result>> { + let mut app = App::new(rows); + app.last_height = (viewport_height as usize).saturating_sub(2); // input + hint + loop { + render(tty, &app, viewport_y, viewport_height, term_cols)?; + if !event::poll(Duration::from_millis(250)).context("event poll failed")? { + continue; + } + match event::read().context("event read failed")? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(action) = handle_key(&mut app, key) { + return Ok(action); + } + } + Event::Resize(_, _) => { + // Just redraw on next loop iteration; viewport stays the same. + } + _ => {} + } + } +} + +fn handle_key(app: &mut App, key: KeyEvent) -> Option>> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + if ctrl { + match key.code { + KeyCode::Char('c') => return Some(None), + KeyCode::Char('n') | KeyCode::Char('j') => { + app.move_cursor(1); + return None; + } + KeyCode::Char('p') | KeyCode::Char('k') => { + app.move_cursor(-1); + return None; + } + KeyCode::Char('a') => { + app.jump_top(); + return None; + } + KeyCode::Char('e') => { + app.jump_bottom(); + return None; + } + KeyCode::Char('h') => { + app.filter.pop(); + app.refilter(); + return None; + } + KeyCode::Char('u') => { + app.filter.clear(); + app.refilter(); + return None; + } + KeyCode::Char('w') => { + delete_word(&mut app.filter); + app.refilter(); + return None; + } + _ => return None, + } + } + + match key.code { + KeyCode::Esc => Some(None), + KeyCode::Enter => Some(Some(app.pending_ids())), + KeyCode::Tab => { + app.toggle_select(); + None + } + KeyCode::Up => { + app.move_cursor(-1); + None + } + KeyCode::Down => { + app.move_cursor(1); + None + } + KeyCode::PageUp => { + app.move_cursor(-(app.last_height.max(1) as isize)); + None + } + KeyCode::PageDown => { + app.move_cursor(app.last_height.max(1) as isize); + None + } + KeyCode::Home => { + app.jump_top(); + None + } + KeyCode::End => { + app.jump_bottom(); + None + } + KeyCode::Backspace => { + app.filter.pop(); + app.refilter(); + None + } + KeyCode::Char(c) => { + app.filter.push(c); + app.refilter(); + None + } + _ => None, + } +} + +fn delete_word(s: &mut String) { + while s.ends_with(' ') { + s.pop(); + } + while let Some(c) = s.chars().last() { + if c.is_whitespace() { + break; + } + s.pop(); + } +} + +fn render( + tty: &mut File, + app: &App, + viewport_y: u16, + viewport_height: u16, + term_cols: u16, +) -> Result<()> { + let mut buf: Vec = Vec::with_capacity(8 * 1024); + + // Move to viewport top-left. + write!(buf, "\x1b[{};1H", viewport_y + 1)?; + + // Input row. + buf.extend_from_slice(SGR_BOLD); + buf.extend_from_slice(SGR_FG_CYAN); + buf.extend_from_slice("❯ ".as_bytes()); + buf.extend_from_slice(SGR_RESET); + buf.extend_from_slice(app.filter.as_bytes()); + buf.extend_from_slice(SGR_FG_CYAN); + buf.extend_from_slice("█".as_bytes()); + buf.extend_from_slice(SGR_RESET); + buf.extend_from_slice(ERASE_TO_EOL); + buf.extend_from_slice(b"\r\n"); + + // Log rows. + let log_height = app.last_height; + let visible: Vec<(usize, usize)> = app + .filtered + .iter() + .enumerate() + .skip(app.view_offset) + .take(log_height) + .map(|(filt_idx, &row_idx)| (filt_idx, row_idx)) + .collect(); + + for i in 0..log_height { + if let Some(&(filt_idx, row_idx)) = visible.get(i) { + let row = &app.rows[row_idx]; + let is_cursor = filt_idx == app.cursor; + let is_selected = app.selected.contains(&row_idx); + render_row(&mut buf, row, is_cursor, is_selected, term_cols); + } else { + buf.extend_from_slice(ERASE_TO_EOL); + } + buf.extend_from_slice(b"\r\n"); + } + + // Hint row. + let counts = if app.selected.is_empty() { + format!("[{}/{}] ", app.filtered.len(), app.rows.len()) + } else { + format!( + "[{}/{} · {} selected] ", + app.filtered.len(), + app.rows.len(), + app.selected.len() + ) + }; + buf.extend_from_slice(SGR_DIM); + buf.extend_from_slice(counts.as_bytes()); + buf.extend_from_slice( + "type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit".as_bytes(), + ); + buf.extend_from_slice(SGR_RESET); + buf.extend_from_slice(ERASE_TO_EOL); + + // Park cursor at end of input line so terminal doesn't blink in the log. + // Compute input column = column after "❯ " + filter chars + cursor block. + let input_col = 2 + app.filter.chars().count() as u16 + 1; + write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?; + + let _ = viewport_height; // currently unused; reserved for future use + tty.write_all(&buf)?; + tty.flush()?; + Ok(()) +} + +fn render_row(buf: &mut Vec, row: &Row, is_cursor: bool, is_selected: bool, width: u16) { + // Selected marker (always rendered at column 0, two cells) + if is_selected { + buf.extend_from_slice(SGR_BOLD); + buf.extend_from_slice(SGR_FG_YELLOW); + buf.extend_from_slice("▎ ".as_bytes()); + buf.extend_from_slice(SGR_RESET); + } else { + buf.extend_from_slice(b" "); + } + + if is_cursor { + buf.extend_from_slice(SGR_BG_CURSOR); + // Inject bg-restore after every embedded reset so the row's bg persists. + inject_bg_into(buf, &row.styled, SGR_BG_CURSOR); + // Pad to terminal width with bg. + let used_chars = 2 + crate::jj::strip_ansi(&row.styled).chars().count(); + let width_us = width as usize; + if used_chars < width_us { + for _ in 0..(width_us - used_chars) { + buf.push(b' '); + } + } + buf.extend_from_slice(SGR_RESET); + buf.extend_from_slice(ERASE_TO_EOL); + } else { + buf.extend_from_slice(&row.styled); + buf.extend_from_slice(SGR_RESET); + buf.extend_from_slice(ERASE_TO_EOL); + } +} + +/// Copy `src` into `dst`, but after every embedded `ESC [ 0 m` or `ESC [ m` +/// reset, append `bg` so the cursor-row background survives mid-row resets. +fn inject_bg_into(dst: &mut Vec, src: &[u8], bg: &[u8]) { + let mut i = 0; + while i < src.len() { + if src[i] == 0x1b && src.get(i + 1) == Some(&b'[') { + if src[i..].starts_with(SGR_RESET) { + dst.extend_from_slice(SGR_RESET); + dst.extend_from_slice(bg); + i += SGR_RESET.len(); + continue; + } + if src[i..].starts_with(SGR_RESET_SHORT) { + dst.extend_from_slice(SGR_RESET_SHORT); + dst.extend_from_slice(bg); + i += SGR_RESET_SHORT.len(); + continue; + } + } + dst.push(src[i]); + i += 1; + } +}