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 '<id>' 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 <bash|zsh|fish|nu>) 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.
This commit is contained in:
11 files changed
+1325
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+356
@@ -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",
|
||||||
|
]
|
||||||
+14
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_log() -> Result<Vec<Row>> {
|
||||||
|
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<Row> {
|
||||||
|
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<bool> {
|
||||||
|
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<ExitStatus> {
|
||||||
|
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<String> = 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
|
||||||
|
}
|
||||||
+153
@@ -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<u8> {
|
||||||
|
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<String> = args.collect();
|
||||||
|
run_pick(&sub, &passthrough, /* emit = */ true)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!("usage: jjf --emit <jj-subcommand> [args...]");
|
||||||
|
Ok(2)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sub => {
|
||||||
|
let passthrough: Vec<String> = args.collect();
|
||||||
|
run_pick(sub, &passthrough, /* emit = */ false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() {
|
||||||
|
eprintln!("usage: jjf <jj-subcommand> [args...]");
|
||||||
|
eprintln!(" jjf --emit <jj-subcommand> [args...] (print resolved command, don't run)");
|
||||||
|
eprintln!(" jjf init <bash|zsh|fish|nu> (print shell wrapper)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_init(mut args: impl Iterator<Item = String>) -> Result<u8> {
|
||||||
|
let Some(shell) = args.next() else {
|
||||||
|
eprintln!("usage: jjf init <bash|zsh|fish|nu>");
|
||||||
|
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<u8> {
|
||||||
|
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<libc::c_int> {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+511
@@ -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<Row>,
|
||||||
|
filter: String,
|
||||||
|
cursor: usize,
|
||||||
|
view_offset: usize,
|
||||||
|
selected: HashSet<usize>,
|
||||||
|
filtered: Vec<usize>,
|
||||||
|
matcher: Matcher,
|
||||||
|
last_height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(rows: Vec<Row>) -> 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<char> = Vec::new();
|
||||||
|
let mut nbuf: Vec<char> = 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<String> {
|
||||||
|
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<usize> = 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<char>,
|
||||||
|
nbuf: &mut Vec<char>,
|
||||||
|
) -> Option<u32> {
|
||||||
|
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<Row>) -> Result<Option<Vec<String>>> {
|
||||||
|
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<u16> {
|
||||||
|
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<Row>,
|
||||||
|
viewport_y: u16,
|
||||||
|
viewport_height: u16,
|
||||||
|
term_cols: u16,
|
||||||
|
) -> Result<Option<Vec<String>>> {
|
||||||
|
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<Option<Vec<String>>> {
|
||||||
|
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<u8> = 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<u8>, 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<u8>, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user