File lazyjj-0.6.1.obscpio of Package lazyjj
07070100000000000081A400000000000000000000000168C1DE940000B54B000000000000000000000000000000000000001800000000lazyjj-0.6.1/Cargo.lock# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "ansi-to-tui"
version = "7.0.0"
source = "git+https://github.com/Cretezy/ansi-to-tui.git?rev=74bd97e#74bd97e76066186cace33ea04cf497055db67e62"
dependencies = [
"nom",
"ratatui",
"simdutf8",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "anstream"
version = "0.6.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.59.0",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
dependencies = [
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "chrono"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"serde",
"static_assertions",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"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 = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
name = "dyn-clone"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown 0.15.4",
"serde",
]
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "insta"
version = "1.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
dependencies = [
"console",
"once_cell",
"regex",
"similar",
]
[[package]]
name = "instability"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazyjj"
version = "0.6.1"
dependencies = [
"ansi-to-tui",
"anyhow",
"chrono",
"clap",
"insta",
"itertools 0.14.0",
"ratatui",
"regex",
"serde",
"serde_with",
"shell-words",
"tempdir",
"thiserror 2.0.12",
"toml",
"tracing",
"tracing-chrome",
"tracing-log",
"tracing-subscriber",
"tui-textarea",
"tui_confirm_dialog",
"version-compare",
]
[[package]]
name = "libc"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
"overload",
"winapi",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking_lot"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "redox_syscall"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
dependencies = [
"bitflags",
]
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.10.0",
"schemars 0.9.0",
"schemars 1.0.4",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[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.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempdir"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
dependencies = [
"rand 0.4.6",
"remove_dir_all",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl 2.0.12",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.10.0",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tracing"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-chrome"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0a738ed5d6450a9fb96e86a23ad808de2b727fd1394585da5cdd6788ffe724"
dependencies = [
"serde_json",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "tracing-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
name = "tui-textarea"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"ratatui",
"unicode-width 0.2.0",
]
[[package]]
name = "tui_confirm_dialog"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c8edc8d973aeb02b90ed35b06d122ff571aae980469774027c4498bad05362b"
dependencies = [
"rand 0.9.1",
"ratatui",
"regex",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
"unicode-ident",
]
[[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-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.2",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
07070100000001000081A400000000000000000000000168C1DE9400000570000000000000000000000000000000000000001800000000lazyjj-0.6.1/Cargo.toml[package]
name = "lazyjj"
description = "TUI for Jujutsu/jj"
version = "0.6.1"
edition = "2024"
license = "Apache-2.0"
readme = "README.md"
repository = "https://github.com/Cretezy/lazyjj"
authors = ["Charles Crete <charles@cretezy.com>"]
[package.metadata.binstall]
# `cargo binstall` gets confused by the `v` before versions in archive name.
pkg-url = "{ repo }/releases/download/v{ version }/lazyjj-v{ version }-{ target }.{ archive-format }"
[dependencies]
ansi-to-tui = { git = "https://github.com/Cretezy/ansi-to-tui.git", rev = "74bd97e" }
anyhow = "1.0.95"
chrono = "0.4.39"
clap = { version = "4.5.31", features = ["derive", "env"] }
insta = { version = "1.42.1", features = ["filters"] }
itertools = "0.14.0"
ratatui = { version = "0.29.0", features = [
"serde",
"unstable-rendered-line-info",
] }
regex = "1.11.1"
serde = { version = "1.0.217", features = ["derive"] }
serde_with = "3.12.0"
shell-words = "1.1.0"
tempdir = "0.3.7"
thiserror = "2.0.11"
toml = "0.8.19"
tracing = { version = "0.1.41", features = ["attributes"] }
tracing-chrome = "0.7.2"
tracing-log = "0.2.0"
tracing-subscriber = "0.3.19"
tui-textarea = "0.7.0"
tui_confirm_dialog = "0.3.1"
version-compare = "0.2.0"
# Release build optimize size.
# Run strip manually after build to reduce further.
[profile.release]
lto = true
opt-level = 's' # Optimize for size.
codegen-units = 1
strip = "symbols"
07070100000002000081A400000000000000000000000168C1DE9400002C5E000000000000000000000000000000000000001500000000lazyjj-0.6.1/LICENSE
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
07070100000003000081A400000000000000000000000168C1DE9400001C24000000000000000000000000000000000000001700000000lazyjj-0.6.1/README.md# lazyjj
TUI for [Jujutsu/jj](https://github.com/martinvonz/jj). Built in Rust with Ratatui. Interacts with `jj` CLI.
https://github.com/Cretezy/lazyjj/assets/2672503/b5e6b4f1-ebdb-448f-af9e-361e86f0c148
## Features
- Log
- Scroll through the jj log and view change details in side panel
- Create new changes from selected change with `n`
- Edit changes with `e`/`E`
- Desribe changes with `d`
- Abandon changes with `a`
- Toggle between color words and git diff with `p`
- See different revset with `r`
- Set a bookmark to selected change with `b`
- Fetch/push with `f`/`p`
- Squash current changes to selected change with `s`/`S`
- Files
- View files in current change and diff in side panel
- See a change's files from the log tab with `Enter`
- View conflicts list in current change
- Toggle between color words and git diff with `w`
- Untrack file with `x`
- Bookmarks
- View list of bookmarks, including from all remotes with `a`
- Create with `c`, rename with `r`, delete with `d`, forget with `f`
- Track bookmarks with `t`, untrack bookmarks with `T`
- Create new change with `n`, edit change with `e`/`E`
- Command log: View every command lazyjj executes
- Config: Configure lazyjj with your jj config
- Command box: Run jj commands directly in lazyjj with `:`
- Help: See all key mappings with `?`
## Setup
Make sure you have [`jj`](https://martinvonz.github.io/jj/latest/install-and-setup) installed first.
- With [`cargo binstall`](https://github.com/cargo-bins/cargo-binstall): `cargo binstall lazyjj`
- With `cargo install`: `cargo install lazyjj --locked` (may take a few moments to compile)
- With pre-built binaries: [View releases](https://github.com/Cretezy/lazyjj/releases)
- For Arch Linux: `pacman -S lazyjj`
To build and install a pre-release version: `cargo install --git https://github.com/Cretezy/lazyjj.git --locked`
## Configuration
You can optionally configure the following options through your jj config:
- `lazyjj.highlight-color`: Changes the highlight color. Can use named colors. Defaults to `#323264`
- `lazyjj.diff-format`: Change the default diff format. Can be `color-words` or `git`. Defaults to `color_words`
- If `lazyjj.diff-format` is not set but `ui.diff.format` is, the latter will be used
- `lazyjj.diff-tool`: Specify which diff tool to use by default
- If `lazyjj.diff-tool` is not set but `ui.diff.tool` is, the latter will be used
- `lazyjj.bookmark-prefix`: Change the bookmark name prefix for generated bookmark names. Defaults to `push-`
- If `lazyjj.bookmark-prefix` is not set but `git.push-bookmark-prefix` is, the latter will be used
- `lazyjj.layout`: Changes the layout of the main and details panel. Can be `horizontal` (default) or `vertical`
- `lazyjj.layout-percent`: Changes the layout split of the main page. Should be number between 0 and 100. Defaults to `50`
Example: `jj config set --user lazyjj.diff-format "color-words"` (for storing in [user config file](https://martinvonz.github.io/jj/latest/config/#user-config-file), repo config is also supported)
## Usage
To start lazyjj for the repository in the current directory: `lazyjj`
To use a different repository: `lazyjj --path ~/path/to/repo`
To start with a different default revset: `lazyjj -r '::@'`
## Key mappings
See all key mappings for the current tab with `?`.
### Basic navigation
- Quit with `q`
- Change tab with `1`/`2`/`3` or with `h`/`l`
- Scrolling in main panel
- Scroll down/up by one line with `j`/`k` or down/up arrow
- Scroll down/up by half page with `J`/`K` or down/up arrow
- Scrolling in details panel
- Scroll down/up by one line with `Ctrl+e`/`Ctrl+y`
- Scroll down/up by a half page with `Ctrl+d`/`Ctrl+u`
- Scroll down/up by a full page with `Ctrl+f`/`Ctrl+b`
- Open a command popup to run jj commands using `:` (jj prefix not required, e.g. write `new main` instead of `jj new main`)
### Log tab
- Select current change with `@`
- View change files in files tab with `Enter`
- Display different revset with `r` (`jj log -r`)
- Change details panel diff format between color words (default) and Git (and diff tool if set) with `w`
- Toggle details panel wrapping with `W`
- Create new change after highlighted change with `n` (`jj new`)
- Create new change and describe with `N` (`jj new -m`)
- Edit highlighted change with `e` (`jj edit`)
- Edit highlighted change ignoring immutability with `E` (`jj edit --ignore-immutable`)
- Abandon a change with `a` (`jj abandon`)
- Describe the highlighted change with `d` (`jj describe`)
- Save with `Ctrl+s`
- Cancel with `Esc`
- Set a bookmark to the highlighted change with `b` (`jj bookmark set`)
- Scroll in bookmark list with `j`/`k`
- Create a new bookmark with `c`
- Use auto-generated name with `g`
- Squash current changes (in @) to the selected change with `s` (`jj squash`)
- Squash current changes to the selected change ignoring immutability with `S` (`jj squash --ignore-immutable`)
- Git fetch with `f` (`jj git fetch`)
- Git fetch all remotes with `F` (`jj git fetch --all-remotes`)
- Git push with `p` (`jj git push`)
- Git push all bookmarks with `P` (`jj git push --all`)
- Use `Ctrl+p` or `Ctrl+P` to include pushing new bookmarks (`--allow-new`)
### Files tab
- Select current change with `@`
- Change details panel diff format between color words (default) and Git (and diff tool if set) with `w`
- Toggle details panel wrapping with `W`
### Bookmarks tab
- Show bookmarks with all remotes with `a` (`jj bookmark list --all`)
- Create a bookmark with `c` (`jj bookmark create`)
- Rename a bookmark with `r` (`jj bookmark rename`)
- Delete a bookmark with `d` (`jj bookmark delete`)
- Forget a bookmark with `f` (`jj bookmark forget`)
- Track a bookmark with `t` (only works for bookmarks with remotes) (`jj bookmark track`)
- Untrack a bookmark with `T` (only works for bookmarks with remotes) (`jj bookmark untrack`)
- Change details panel diff format between color words (default) and Git (and diff tool if set) with `w`
- Toggle details panel wrapping with `W`
- Create a new change after the highlighted bookmark's change with `n` (`jj new`)
- Create a new change and describe with `N` (`jj new -m`)
- Edit the highlighted bookmark's change with `e` (`jj edit`)
- Edit the highlighted bookmark's change ignoring immutability with `E` (`jj edit --ignore-immutable`)
### Command log tab
- Select latest command with `@`
- Toggle details panel wrapping with `W`
### Configuring
Keys can be configured
```toml
[lazyjj.keybinds.log_tab]
save = "ctrl+s"
```
See more in [keybindings.md](docs/keybindings.md)
## Development
### Setup
1. Install Rust and
2. Clone repository
3. Run with `cargo run`
4. Build with `cargo build --release` (output in `target/release`)
5. You can point it to another jj repo with `--path`: `cargo run -- --path ~/other-repo`
### Logging/Tracing
lazyjj has 2 debugging tools:
1. Logging: Enabled by setting `LAZYJJ_LOG=1` when running. Produces a `lazyjj.log` log file
2. Tracing: Enabled by setting `LAZYJJ_TRACE=1` when running. Produces `trace-*.json` Chrome trace file, for `chrome://tracing` or [ui.perfetto.dev](https://ui.perfetto.dev)
07070100000004000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000001200000000lazyjj-0.6.1/docs07070100000005000081A400000000000000000000000168C1DE940000036F000000000000000000000000000000000000002100000000lazyjj-0.6.1/docs/keybindings.md## Configuring keybindings
```toml
# change keybinding
save = "ctrl+s"
# set multiple keybindings
save = ["ctrl+s", "ctrl+shift+g"]
# disable keybinding
save = false
```
In below examples default values are used.
### Log tab
```toml
[lazyjj.keybinds.log_tab]
save = "ctrl+s"
cancel = "esc"
close-popup = "q"
scroll-down = ["j", "down"]
scroll-up = ["k", "up"]
scroll-down-half = "shift+j"
scroll-up-half = "shift+k"
focus-current = "@"
toggle-diff-format = "w"
refresh = ["shift+r", "f5"]
create-new = "n"
create-new-describe = "shift+n"
squash = "s"
squash-ignore-immutable = "shift+s"
edit-change = "e"
edit-change-ignore-immutable = "shift+e"
abandon = "a"
describe = "d"
edit-revset = "r"
set-bookmark = "b"
open-files = "enter"
push = "p"
push-new = "ctrl+p"
push-all = "shift+p"
push-all-new = "ctrl+shift+p"
fetch = "f"
fetch-all = "shift+f"
open-help = "?"
```
07070100000006000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000001100000000lazyjj-0.6.1/src07070100000007000081A400000000000000000000000168C1DE9400002A3E000000000000000000000000000000000000001800000000lazyjj-0.6.1/src/app.rsuse crate::{
ComponentInputResult,
commander::Commander,
env::Env,
ui::{
Component, ComponentAction, bookmarks_tab::BookmarksTab, command_log_tab::CommandLogTab,
command_popup::CommandPopup, files_tab::FilesTab, log_tab::LogTab,
},
};
use anyhow::{Result, anyhow};
use core::fmt;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tracing::{info, info_span};
#[derive(PartialEq, Copy, Clone)]
pub enum Tab {
Log,
Files,
Bookmarks,
CommandLog,
}
impl fmt::Display for Tab {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Tab::Log => write!(f, "Log"),
Tab::Files => write!(f, "Files"),
Tab::Bookmarks => write!(f, "Bookmarks"),
Tab::CommandLog => write!(f, "Command Log"),
}
}
}
impl Tab {
pub const VALUES: [Self; 4] = [Tab::Log, Tab::Files, Tab::Bookmarks, Tab::CommandLog];
}
pub struct App<'a> {
pub env: Env,
pub current_tab: Tab,
pub log: Option<LogTab<'a>>,
pub files: Option<FilesTab>,
pub bookmarks: Option<BookmarksTab<'a>>,
pub command_log: Option<CommandLogTab>,
pub popup: Option<Box<dyn Component>>,
}
impl<'a> App<'a> {
pub fn new(env: Env) -> Result<App<'a>> {
Ok(App {
env,
current_tab: Tab::Log,
log: None,
files: None,
bookmarks: None,
command_log: None,
popup: None,
})
}
pub fn get_or_init_current_tab(
&mut self,
commander: &mut Commander,
) -> Result<&mut dyn Component> {
self.get_or_init_tab(commander, self.current_tab)
}
pub fn get_current_tab(&mut self) -> Option<&mut dyn Component> {
self.get_tab(self.current_tab)
}
pub fn set_next_tab_with_offset(
&mut self,
commander: &mut Commander,
offset: i64,
) -> Result<()> {
let current_index = Tab::VALUES
.iter()
.position(|&t| t == self.current_tab)
.unwrap();
let new_index =
(current_index as i64 + Tab::VALUES.len() as i64 + offset) as usize % Tab::VALUES.len();
let new_tab: Tab = Tab::VALUES[new_index];
self.set_tab(commander, new_tab)
}
pub fn set_tab(&mut self, commander: &mut Commander, tab: Tab) -> Result<()> {
info!("Setting tab to {}", tab);
self.current_tab = tab;
self.get_or_init_current_tab(commander)?.focus(commander)?;
Ok(())
}
pub fn get_log_tab(&mut self, commander: &mut Commander) -> Result<&mut LogTab<'a>> {
if self.log.is_none() {
let span = info_span!("Initializing log tab");
let log_tab = span.in_scope(|| LogTab::new(commander))?;
self.log = Some(log_tab);
}
self.log
.as_mut()
.ok_or_else(|| anyhow!("Failed to get mutable reference to LogTab"))
}
pub fn get_files_tab(&mut self, commander: &mut Commander) -> Result<&mut FilesTab> {
if self.files.is_none() {
let span = info_span!("Initializing files tab");
let files_tab = span.in_scope(|| {
let current_head = commander.get_current_head()?;
FilesTab::new(commander, ¤t_head)
})?;
self.files = Some(files_tab);
}
self.files
.as_mut()
.ok_or_else(|| anyhow!("Failed to get mutable reference to FilesTab"))
}
pub fn get_bookmarks_tab(
&mut self,
commander: &mut Commander,
) -> Result<&mut BookmarksTab<'a>> {
if self.bookmarks.is_none() {
let span = info_span!("Initializing bookmarks tab");
let bookmarks_tab = span.in_scope(|| BookmarksTab::new(commander))?;
self.bookmarks = Some(bookmarks_tab);
}
self.bookmarks
.as_mut()
.ok_or_else(|| anyhow!("Failed to get mutable reference to BookmarksTab"))
}
pub fn get_command_log_tab(&mut self, commander: &mut Commander) -> Result<&mut CommandLogTab> {
if self.command_log.is_none() {
let span = info_span!("Initializing command log tab");
let command_log_tab = span.in_scope(|| CommandLogTab::new(commander))?;
self.command_log = Some(command_log_tab);
}
self.command_log
.as_mut()
.ok_or_else(|| anyhow!("Failed to get mutable reference to CommandLogTab"))
}
pub fn get_or_init_tab(
&mut self,
commander: &mut Commander,
tab: Tab,
) -> Result<&mut dyn Component> {
Ok(match tab {
Tab::Log => self.get_log_tab(commander)?,
Tab::Files => self.get_files_tab(commander)?,
Tab::Bookmarks => self.get_bookmarks_tab(commander)?,
Tab::CommandLog => self.get_command_log_tab(commander)?,
})
}
pub fn get_tab(&mut self, tab: Tab) -> Option<&mut dyn Component> {
match tab {
Tab::Log => self
.log
.as_mut()
.map(|log_tab| log_tab as &mut dyn Component),
Tab::Files => self
.files
.as_mut()
.map(|files_tab| files_tab as &mut dyn Component),
Tab::Bookmarks => self
.bookmarks
.as_mut()
.map(|bookmarks_tab| bookmarks_tab as &mut dyn Component),
Tab::CommandLog => self
.command_log
.as_mut()
.map(|command_log_tab| command_log_tab as &mut dyn Component),
}
}
pub fn handle_action(
&mut self,
component_action: ComponentAction,
commander: &mut Commander,
) -> Result<()> {
match component_action {
ComponentAction::ViewFiles(head) => {
self.set_tab(commander, Tab::Files)?;
self.get_files_tab(commander)?.set_head(commander, &head)?;
}
ComponentAction::ViewLog(head) => {
self.get_log_tab(commander)?.set_head(commander, head);
self.set_tab(commander, Tab::Log)?;
}
ComponentAction::ChangeHead(head) => {
self.get_files_tab(commander)?.set_head(commander, &head)?;
}
ComponentAction::SetPopup(popup) => {
self.popup = popup;
}
ComponentAction::Multiple(component_actions) => {
for component_action in component_actions.into_iter() {
self.handle_action(component_action, commander)?;
}
}
ComponentAction::RefreshTab() => {
self.set_tab(commander, self.current_tab)?;
match self.current_tab {
Tab::Log => {
let head = commander.get_current_head()?;
self.get_log_tab(commander)?.set_head(commander, head);
}
Tab::CommandLog => {
self.get_command_log_tab(commander)?.update(commander)?;
}
_ => {}
};
}
}
Ok(())
}
pub fn input(&mut self, event: Event, commander: &mut Commander) -> Result<bool> {
if let Some(popup) = self.popup.as_mut() {
match popup.input(commander, event.clone())? {
ComponentInputResult::HandledAction(component_action) => {
self.handle_action(component_action, commander)?
}
ComponentInputResult::Handled => {}
ComponentInputResult::NotHandled => {
if let Event::Key(key) = event
&& key.kind == event::KeyEventKind::Press
{
// Close
if matches!(
key.code,
KeyCode::Char('y')
| KeyCode::Char('n')
| KeyCode::Char('o')
| KeyCode::Enter
| KeyCode::Char('q')
| KeyCode::Esc
) {
self.popup = None
}
}
}
};
} else if event == event::Event::FocusGained {
self.get_or_init_current_tab(commander)?.focus(commander)?;
} else {
match self
.get_or_init_current_tab(commander)?
.input(commander, event.clone())?
{
ComponentInputResult::HandledAction(component_action) => {
self.handle_action(component_action, commander)?
}
ComponentInputResult::Handled => {}
ComponentInputResult::NotHandled => {
if let Event::Key(key) = event
&& key.kind == event::KeyEventKind::Press
{
// Close
if key.code == KeyCode::Char('q')
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& (key.code == KeyCode::Char('c')))
|| key.code == KeyCode::Esc
{
return Ok(true);
}
//
// Tab switching
else if key.code == KeyCode::Char('l') {
self.set_next_tab_with_offset(commander, 1)?;
} else if key.code == KeyCode::Char('h') {
self.set_next_tab_with_offset(commander, -1)?;
} else if let Some((_, tab)) =
Tab::VALUES.iter().enumerate().find(|(i, _)| {
key.code
== KeyCode::Char(
char::from_digit((*i as u32) + 1u32, 10)
.expect("Tab index could not be converted to digit"),
)
})
{
self.set_tab(commander, *tab)?;
}
// General jj command runner
else if key.code == KeyCode::Char(':') {
self.popup = Some(Box::new(CommandPopup::new()));
}
}
}
};
}
Ok(false)
}
}
07070100000008000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000001B00000000lazyjj-0.6.1/src/commander07070100000009000081A400000000000000000000000168C1DE9400002408000000000000000000000000000000000000002800000000lazyjj-0.6.1/src/commander/bookmarks.rs/*!
[Commander] member functions related to jj bookmark.
This module has features to parse the `jj bookmark list` output. The
other jj bookmark commands are defined in module [jj][super::jj].
It is mostly used in the [bookmarks_tab][crate::ui::bookmarks_tab] module.
*/
use crate::{
commander::{CommandError, Commander, RemoveEndLine},
env::DiffFormat,
};
use ansi_to_tui::IntoText;
use anyhow::Result;
use itertools::Itertools;
use ratatui::text::Text;
use regex::Regex;
use std::{cmp::Ordering, fmt::Display, sync::LazyLock};
use tracing::instrument;
#[derive(Clone, Debug, PartialEq)]
pub struct Bookmark {
pub name: String,
pub remote: Option<String>,
pub present: bool,
pub timestamp: i64,
}
impl Display for Bookmark {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut text = self.name.clone();
if let Some(remote) = self.remote.as_ref() {
text.push('@');
text.push_str(remote);
}
write!(f, "{text}")
}
}
// Template which outputs `[name@remote]`. Used to parse data from bookmark list
const BRANCH_TEMPLATE: &str = r#""[" ++ name ++ "@" ++ remote ++ "|" ++ present ++ "|" ++ self.normal_target().committer().timestamp().format("%s") ++ "]""#;
// Regex to parse bookmark
static BRANCH_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\[(.*)@(.*)\|(true|false)\|(\d+)\]$").unwrap());
fn parse_bookmark(text: &str) -> Option<Bookmark> {
let captured = BRANCH_REGEX.captures(text);
captured.as_ref().and_then(|captured| {
let name = captured.get(1);
let remote = captured.get(2);
let present = captured.get(3);
let timestamp = captured.get(4);
if let (Some(name), Some(remote), Some(present), Some(timestamp)) =
(name, remote, present, timestamp)
{
let remote = remote.as_str().to_owned();
Some(Bookmark {
remote: if remote.is_empty() {
None
} else {
Some(remote)
},
name: name.as_str().to_owned(),
present: present.as_str() == "true",
timestamp: timestamp.as_str().parse::<i64>().unwrap_or(0),
})
} else {
None
}
})
}
#[derive(Clone, Debug)]
pub enum BookmarkLine {
Unparsable(String),
Parsed { text: String, bookmark: Bookmark },
}
impl BookmarkLine {
pub fn to_text(&self) -> Result<Text<'_>, ansi_to_tui::Error> {
match self {
BookmarkLine::Unparsable(text) => text.to_text(),
BookmarkLine::Parsed { text, .. } => text.to_text(),
}
}
}
impl Commander {
/// Get bookmarks.
/// Maps to `jj bookmark list`
#[instrument(level = "trace", skip(self))]
pub fn get_bookmarks(&self, show_all: bool) -> Result<Vec<BookmarkLine>, CommandError> {
let mut args = vec![];
if show_all {
args.push("--all-remotes");
}
let bookmarks_colored = self.execute_jj_command(
[
vec![
"bookmark",
"list",
"--config",
// Override format_ref_targets to not list conflicts
r#"template-aliases.'format_ref_targets(ref)'='''
if(ref.conflict(),
" " ++ label("conflict", "(conflicted)"),
": " ++ format_commit_summary_with_refs(ref.normal_target(), ""),
)
'''"#,
],
args.clone(),
]
.concat(),
true,
true,
)?;
let bookmarks: Vec<BookmarkLine> = self
.execute_jj_command(
[
vec![
"bookmark",
"list",
"-T",
&format!(r#"{BRANCH_TEMPLATE} ++ "\n""#),
],
args,
]
.concat(),
false,
true,
)?
.lines()
.zip(bookmarks_colored.lines())
.map(|(line, line_colored)| match parse_bookmark(line) {
Some(bookmark) => BookmarkLine::Parsed {
text: line_colored.to_owned(),
bookmark,
},
None => BookmarkLine::Unparsable(line_colored.to_owned()),
})
.sorted_by(|a, b| {
use BookmarkLine::*;
match (a, b) {
(Parsed { bookmark: a, .. }, Parsed { bookmark: b, .. }) => {
b.timestamp.cmp(&a.timestamp)
}
// Just move unparsable lines to the back, we don't care about the actual
// order, but sorted_by() expects to be given a total order
(Parsed { .. }, Unparsable(..)) => Ordering::Less,
(Unparsable(..), Parsed { .. }) => Ordering::Greater,
(Unparsable(..), Unparsable(..)) => Ordering::Equal,
}
})
.collect();
Ok(bookmarks)
}
#[instrument(level = "trace", skip(self))]
pub fn get_bookmarks_list(&self, show_all: bool) -> Result<Vec<Bookmark>, CommandError> {
let mut args = vec![
"bookmark".to_owned(),
"list".to_owned(),
"-T".to_owned(),
format!(r#"if(present, {} ++ "\n", "")"#, BRANCH_TEMPLATE),
];
if show_all {
args.push("--all-remotes".to_owned());
}
let bookmarks: Vec<Bookmark> = self
.execute_jj_command(args, false, true)?
.lines()
.filter_map(parse_bookmark)
.sorted_by(|a, b| b.timestamp.cmp(&a.timestamp))
.collect();
Ok(bookmarks)
}
/// Get bookmark details.
/// Maps to `jj show <bookmark>`
#[instrument(level = "trace", skip(self))]
pub fn get_bookmark_show(
&self,
bookmark: &Bookmark,
diff_format: &DiffFormat,
ignore_working_copy: bool,
) -> Result<String, CommandError> {
let bookmark_arg = &bookmark.to_string();
let mut args = vec!["show", bookmark_arg];
args.append(&mut diff_format.get_args());
if ignore_working_copy {
args.push("--ignore-working-copy");
}
Ok(self.execute_jj_command(args, true, true)?.remove_end_line())
}
}
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use crate::commander::tests::TestRepo;
use super::*;
#[test]
fn get_bookmarks() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmarks = test_repo.commander.get_bookmarks(false)?;
assert_eq!(bookmarks.len(), 1);
assert_eq!(
bookmarks.first().and_then(|bookmark| match bookmark {
BookmarkLine::Parsed { bookmark, .. } => Some(Bookmark {
name: bookmark.name.clone(),
remote: bookmark.remote.clone(),
present: bookmark.present,
timestamp: 0,
}),
_ => None,
}),
Some(Bookmark {
name: bookmark.name.clone(),
remote: bookmark.remote.clone(),
present: bookmark.present,
timestamp: 0,
})
);
Ok(())
}
#[test]
fn get_bookmarks_list() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks
.iter()
.map(|b| Bookmark {
name: b.name.clone(),
remote: b.remote.clone(),
present: b.present,
timestamp: 0,
})
.collect::<Vec<_>>(),
[Bookmark {
name: bookmark.name,
remote: bookmark.remote,
present: bookmark.present,
timestamp: 0,
}]
);
Ok(())
}
#[test]
fn get_bookmark_show() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmark_show =
test_repo
.commander
.get_bookmark_show(&bookmark, &DiffFormat::default(), false)?;
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"Commit ID: [0-9a-fA-F]{40}", "Commit ID: [COMMIT_ID]");
settings.add_filter(r"Change ID: [k-z]{32}", "Change ID: [Change ID]");
settings.add_filter(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", "([DATE_TIME])");
let _bound = settings.bind_to_scope();
assert_debug_snapshot!(bookmark_show);
Ok(())
}
}
0707010000000A000081A400000000000000000000000168C1DE9400003059000000000000000000000000000000000000002400000000lazyjj-0.6.1/src/commander/files.rs/*!
[Commander] member functions related to jj diff.
This module has features to parse the diff output.
It is mostly used in the [files_tab][crate::ui::files_tab] module.
*/
use std::sync::LazyLock;
use crate::{
commander::{CommandError, Commander, ids::CommitId, log::Head},
env::DiffFormat,
};
use anyhow::{Context, Result};
use ratatui::style::Color;
use regex::Regex;
use tracing::instrument;
#[derive(Clone, Debug, PartialEq)]
pub struct File {
pub line: String,
pub path: Option<String>,
pub diff_type: Option<DiffType>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DiffType {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Conflict {
pub path: String,
}
impl DiffType {
pub fn parse(value: &str) -> Option<Self> {
match value {
"A" => Some(DiffType::Added),
"M" => Some(DiffType::Modified),
"D" => Some(DiffType::Deleted),
"R" => Some(DiffType::Renamed),
_ => None,
}
}
pub fn color(&self) -> Color {
match self {
DiffType::Added => Color::Green,
DiffType::Modified => Color::Cyan,
DiffType::Renamed => Color::Cyan,
DiffType::Deleted => Color::Red,
}
}
}
// Example line: `A README.md`, `M src/main.rs`, `D Hello World`
static FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(.) (.*)").unwrap());
static RENAME_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{(.*?) => (.*?)\}").unwrap());
static CONFLICTS_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(.*) .*").unwrap());
impl Commander {
/// Get list of changes files in a change. Parses the output.
/// Maps to `jj diff --summary -r <revision>`
#[instrument(level = "trace", skip(self))]
pub fn get_files(&self, head: &Head) -> Result<Vec<File>, CommandError> {
Ok(self
.execute_jj_command(
vec!["diff", "-r", head.commit_id.as_str(), "--summary"],
false,
true,
)?
.lines()
.map(|line| {
let captured = FILES_REGEX.captures(line);
let diff_type = captured
.as_ref()
.and_then(|captured| captured.get(1))
.and_then(|inner_text| DiffType::parse(inner_text.as_str()));
let path = captured
.as_ref()
.and_then(|captured| captured.get(2))
.map(|inner_text| inner_text.as_str().to_owned());
File {
line: line.to_string(),
path,
diff_type,
}
})
.collect())
}
/// Get list of changes files in a change. Parses the output.
/// Maps to `jj diff --summary -r <revision>`
#[instrument(level = "trace", skip(self))]
pub fn get_conflicts(&self, commit_id: &CommitId) -> Result<Vec<Conflict>> {
let output = self.execute_jj_command(
vec!["resolve", "--list", "-r", commit_id.as_str()],
false,
true,
);
match output {
Ok(output) => Ok(output
.lines()
.filter_map(|line| {
let captured = CONFLICTS_REGEX.captures(line);
captured
.as_ref()
.and_then(|captured| captured.get(1))
.map(|inner_text| Conflict {
path: inner_text.as_str().to_owned(),
})
})
.collect()),
Err(CommandError::Status(_, Some(2))) => {
// No conflicts
Ok(vec![])
}
Err(err) => Err(err).context("Failed getting conflicts"),
}
}
/// Get diff for file change in a change.
/// Maps to `jj diff -r <revision> <path>`
#[instrument(level = "trace", skip(self))]
pub fn get_file_diff(
&self,
head: &Head,
current_file: &File,
diff_format: &DiffFormat,
ignore_working_copy: bool,
) -> Result<Option<String>, CommandError> {
let Some(path) = current_file.path.as_ref() else {
return Ok(None);
};
let path = if let (true, Some(captures)) = (
current_file.diff_type == Some(DiffType::Renamed),
RENAME_REGEX.captures(path),
) {
match captures.get(2) {
Some(path) => path.as_str(),
None => return Ok(None),
}
} else {
path
};
let mut args = vec!["diff", "-r", head.commit_id.as_str(), path];
args.append(&mut diff_format.get_args());
if ignore_working_copy {
args.push("--ignore-working-copy");
}
self.execute_jj_command(args, true, true).map(Some)
}
#[instrument(level = "trace", skip(self))]
pub fn untrack_file(&self, current_file: &File) -> Result<Option<String>, CommandError> {
let Some(path) = current_file.path.as_ref() else {
return Ok(None);
};
let path = if let Some(DiffType::Renamed) = current_file.diff_type
&& let Some(captures) = RENAME_REGEX.captures(path)
{
match captures.get(2) {
Some(path) => path.as_str(),
None => return Ok(None),
}
} else {
path
};
Ok(Some(self.execute_jj_command(
vec!["file", "untrack", path],
false,
true,
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commander::tests::TestRepo;
use insta::assert_debug_snapshot;
use std::fs;
#[test]
fn get_files() -> Result<()> {
let test_repo = TestRepo::new()?;
let file_path = test_repo.directory.path().join("README");
// Initial state
{
let head = test_repo.commander.get_current_head()?;
let files = test_repo.commander.get_files(&head)?;
assert_eq!(files, vec![]);
}
// Add file
{
fs::write(&file_path, b"AAA")?;
let head = test_repo.commander.get_current_head()?;
let files = test_repo.commander.get_files(&head)?;
assert_eq!(
files,
vec![File {
line: "A README".to_owned(),
path: Some("README".to_owned(),),
diff_type: Some(DiffType::Added,),
},]
);
}
// Commit
test_repo.commander.execute_void_jj_command(vec!["new"])?;
// Modify file
{
fs::write(&file_path, b"BBB")?;
let head = test_repo.commander.get_current_head()?;
let files = test_repo.commander.get_files(&head)?;
assert_eq!(
files,
vec![File {
line: "M README".to_owned(),
path: Some("README".to_owned()),
diff_type: Some(DiffType::Modified)
},]
);
}
// Delete file
{
fs::remove_file(&file_path)?;
let head = test_repo.commander.get_current_head()?;
let files = test_repo.commander.get_files(&head)?;
assert_eq!(
files,
vec![File {
line: "D README".to_owned(),
path: Some("README".to_owned()),
diff_type: Some(DiffType::Deleted)
},]
);
}
Ok(())
}
#[test]
fn get_file_diff() -> Result<()> {
let test_repo = TestRepo::new()?;
let mut file_path = test_repo.directory.path().join("README");
// Add file
{
fs::write(&file_path, b"AAA")?;
let file = File {
path: Some("README".to_string()),
diff_type: Some(DiffType::Added),
line: "A README".to_string(),
};
let head = test_repo.commander.get_current_head()?;
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::ColorWords,
false
)?);
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::Git,
false
)?);
}
// Commit
test_repo.commander.execute_void_jj_command(vec!["new"])?;
// Modify file
{
fs::write(&file_path, b"BBB")?;
let file = File {
path: Some("README".to_string()),
diff_type: Some(DiffType::Modified),
line: "M README".to_string(),
};
let head = test_repo.commander.get_current_head()?;
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::ColorWords,
true
)?);
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::Git,
true
)?);
}
// Commit
test_repo.commander.execute_void_jj_command(vec!["new"])?;
// Rename file
{
let file_path_new = test_repo.directory.path().join("README2");
fs::rename(file_path, &file_path_new)?;
file_path = file_path_new;
let file = File {
path: Some("{README => README2}".to_string()),
diff_type: Some(DiffType::Renamed),
line: "R {README => README2}".to_string(),
};
let head = test_repo.commander.get_current_head()?;
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::ColorWords,
true
)?);
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::Git,
true
)?);
}
// Commit
test_repo.commander.execute_void_jj_command(vec!["new"])?;
// Delete file
{
fs::remove_file(&file_path)?;
let file = File {
path: Some("README2".to_string()),
diff_type: Some(DiffType::Deleted),
line: "D README2".to_string(),
};
let head = test_repo.commander.get_current_head()?;
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::ColorWords,
true
)?);
assert_debug_snapshot!(test_repo.commander.get_file_diff(
&head,
&file,
&DiffFormat::Git,
true
)?);
}
Ok(())
}
#[test]
fn get_conflicts() -> Result<()> {
let test_repo = TestRepo::new()?;
let file_path = test_repo.directory.path().join("README");
let head0 = test_repo.commander.get_current_head()?;
// First change
test_repo.commander.run_new(head0.commit_id.as_str())?;
let head1 = test_repo.commander.get_current_head()?;
fs::write(&file_path, b"AAA")?;
test_repo.commander.run_new(head0.commit_id.as_str())?;
let head2 = test_repo.commander.get_current_head()?;
fs::write(&file_path, b"BBB")?;
test_repo.commander.execute_void_jj_command([
"rebase",
"-s",
head2.change_id.as_str(),
"-d",
head1.change_id.as_str(),
])?;
let head = test_repo.commander.get_current_head()?;
let conflicts = test_repo.commander.get_conflicts(&head.commit_id)?;
assert_eq!(
conflicts,
[Conflict {
path: "README".to_owned()
}]
);
Ok(())
}
}
0707010000000B000081A400000000000000000000000168C1DE94000004EA000000000000000000000000000000000000002200000000lazyjj-0.6.1/src/commander/ids.rs/*!
Helper structs [ChangeId] and [CommitId]
*/
use std::{ffi::OsStr, fmt::Display};
/// Wrapper around change ID.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct ChangeId(pub String);
impl ChangeId {
pub fn as_os_str(&self) -> &OsStr {
OsStr::new(&self.0)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_string(&self) -> String {
self.0.to_owned()
}
}
impl AsRef<OsStr> for ChangeId {
fn as_ref(&self) -> &OsStr {
self.as_os_str()
}
}
impl Display for ChangeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Wrapper around commit ID.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct CommitId(pub String);
impl CommitId {
pub fn as_os_str(&self) -> &OsStr {
OsStr::new(&self.0)
}
pub fn as_str(&self) -> &str {
&self.0
}
// pub fn as_string(&self) -> String {
// self.0.to_owned()
// }
}
impl AsRef<OsStr> for CommitId {
fn as_ref(&self) -> &OsStr {
self.as_os_str()
}
}
impl Display for CommitId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
0707010000000C000081A400000000000000000000000168C1DE9400003791000000000000000000000000000000000000002100000000lazyjj-0.6.1/src/commander/jj.rs/*!
[Commander] member functions related to various simpler jj commands.
The module implementes a number of jj commands.
Surprisingly, this module also contains jj bookmark commands.
These functions are used everywhere (bookmark tab, log tab).
*/
use crate::commander::{CommandError, Commander, bookmarks::Bookmark, ids::CommitId};
use anyhow::{Context, Result};
use tracing::instrument;
impl Commander {
/// Create a new change after revision. Maps to `jj new <revision>`
#[instrument(level = "trace", skip(self))]
pub fn run_new(&self, revision: &str) -> Result<()> {
self.execute_void_jj_command(vec!["new", revision])
.context("Failed executing jj new")
}
/// Edit change. Maps to `jj edit <commit>`
#[instrument(level = "trace", skip(self))]
pub fn run_edit(&self, revision: &str, ignore_immutable: bool) -> Result<()> {
let mut args = vec!["edit", revision];
if ignore_immutable {
args.push("--ignore-immutable");
}
self.execute_void_jj_command(args)
.context("Failed executing jj edit")
}
/// Abandon change. Maps to `jj abandon <revision>`
#[instrument(level = "trace", skip(self))]
pub fn run_abandon(&self, commit_id: &CommitId) -> Result<()> {
self.execute_void_jj_command(vec!["abandon", commit_id.as_str()])
.context("Failed executing jj abandon")
}
/// Describe change. Maps to `jj describe <revision> -m <message>`
#[instrument(level = "trace", skip(self))]
pub fn run_describe(&self, revision: &str, message: &str) -> Result<()> {
self.execute_void_jj_command(vec!["describe", revision, "-m", message])
.context("Failed executing jj describe")
}
/// Squash changes. Maps to `jj squash -u --into <revision>`
#[instrument(level = "trace", skip(self))]
pub fn run_squash(&mut self, revision: &str, ignore_immutable: bool) -> Result<()> {
let mut args = vec!["squash", "-u", "--into", revision];
if ignore_immutable {
args.push("--ignore-immutable");
}
self.execute_void_jj_command(args)
.context("Failed executing jj squash")
}
/// Create bookmark. Maps to `jj bookmark create <name>`
#[instrument(level = "trace", skip(self))]
pub fn create_bookmark(&self, name: &str) -> Result<Bookmark, CommandError> {
self.execute_void_jj_command(vec!["bookmark", "create", name])?;
// jj only creates local bookmarks
Ok(Bookmark {
name: name.to_owned(),
remote: None,
present: true,
timestamp: chrono::Utc::now().timestamp(),
})
}
/// Create bookmark pointing to commit. Maps to `jj bookmark create <name> -r <revision>`
#[instrument(level = "trace", skip(self))]
pub fn create_bookmark_commit(
&self,
name: &str,
commit_id: &CommitId,
) -> Result<Bookmark, CommandError> {
self.execute_void_jj_command(vec!["bookmark", "create", name, "-r", commit_id.as_str()])?;
// jj only creates local bookmarks
Ok(Bookmark {
name: name.to_owned(),
remote: None,
present: true,
timestamp: chrono::Utc::now().timestamp(),
})
}
/// Set bookmark pointing to commit. Maps to `jj bookmark set <name> -r <revision>`
#[instrument(level = "trace", skip(self))]
pub fn set_bookmark_commit(
&self,
name: &str,
commit_id: &CommitId,
) -> Result<(), CommandError> {
// TODO: Maybe don't do --allow-backwards by default?
self.execute_void_jj_command(vec![
"bookmark",
"set",
name,
"-r",
commit_id.as_str(),
"--allow-backwards",
])
}
/// Rename bookmark. Maps to `jj bookmark rename <old> <new>`
#[instrument(level = "trace", skip(self))]
pub fn rename_bookmark(&self, old: &str, new: &str) -> Result<(), CommandError> {
self.execute_void_jj_command(vec!["bookmark", "rename", old, new])
}
/// Delete bookmark. Maps to `jj bookmark delete <name>`
#[instrument(level = "trace", skip(self))]
pub fn delete_bookmark(&self, name: &str) -> Result<(), CommandError> {
self.execute_void_jj_command(vec!["bookmark", "delete", name])
}
/// Forget bookmark. Maps to `jj bookmark forget <name>`
#[instrument(level = "trace", skip(self))]
pub fn forget_bookmark(&self, name: &str) -> Result<(), CommandError> {
self.execute_void_jj_command(vec!["bookmark", "forget", name])
}
/// Track bookmark. Maps to `jj bookmark track <bookmark>@<remote>`
#[instrument(level = "trace", skip(self))]
pub fn track_bookmark(&self, bookmark: &Bookmark) -> Result<(), CommandError> {
self.execute_void_jj_command(vec!["bookmark", "track", &bookmark.to_string()])
}
/// Untrack bookmark. Maps to `jj bookmark untrack <bookmark>@<remote>`
#[instrument(level = "trace", skip(self))]
pub fn untrack_bookmark(&self, bookmark: &Bookmark) -> Result<(), CommandError> {
self.execute_void_jj_command(vec!["bookmark", "untrack", &bookmark.to_string()])
}
/// Git push. Maps to `jj git push`
#[instrument(level = "trace", skip(self))]
pub fn git_push(
&self,
all_bookmarks: bool,
allow_new: bool,
commit_id: &CommitId,
) -> Result<String, CommandError> {
let mut args = vec!["git", "push"];
if allow_new {
args.push("--allow-new");
}
if all_bookmarks {
args.push("--all");
} else {
args.push("-r");
args.push(commit_id.as_str());
}
self.execute_jj_command(args, true, true)
}
/// Git fetch. Maps to `jj git fetch`
#[instrument(level = "trace", skip(self))]
pub fn git_fetch(&self, all_remotes: bool) -> Result<String, CommandError> {
let mut args = vec!["git", "fetch"];
if all_remotes {
args.push("--all-remotes");
}
self.execute_jj_command(args, true, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commander::tests::TestRepo;
#[test]
fn run_new() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
test_repo.commander.run_new(head.commit_id.as_str())?;
assert_eq!(
test_repo
.commander
.command_history
.lock()
.unwrap()
.last()
.unwrap()
.args
.first()
.unwrap(),
"new"
);
assert_ne!(head, test_repo.commander.get_current_head()?);
Ok(())
}
#[test]
fn run_edit() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
test_repo.commander.run_new(head.commit_id.as_str())?;
assert_ne!(head, test_repo.commander.get_current_head()?);
test_repo
.commander
.run_edit(head.commit_id.as_str(), false)?;
assert_eq!(
test_repo
.commander
.command_history
.lock()
.unwrap()
.last()
.unwrap()
.args
.first()
.unwrap(),
"edit"
);
assert_eq!(head, test_repo.commander.get_current_head()?);
Ok(())
}
#[test]
fn run_abandon() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
test_repo.commander.run_abandon(&head.commit_id)?;
assert_eq!(
test_repo
.commander
.command_history
.lock()
.unwrap()
.last()
.unwrap()
.args
.first()
.unwrap(),
"abandon"
);
assert_ne!(head, test_repo.commander.get_current_head()?);
Ok(())
}
#[test]
fn run_describe() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
test_repo
.commander
.run_describe(head.commit_id.as_str(), "AAA")?;
assert_eq!(
test_repo
.commander
.command_history
.lock()
.unwrap()
.last()
.unwrap()
.args
.first()
.unwrap(),
"describe"
);
let head = test_repo.commander.get_current_head()?.commit_id;
assert_eq!(test_repo.commander.get_commit_description(&head)?, "AAA");
Ok(())
}
#[test]
fn create_bookmark() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks,
[Bookmark {
name: bookmark.name,
remote: bookmark.remote,
present: bookmark.present,
timestamp: bookmarks[0].timestamp,
}]
);
Ok(())
}
#[test]
fn create_bookmark_commit() -> Result<()> {
let test_repo = TestRepo::new()?;
// Create new change, since by default `jj bookmark create` uses current change
let head = test_repo.commander.get_current_head()?;
test_repo.commander.run_new(head.commit_id.as_str())?;
assert_ne!(head, test_repo.commander.get_current_head()?);
let bookmark = test_repo
.commander
.create_bookmark_commit("test", &head.commit_id)?;
let log = test_repo.commander.execute_jj_command(
[
"log",
"--limit",
"1",
"--no-graph",
"-T",
"commit_id",
"-r",
&bookmark.name,
],
false,
true,
)?;
assert_eq!(head.commit_id.to_string(), log);
Ok(())
}
#[test]
fn set_bookmark_commit() -> Result<()> {
let test_repo = TestRepo::new()?;
// Create new change, since by default `jj bookmark create` uses current change
let old_head = test_repo.commander.get_current_head()?;
test_repo.commander.run_new(old_head.commit_id.as_str())?;
let new_head = test_repo.commander.get_current_head()?;
assert_ne!(old_head, new_head);
let bookmark = test_repo.commander.create_bookmark("test")?;
let log = test_repo.commander.execute_jj_command(
[
"log",
"--limit",
"1",
"--no-graph",
"-T",
"commit_id",
"-r",
&bookmark.name,
],
false,
true,
)?;
assert_eq!(new_head.commit_id.to_string(), log);
test_repo
.commander
.set_bookmark_commit(&bookmark.name, &old_head.commit_id)?;
let log = test_repo.commander.execute_jj_command(
[
"log",
"--limit",
"1",
"--no-graph",
"-T",
"commit_id",
"-r",
&bookmark.name,
],
false,
true,
)?;
assert_eq!(old_head.commit_id.to_string(), log);
Ok(())
}
#[test]
fn rename_bookmark() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test1")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks,
[Bookmark {
name: bookmark.name.clone(),
remote: bookmark.remote,
present: bookmark.present,
timestamp: bookmarks[0].timestamp,
}]
);
test_repo
.commander
.rename_bookmark(&bookmark.name, "test2")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks,
[Bookmark {
name: "test2".to_owned(),
remote: None,
present: true,
timestamp: bookmarks[0].timestamp,
}]
);
Ok(())
}
#[test]
fn delete_bookmark() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks,
[Bookmark {
name: bookmark.name.clone(),
remote: bookmark.remote,
present: bookmark.present,
timestamp: bookmarks[0].timestamp,
}]
);
test_repo.commander.delete_bookmark(&bookmark.name)?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(bookmarks, []);
Ok(())
}
#[test]
fn forget_bookmark() -> Result<()> {
let test_repo = TestRepo::new()?;
let bookmark = test_repo.commander.create_bookmark("test")?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(
bookmarks,
[Bookmark {
name: bookmark.name.clone(),
remote: bookmark.remote,
present: bookmark.present,
timestamp: bookmarks[0].timestamp,
}]
);
test_repo.commander.forget_bookmark(&bookmark.name)?;
let bookmarks = test_repo.commander.get_bookmarks_list(false)?;
assert_eq!(bookmarks, []);
Ok(())
}
}
0707010000000D000081A400000000000000000000000168C1DE9400003682000000000000000000000000000000000000002200000000lazyjj-0.6.1/src/commander/log.rs/*!
[Commander] member functions related to jj log.
This module has features to parse the log output to extract change id and commit id.
It is mostly used in the [log_tab][crate::ui::log_tab] module.
*/
use crate::{
commander::{
CommandError, Commander, RemoveEndLine,
bookmarks::Bookmark,
ids::{ChangeId, CommitId},
},
env::DiffFormat,
};
use anyhow::{Context, Result, anyhow, bail};
use itertools::Itertools;
use regex::Regex;
use std::{fmt::Display, sync::LazyLock};
use thiserror::Error;
use tracing::instrument;
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct Head {
pub change_id: ChangeId,
pub commit_id: CommitId,
pub divergent: bool,
pub immutable: bool,
}
#[derive(Clone, Debug)]
pub struct LogOutput {
pub graph: String,
// Maps graph line -> heads
pub graph_heads: Vec<Option<Head>>,
pub heads: Vec<Head>,
}
#[derive(Error, Debug)]
pub struct HeadParseError(String);
impl Display for HeadParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Head parse error: {}", self.0)
}
}
// Template which outputs `[change_id|commit_id|divergent]`. Used to parse data from log and other
// commands which supports templating.
const HEAD_TEMPLATE: &str =
r#""[" ++ change_id ++ "|" ++ commit_id ++ "|" ++ divergent ++ "|" ++ immutable ++ "]""#;
// Regex to parse HEAD_TEMPLATE
static HEAD_TEMPLATE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[(.*)\|(.*)\|(.*)\|(.*)\]").unwrap());
// Parse a head with HEAD_TEMPLATE.
fn parse_head(text: &str) -> Result<Head> {
let captured = HEAD_TEMPLATE_REGEX.captures(text);
captured
.as_ref()
.map_or(Err(anyhow!(HeadParseError(text.to_owned()))), |captured| {
if let (Some(change_id), Some(commit_id), Some(divergent), Some(immutable)) = (
captured.get(1),
captured.get(2),
captured.get(3),
captured.get(4),
) {
Ok(Head {
change_id: ChangeId(change_id.as_str().to_string()),
commit_id: CommitId(commit_id.as_str().to_string()),
divergent: divergent.as_str() == "true",
immutable: immutable.as_str() == "true",
})
} else {
bail!(HeadParseError(text.to_owned()))
}
})
}
impl Commander {
/// Get log. Returns human readable log and mapping to log line to head.
/// Maps to `jj log`
#[instrument(level = "trace", skip(self))]
pub fn get_log(&self, revset: &Option<String>) -> Result<LogOutput, CommandError> {
let mut args = vec![];
if let Some(revset) = revset {
args.push("-r");
args.push(revset);
}
// Force builtin_log_compact which uses 2 lines per change
let graph = self.execute_jj_command(
[
vec!["log", "--template", "builtin_log_compact"],
args.clone(),
]
.concat(),
true,
true,
)?;
let graph_heads: Vec<Option<Head>> = self
.execute_jj_command(
[
vec![
"log",
"--template",
// Match builtin_log_compact with 2 lines per change
&format!(
r#"{HEAD_TEMPLATE} ++ " " ++ bookmarks ++"\n" ++ {HEAD_TEMPLATE}"#
),
],
args,
]
.concat(),
false,
true,
)?
.lines()
.map(|line| parse_head(line).ok())
.collect();
let heads = graph_heads.clone().into_iter().flatten().unique().collect();
Ok(LogOutput {
graph,
graph_heads,
heads,
})
}
/// Get commit details.
/// Maps to `jj show <commit>`
#[instrument(level = "trace", skip(self))]
pub fn get_commit_show(
&self,
commit_id: &CommitId,
diff_format: &DiffFormat,
ignore_working_copy: bool,
) -> Result<String, CommandError> {
let mut args = vec!["show", commit_id.as_str()];
args.append(&mut diff_format.get_args());
if ignore_working_copy {
args.push("--ignore-working-copy");
}
Ok(self.execute_jj_command(args, true, true)?.remove_end_line())
}
/// Get the current head.
/// Maps to `jj log -r @`
#[instrument(level = "trace", skip(self))]
pub fn get_current_head(&self) -> Result<Head> {
parse_head(
&self
.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
&format!(r#"{HEAD_TEMPLATE} ++ "\n""#),
"-r",
"@",
"--limit",
"1",
],
false,
true,
)
.context("Failed getting current head")?
.remove_end_line(),
)
}
/// Get the latest version of a head. Can detect evolution of divergent head.
#[instrument(level = "trace", skip(self))]
pub fn get_head_latest(&self, head: &Head) -> Result<Head> {
// Get all heads which point to the same change ID
let latest_heads_res = self.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
&format!(r#"{HEAD_TEMPLATE} ++ "\n""#),
"-r",
head.change_id.as_str(),
],
false,
true,
);
let Ok(latest_heads_res) = latest_heads_res else {
return self.get_head_latest(&self.get_current_head()?);
};
let latest_heads: Vec<Head> = latest_heads_res
.lines()
.map(parse_head)
.collect::<Result<Vec<Head>>>()?;
// If the current head exist, that means it wasn't updated
if let Some(head) = latest_heads.iter().find(|latest_head| latest_head == &head) {
return Ok(head.to_owned());
}
// Check obslog for each head. If the obslog contains the head's commit, it means
// there's a new commit for the head
for latest_head in latest_heads.iter() {
let parent_commits: Vec<ChangeId> = self
.execute_jj_command(
vec![
"obslog",
"--no-graph",
"--template",
r#"commit.change_id() ++ "\n""#,
"-r",
latest_head.commit_id.as_str(),
],
false,
true,
)
.context("Failed getting latest head parent commits")?
.lines()
.map(|line| ChangeId(line.to_owned()))
.collect();
if parent_commits
.iter()
.any(|parent_commit| parent_commit == &head.change_id)
{
return Ok(latest_head.to_owned());
}
}
bail!(
"Could not find head latest: {} {} {:?}",
head.change_id,
head.commit_id,
latest_heads
);
}
/// Get a commit's parent.
/// Maps to `jj log -r <revision>-`
#[instrument(level = "trace", skip(self))]
pub fn get_commit_parent(&self, commit_id: &CommitId) -> Result<Head> {
parse_head(
&self
.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
&format!(r#"{HEAD_TEMPLATE} ++ "\n""#),
"-r",
&format!("{commit_id}-"),
"--limit",
"1",
],
false,
true,
)
.with_context(|| format!("Failed getting commit parent: {commit_id}"))?
.remove_end_line(),
)
}
/// Get commit's description.
/// Maps to `jj log -r <revision> -T description`
#[instrument(level = "trace", skip(self))]
pub fn get_commit_description(&self, commit_id: &CommitId) -> Result<String> {
Ok(self
.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
"description",
"-r",
commit_id.as_str(),
"--limit",
"1",
],
false,
true,
)
.with_context(|| format!("Failed getting commit description: {commit_id}"))?
.remove_end_line())
}
/// Check if a revision is immutable
/// Maps to `jj log -r <revision> -T immutable`
#[instrument(level = "trace", skip(self))]
pub fn check_revision_immutable(&self, revision: &str) -> Result<bool> {
Ok(self
.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
"immutable",
"-r",
revision,
"--limit",
"1",
],
false,
true,
)
.with_context(|| format!("Failed checking if revision is immutable: {revision}"))?
.remove_end_line()
== "true")
}
/// Get bookmark head
/// Maps to `jj log -r <bookmark>[@<remote>]`
#[instrument(level = "trace", skip(self))]
pub fn get_bookmark_head(&self, bookmark: &Bookmark) -> Result<Head> {
parse_head(
&self
.execute_jj_command(
vec![
"log",
"--no-graph",
"--template",
&format!(r#"{HEAD_TEMPLATE} ++ "\n""#),
"-r",
&bookmark.to_string(),
"--limit",
"1",
],
false,
true,
)
.context("Failed getting bookmark head")?
.remove_end_line(),
)
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::commander::tests::TestRepo;
use insta::assert_debug_snapshot;
#[test]
fn get_log() -> Result<()> {
let test_repo = TestRepo::new()?;
let log = test_repo.commander.get_log(&None)?;
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"[k-z]{8} .*? [0-9a-fA-F]{8}", "[LINE]");
let _bound = settings.bind_to_scope();
assert_debug_snapshot!(log.graph);
assert!(log.graph_heads.iter().all(|graph_head| {
graph_head
.as_ref()
.is_none_or(|graph_head| log.heads.contains(graph_head))
}));
Ok(())
}
#[test]
fn get_commit_show() -> Result<()> {
let test_repo = TestRepo::new()?;
fs::write(test_repo.directory.path().join("README"), b"AAA")?;
let head = test_repo.commander.get_current_head()?;
let show =
test_repo
.commander
.get_commit_show(&head.commit_id, &DiffFormat::ColorWords, false)?;
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"Commit ID: [0-9a-fA-F]{40}", "Commit ID: [COMMIT_ID]");
settings.add_filter(r"Change ID: [k-z]{32}", "Change ID: [Change ID]");
settings.add_filter(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", "([DATE_TIME])");
let _bound = settings.bind_to_scope();
assert_debug_snapshot!(show);
Ok(())
}
#[test]
fn get_commit_parent() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
assert_eq!(
test_repo.commander.get_commit_parent(&head.commit_id)?,
Head {
commit_id: CommitId("0000000000000000000000000000000000000000".to_owned()),
change_id: ChangeId("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned()),
divergent: false,
immutable: true,
}
);
Ok(())
}
#[test]
fn get_head_latest() -> Result<()> {
let test_repo = TestRepo::new()?;
let old_head = test_repo.commander.get_current_head()?;
fs::write(test_repo.directory.path().join("README"), b"AAA")?;
let new_head = test_repo.commander.get_current_head()?;
assert_ne!(old_head, new_head);
assert_eq!(new_head, test_repo.commander.get_head_latest(&old_head)?);
Ok(())
}
#[test]
fn check_revision_immutable() -> Result<()> {
let test_repo = TestRepo::new()?;
assert!(!(test_repo.commander.check_revision_immutable("@")?));
Ok(())
}
#[test]
fn get_bookmark_head() -> Result<()> {
let test_repo = TestRepo::new()?;
let head = test_repo.commander.get_current_head()?;
// Git doesn't support bookmark pointing to root commit, so it will advance
let bookmark = test_repo.commander.create_bookmark("main")?;
assert_eq!(test_repo.commander.get_bookmark_head(&bookmark)?, head);
Ok(())
}
}
0707010000000E000081A400000000000000000000000168C1DE94000025F3000000000000000000000000000000000000002200000000lazyjj-0.6.1/src/commander/mod.rs/*!
This module contains all functions used to interact with jj via command
line execution.
The module has one primary struct: [`Commander`] which implements
several member functions that each call a jj command and handles the output.
Since the number of jj commands are quite high and some are quite complex,
the implementation is found in multiple source files. This is why you
will find multiple "impl Commander" sections in Commander, one for each source file.
This module implements the low level functions used by the
command implementation functions:
* [Commander::new] - Create a new instance
* [Commander::init] - Prepare for commands. This will panic if jj does not work
* [Commander::execute_command] - Execute any command and log the result
* [Commander::execute_jj_command] - Execute a jj command.
* [Commander::execute_void_jj_command] - Execute a jj command and discard the output.
*/
pub mod bookmarks;
pub mod files;
pub mod ids;
pub mod jj;
pub mod log;
use crate::env::DiffFormat;
use crate::env::Env;
use ansi_to_tui::IntoText;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local, TimeDelta};
use ratatui::{
style::{Color, Stylize},
text::{Line, Text},
};
use std::sync::Mutex;
use std::{
ffi::OsStr,
io,
process::{Command, Output},
string::FromUtf8Error,
sync::Arc,
};
use thiserror::Error;
use tracing::{instrument, trace};
use version_compare::{Cmp, compare};
/// The oldest version of jj that is known to work with lazyjj.
/// 0.33.0 changed the template language for evolog/obslog
const JJ_MIN_VERSION: &str = "0.33.0";
const JJ_VERSION_IGNORE_HELP: &str = "If you want to continue anyway, use --ignore-jj-version";
impl DiffFormat {
pub fn get_args(&self) -> Vec<&str> {
match self {
DiffFormat::ColorWords => vec!["--color-words"],
DiffFormat::Git => vec!["--git"],
DiffFormat::Summary => vec!["--summary"],
DiffFormat::Stat => vec!["--stat"],
DiffFormat::DiffTool(Some(tool)) => vec!["--tool", tool],
DiffFormat::DiffTool(None) => vec![],
}
}
}
#[derive(Debug, Error)]
pub enum CommandError {
#[error("Error getting output: {0}")]
Output(#[from] io::Error),
#[error("{0}")]
Status(String, Option<i32>),
#[error("Error parsing UTF-8 output: {0}")]
FromUtf8(#[from] FromUtf8Error),
}
impl CommandError {
#[expect(clippy::wrong_self_convention)]
pub fn into_text<'a>(&self, title: &'a str) -> Result<Text<'a>, ansi_to_tui::Error> {
let mut lines = vec![];
if !title.is_empty() {
lines.push(Line::raw(title).bold().fg(Color::Red));
lines.append(&mut vec![Line::raw(""), Line::raw("")]);
}
lines.append(&mut self.to_string().into_text()?.lines);
Ok(Text::from(lines))
}
}
#[derive(Clone, Debug)]
pub struct CommandLogItem {
pub program: String,
pub args: Vec<String>,
pub output: Arc<Result<Output>>,
pub time: DateTime<Local>,
pub duration: TimeDelta,
}
/// Struct used to interact with the jj cli using commanders.
///
/// Handles arguments and recording of history.
#[derive(Debug)]
pub struct Commander {
pub env: Env,
pub command_history: Arc<Mutex<Vec<CommandLogItem>>>,
// Used for testing
pub jj_config_toml: Option<Vec<String>>,
pub force_no_color: bool,
}
impl Commander {
pub fn new(env: &Env) -> Self {
Self {
env: env.clone(),
command_history: Arc::new(Mutex::new(Vec::new())),
jj_config_toml: None,
force_no_color: false,
}
}
/// Execute a command and record to history.
fn execute_command(&self, command: &mut Command) -> Result<String, CommandError> {
// Set current directory to root
command.current_dir(&self.env.root);
let program = command.get_program().to_str().unwrap_or("").to_owned();
let args: Vec<String> = command
.get_args()
.map(|arg| arg.to_str().unwrap_or("").to_owned())
.collect();
let time = Local::now();
let output = command.output();
let duration = Local::now() - time;
// unwrap is enough, because mutex can only poison in the case of push panic
self.command_history.lock().unwrap().push(CommandLogItem {
program,
args,
output: Arc::new(match output.as_ref() {
Ok(value) => Ok(value.clone()),
// Clone io::Error
Err(err) => Err(anyhow::Error::new(io::Error::new(
err.kind(),
err.to_string(),
))),
}),
time,
duration,
});
let output = output?;
if !output.status.success() {
// Return JjError if non-zero status code
return Err(CommandError::Status(
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code(),
));
}
Ok(String::from_utf8(output.stdout)?)
}
/// Execute a jj command with color/quiet arguments.
pub fn execute_jj_command<I, S>(
&self,
args: I,
color: bool,
quiet: bool,
) -> Result<String, CommandError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = Command::new(&self.env.jj_bin);
command.args(args);
command.args(get_output_args(!self.force_no_color && color, quiet));
if let Some(jj_config_toml) = &self.jj_config_toml {
for cfg in jj_config_toml {
command.args(["--config", cfg]);
}
}
self.execute_command(&mut command)
}
/// Execute a jj command without using the output.
pub fn execute_void_jj_command<I, S>(&self, args: I) -> Result<(), CommandError>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
// Since no result is used, enable color for command log
self.execute_jj_command(args, true, true)?;
Ok(())
}
#[instrument(level = "trace", skip(self))]
pub fn check_jj_version(&self) -> Result<()> {
// Ask jj about its version
let (color, quiet) = (false, false);
let found_version = self
.execute_jj_command(vec!["version"], color, quiet)
.context("Run jj version")?;
// Extract version number
if found_version[0..3] != *"jj " {
trace!("jj version output \"{}\"", found_version);
bail!("jj version string was not recognized");
}
let found_version = &found_version[3..].trim();
trace!(
found_version = found_version,
min_version = JJ_MIN_VERSION,
"Checking jj version",
);
// Verify that jj is not too old
match compare(found_version, JJ_MIN_VERSION) {
Err(_) => bail!(
"Unable to compare version '{found_version}' to '{JJ_MIN_VERSION}'\n{JJ_VERSION_IGNORE_HELP}"
),
Ok(Cmp::Lt) => bail!(
"jj version is too old ({found_version}). Must be at least {JJ_MIN_VERSION}\n{JJ_VERSION_IGNORE_HELP}"
),
Ok(_) => Ok(()), // found >= min, so jj is recent enough
}
}
}
pub trait RemoveEndLine {
fn remove_end_line(self) -> Self;
}
impl RemoveEndLine for String {
fn remove_end_line(mut self) -> Self {
if self.ends_with('\n') {
self.pop();
if self.ends_with('\r') {
self.pop();
}
}
self
}
}
pub fn get_output_args(color: bool, quiet: bool) -> Vec<String> {
vec![
"--no-pager",
"--color",
if color { "always" } else { "never" },
if quiet { "--quiet" } else { "" },
]
.into_iter()
.map(String::from)
.filter(|arg| !arg.is_empty())
.collect()
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::env::{Config, Env};
use tempdir::TempDir;
macro_rules! apply_common_filters {
{} => {
let mut settings = insta::Settings::clone_current();
// Change + commit IDs
settings.add_filter(r"[k-z]{8} [0-9a-fA-F]{8}", "[CHANGE_ID + COMMIT_ID]");
let _bound = settings.bind_to_scope();
}
}
pub struct TestRepo {
pub commander: Commander,
pub directory: TempDir,
}
impl TestRepo {
pub fn new() -> Result<Self> {
let directory = TempDir::new("lazyjj")?;
let jj_config_toml = vec![
r#"user.email="lazyjj@example.com""#.to_owned(),
r#"user.name="lazyjj""#.to_owned(),
r#"ui.color="never""#.to_owned(),
];
let jj_bin = "jj".to_string();
let env = Env {
root: directory.path().to_string_lossy().to_string(),
config: Config::default(),
default_revset: None,
jj_bin,
};
let mut commander = Commander::new(&env);
commander.jj_config_toml = Some(jj_config_toml);
commander.force_no_color = true;
commander.execute_void_jj_command(vec!["git", "init", "--colocate"])?;
Ok(Self {
directory,
commander,
})
}
}
#[test]
fn test_repo() -> Result<()> {
apply_common_filters!();
let test_repo = TestRepo::new()?;
test_repo
.commander
.execute_jj_command(vec!["status"], true, true)?;
Ok(())
}
}
0707010000000F000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/commander/snapshots07070100000010000081A400000000000000000000000168C1DE940000011E000000000000000000000000000000000000006100000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__bookmarks__tests__get_bookmark_show.snap---
source: src/commander/bookmarks.rs
expression: bookmark_show
---
"Commit ID: [COMMIT_ID]\nChange ID: [Change ID]\nBookmarks: test test@git\nAuthor : lazyjj <lazyjj@example.com> (([DATE_TIME]))\nCommitter: lazyjj <lazyjj@example.com> (([DATE_TIME]))\n\n (no description set)\n"
07070100000011000081A400000000000000000000000168C1DE940000012C000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-2.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::Git)?"
---
Some(
"diff --git a/README b/README\nnew file mode 100644\nindex 0000000000..43d88b6586\n--- /dev/null\n+++ b/README\n@@ -0,0 +1,1 @@\n+AAA\n\\ No newline at end of file\n",
)
07070100000012000081A400000000000000000000000168C1DE94000000C1000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-3.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::ColorWords)?"
---
Some(
"Modified regular file README:\n 1 1: AAABBB\n",
)
07070100000013000081A400000000000000000000000168C1DE9400000140000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-4.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::Git)?"
---
Some(
"diff --git a/README b/README\nindex 43d88b6586..f6d5afa370 100644\n--- a/README\n+++ b/README\n@@ -1,1 +1,1 @@\n-AAA\n\\ No newline at end of file\n+BBB\n\\ No newline at end of file\n",
)
07070100000014000081A400000000000000000000000168C1DE94000000C3000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-5.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::ColorWords)?"
---
Some(
"Modified regular file README2 (README => README2):\n",
)
07070100000015000081A400000000000000000000000168C1DE94000000CE000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-6.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::Git)?"
---
Some(
"diff --git a/README b/README2\nrename from README\nrename to README2\n",
)
07070100000016000081A400000000000000000000000168C1DE94000000BE000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-7.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::ColorWords)?"
---
Some(
"Removed regular file README2:\n 1 : BBB\n",
)
07070100000017000081A400000000000000000000000168C1DE9400000133000000000000000000000000000000000000005B00000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff-8.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::Git)?"
---
Some(
"diff --git a/README2 b/README2\ndeleted file mode 100644\nindex f6d5afa370..0000000000\n--- a/README2\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-BBB\n\\ No newline at end of file\n",
)
07070100000018000081A400000000000000000000000168C1DE94000000BB000000000000000000000000000000000000005900000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__files__tests__get_file_diff.snap---
source: src/commander/files.rs
expression: "test_repo.commander.get_file_diff(&head, &file, &DiffFormat::ColorWords)?"
---
Some(
"Added regular file README:\n 1: AAA\n",
)
07070100000019000081A400000000000000000000000168C1DE9400000121000000000000000000000000000000000000005900000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__log__tests__get_commit_show.snap---
source: src/commander/log.rs
expression: show
---
"Commit ID: [COMMIT_ID]\nChange ID: [Change ID]\nAuthor : lazyjj <lazyjj@example.com> (([DATE_TIME]))\nCommitter: lazyjj <lazyjj@example.com> (([DATE_TIME]))\n\n (no description set)\n\nAdded regular file README:\n 1: AAA"
0707010000001A000081A400000000000000000000000168C1DE9400000079000000000000000000000000000000000000005100000000lazyjj-0.6.1/src/commander/snapshots/lazyjj__commander__log__tests__get_log.snap---
source: src/commander/log.rs
expression: log.graph
---
"@ [LINE]\n│ (empty) (no description set)\n◆ [LINE]\n"
0707010000001B000081A400000000000000000000000168C1DE9400002273000000000000000000000000000000000000001800000000lazyjj-0.6.1/src/env.rsuse std::{path::PathBuf, process::Command};
use anyhow::{Context, Result, bail};
use ratatui::style::Color;
use serde::Deserialize;
use crate::{
commander::{RemoveEndLine, get_output_args},
keybinds::KeybindsConfig,
};
// TODO: After 0.18, remove Config and replace with JjConfig
#[derive(Deserialize, Debug, Clone, Default)]
pub struct Config {
#[serde(rename = "lazyjj.highlight-color")]
lazyjj_highlight_color: Option<Color>,
#[serde(rename = "lazyjj.diff-format")]
lazyjj_diff_format: Option<DiffFormat>,
#[serde(rename = "lazyjj.diff-tool")]
lazyjj_diff_tool: Option<String>,
#[serde(rename = "lazyjj.bookmark-prefix")]
lazyjj_bookmark_prefix: Option<String>,
#[serde(rename = "lazyjj.layout")]
lazyjj_layout: Option<JJLayout>,
#[serde(rename = "lazyjj.layout-percent")]
lazyjj_layout_percent: Option<u16>,
#[serde(rename = "lazyjj.keybinds")]
lazyjj_keybinds: Option<KeybindsConfig>,
#[serde(rename = "ui.diff.format")]
ui_diff_format: Option<DiffFormat>,
#[serde(rename = "ui.diff.tool")]
ui_diff_tool: Option<()>,
#[serde(rename = "git.push-bookmark-prefix")]
git_push_bookmark_prefix: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct JjConfig {
lazyjj: Option<JjConfigLazyjj>,
ui: Option<JjConfigUi>,
git: Option<JjConfigGit>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct JjConfigLazyjj {
highlight_color: Option<Color>,
diff_format: Option<DiffFormat>,
diff_tool: Option<String>,
bookmark_prefix: Option<String>,
layout: Option<JJLayout>,
layout_percent: Option<u16>,
keybinds: Option<KeybindsConfig>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct JjConfigUi {
diff: Option<JjConfigUiDiff>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct JjConfigUiDiff {
format: Option<DiffFormat>,
tool: Option<toml::Value>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub struct JjConfigGit {
push_bookmark_prefix: Option<String>,
}
impl Config {
pub fn diff_format(&self) -> DiffFormat {
let default = if let Some(diff_tool) = self.diff_tool() {
DiffFormat::DiffTool(diff_tool)
} else {
DiffFormat::ColorWords
};
self.lazyjj_diff_format
.clone()
.unwrap_or(self.ui_diff_format.clone().unwrap_or(default))
}
pub fn diff_tool(&self) -> Option<Option<String>> {
if let Some(diff_tool) = self.lazyjj_diff_tool.as_ref() {
return Some(Some(diff_tool.to_owned()));
}
if self.ui_diff_tool.is_some() {
return Some(None);
}
None
}
pub fn highlight_color(&self) -> Color {
self.lazyjj_highlight_color
.unwrap_or(Color::Rgb(50, 50, 150))
}
pub fn bookmark_prefix(&self) -> String {
self.lazyjj_bookmark_prefix.clone().unwrap_or(
self.git_push_bookmark_prefix
.clone()
.unwrap_or("push-".to_owned()),
)
}
pub fn layout(&self) -> JJLayout {
self.lazyjj_layout.unwrap_or(JJLayout::Horizontal)
}
pub fn layout_percent(&self) -> u16 {
self.lazyjj_layout_percent.unwrap_or(50)
}
pub fn keybinds(&self) -> Option<&KeybindsConfig> {
self.lazyjj_keybinds.as_ref()
}
}
#[derive(Debug, Clone)]
pub struct Env {
pub config: Config,
pub root: String,
pub default_revset: Option<String>,
pub jj_bin: String,
}
impl Env {
pub fn new(path: PathBuf, default_revset: Option<String>, jj_bin: String) -> Result<Env> {
// Get jj repository root
let root_output = Command::new(&jj_bin)
.arg("root")
.args(get_output_args(false, true))
.current_dir(&path)
.output()?;
if !root_output.status.success() {
bail!("No jj repository found in {}", path.to_str().unwrap_or(""))
}
let root = String::from_utf8(root_output.stdout)?.remove_end_line();
// Read/parse jj config
let config_toml = String::from_utf8(
Command::new(&jj_bin)
.arg("config")
.arg("list")
.arg("--template")
.arg("'\"' ++ name ++ '\"' ++ '=' ++ value ++ '\n'")
.args(get_output_args(false, true))
.current_dir(&root)
.output()
.context("Failed to get jj config")?
.stdout,
)?;
// Prior to https://github.com/martinvonz/jj/pull/3728, keys were not TOML-escaped.
let config = match toml::from_str::<Config>(&config_toml) {
Ok(config) => config,
Err(_) => {
let config_toml = String::from_utf8(
Command::new(&jj_bin)
.arg("config")
.arg("list")
.args(get_output_args(false, true))
.current_dir(&root)
.output()
.context("Failed to get jj config")?
.stdout,
)?;
toml::from_str::<JjConfig>(&config_toml)
.context("Failed to parse jj config")
.map(|config| Config {
lazyjj_highlight_color: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.highlight_color),
lazyjj_diff_format: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.diff_format.clone()),
lazyjj_diff_tool: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.diff_tool.clone()),
lazyjj_bookmark_prefix: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.bookmark_prefix.clone()),
lazyjj_layout: config.lazyjj.as_ref().and_then(|lazyjj| lazyjj.layout),
lazyjj_layout_percent: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.layout_percent),
lazyjj_keybinds: config
.lazyjj
.as_ref()
.and_then(|lazyjj| lazyjj.keybinds.clone()),
ui_diff_format: config
.ui
.as_ref()
.and_then(|ui| ui.diff.as_ref().and_then(|diff| diff.format.clone())),
ui_diff_tool: config.ui.as_ref().and_then(|ui| {
ui.diff
.as_ref()
.and_then(|diff| diff.tool.as_ref().map(|_| ()))
}),
git_push_bookmark_prefix: config
.git
.and_then(|git| git.push_bookmark_prefix),
})?
}
};
Ok(Env {
root,
config,
default_revset,
jj_bin,
})
}
}
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum DiffFormat {
#[default]
ColorWords,
Git,
DiffTool(Option<String>),
// Unused
Summary,
Stat,
}
impl DiffFormat {
pub fn get_next(&self, diff_tool: Option<Option<String>>) -> DiffFormat {
match self {
DiffFormat::ColorWords => DiffFormat::Git,
DiffFormat::Git => {
if let Some(diff_tool) = diff_tool {
DiffFormat::DiffTool(diff_tool)
} else {
DiffFormat::ColorWords
}
}
_ => DiffFormat::ColorWords,
}
}
}
#[derive(Clone, Debug, Deserialize, Default, Copy, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum JJLayout {
#[default]
Horizontal,
Vertical,
}
// Impl into for JJLayout to ratatui's Direction
impl From<JJLayout> for ratatui::layout::Direction {
fn from(layout: JJLayout) -> Self {
match layout {
JJLayout::Horizontal => ratatui::layout::Direction::Horizontal,
JJLayout::Vertical => ratatui::layout::Direction::Vertical,
}
}
}
0707010000001C000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000001A00000000lazyjj-0.6.1/src/keybinds0707010000001D000081A400000000000000000000000168C1DE94000005CD000000000000000000000000000000000000002400000000lazyjj-0.6.1/src/keybinds/config.rsuse super::Shortcut;
#[derive(Debug, Clone, serde::Deserialize)]
pub struct KeybindsConfig {
pub log_tab: Option<LogTabKeybindsConfig>,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(untagged)]
pub enum Keybind {
Single(Shortcut),
Multiple(Vec<Shortcut>),
Enable(bool),
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LogTabKeybindsConfig {
pub save: Option<Keybind>,
pub cancel: Option<Keybind>,
pub close_popup: Option<Keybind>,
pub scroll_down: Option<Keybind>,
pub scroll_up: Option<Keybind>,
pub scroll_down_half: Option<Keybind>,
pub scroll_up_half: Option<Keybind>,
pub focus_current: Option<Keybind>,
pub toggle_diff_format: Option<Keybind>,
pub refresh: Option<Keybind>,
pub create_new: Option<Keybind>,
pub create_new_describe: Option<Keybind>,
pub squash: Option<Keybind>,
pub squash_ignore_immutable: Option<Keybind>,
pub edit_change: Option<Keybind>,
pub edit_change_ignore_immutable: Option<Keybind>,
pub abandon: Option<Keybind>,
pub describe: Option<Keybind>,
pub edit_revset: Option<Keybind>,
pub set_bookmark: Option<Keybind>,
pub open_files: Option<Keybind>,
pub push: Option<Keybind>,
pub push_new: Option<Keybind>,
pub push_all: Option<Keybind>,
pub push_all_new: Option<Keybind>,
pub fetch: Option<Keybind>,
pub fetch_all: Option<Keybind>,
pub open_help: Option<Keybind>,
}
0707010000001E000081A400000000000000000000000168C1DE94000006DA000000000000000000000000000000000000002C00000000lazyjj-0.6.1/src/keybinds/keybinds_store.rsuse std::collections::HashMap;
use ratatui::crossterm::event::KeyEvent;
use super::{Keybind, Shortcut};
#[derive(Debug)]
pub struct KeybindsStore<A> {
shortcut_actions: HashMap<Shortcut, A>,
}
impl<A> KeybindsStore<A>
where
A: Clone + Eq,
{
pub fn match_event(&self, event: KeyEvent) -> Option<A> {
self.shortcut_actions
.get(&Shortcut::from_event(event))
.map(ToOwned::to_owned)
}
pub fn add_action(&mut self, shortcut: Shortcut, action: A) {
self.shortcut_actions.insert(shortcut, action);
}
pub fn get_shortcuts(&self, action: A) -> Vec<Shortcut> {
self.shortcut_actions
.iter()
.filter(|(_, a)| **a == action)
.map(|(s, _)| *s)
.collect()
}
pub fn replace_action_from_config(&mut self, action: A, key: &Keybind) {
// just ignore this case
if matches!(key, Keybind::Enable(true)) {
return;
}
self.remove_action(action.clone());
match key {
Keybind::Single(s) => self.add_action(*s, action),
Keybind::Multiple(list) => {
for s in list {
self.add_action(*s, action.clone());
}
}
// in case Enable(false) action is only removed
Keybind::Enable(_) => (),
}
}
/// Remove all shortcuts for specified action
fn remove_action(&mut self, action: A) {
self.shortcut_actions.retain(|_, a| action != *a);
}
pub fn len(&self) -> usize {
self.shortcut_actions.len()
}
}
impl<A> Default for KeybindsStore<A> {
fn default() -> Self {
Self {
shortcut_actions: HashMap::new(),
}
}
}
0707010000001F000081A400000000000000000000000168C1DE9400001B34000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/keybinds/log_tab.rsuse std::str::FromStr;
use ratatui::crossterm::event::KeyEvent;
use crate::{make_keybinds_help, set_keybinds, update_keybinds};
use super::{Shortcut, config::LogTabKeybindsConfig, keybinds_store::KeybindsStore};
#[derive(Debug)]
pub struct LogTabKeybinds {
// todo: probably split keys for different contexts, e.g when describe_textarea is opened
keys: KeybindsStore<LogTabEvent>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum LogTabEvent {
Save,
Cancel,
ClosePopup,
ScrollDown,
ScrollUp,
ScrollDownHalf,
ScrollUpHalf,
FocusCurrent,
ToggleDiffFormat,
Refresh,
CreateNew {
describe: bool,
},
Squash {
ignore_immutable: bool,
},
EditChange {
ignore_immutable: bool,
},
Abandon,
Describe,
EditRevset,
SetBookmark,
OpenFiles,
Push {
all_bookmarks: bool,
allow_new: bool,
},
Fetch {
all_remotes: bool,
},
OpenHelp,
Unbound,
}
impl Default for LogTabKeybinds {
fn default() -> Self {
let mut keys = KeybindsStore::<LogTabEvent>::default();
set_keybinds!(
keys,
LogTabEvent::Save => "ctrl+s",
LogTabEvent::Cancel => "esc",
LogTabEvent::ClosePopup => "q",
LogTabEvent::ScrollDown => "j",
LogTabEvent::ScrollDown => "down",
LogTabEvent::ScrollUp => "k",
LogTabEvent::ScrollUp => "up",
LogTabEvent::ScrollDownHalf => "shift+j",
LogTabEvent::ScrollUpHalf => "shift+k",
LogTabEvent::FocusCurrent => "@",
// todo: move to DetailsKeybindings
LogTabEvent::ToggleDiffFormat => "w",
LogTabEvent::Refresh => "shift+r",
LogTabEvent::Refresh => "f5",
LogTabEvent::CreateNew { describe: false } => "n",
LogTabEvent::CreateNew { describe: true } => "shift+n",
LogTabEvent::Squash { ignore_immutable: false } => "s",
LogTabEvent::Squash { ignore_immutable: true } => "shift+s",
LogTabEvent::EditChange { ignore_immutable: false } => "e",
LogTabEvent::EditChange { ignore_immutable: true } => "shift+e",
LogTabEvent::Abandon => "a",
LogTabEvent::Describe => "d",
LogTabEvent::EditRevset => "r",
LogTabEvent::SetBookmark => "b",
LogTabEvent::OpenFiles => "enter",
event_push(false, false) => "p",
event_push(false, true) => "ctrl+p",
event_push(true, false) => "shift+p",
event_push(true, true) => "ctrl+shift+p",
LogTabEvent::Fetch { all_remotes: false } => "f",
LogTabEvent::Fetch { all_remotes: true } => "shift+f",
LogTabEvent::OpenHelp => "?",
);
Self { keys }
}
}
impl LogTabKeybinds {
pub fn match_event(&self, event: KeyEvent) -> LogTabEvent {
if let Some(action) = self.keys.match_event(event) {
action
} else {
LogTabEvent::Unbound
}
}
pub fn extend_from_config(&mut self, config: &LogTabKeybindsConfig) {
update_keybinds!(
self.keys,
LogTabEvent::Save => config.save,
LogTabEvent::Cancel => config.cancel,
LogTabEvent::ClosePopup => config.close_popup,
LogTabEvent::ScrollDown => config.scroll_down,
LogTabEvent::ScrollUp => config.scroll_up,
LogTabEvent::ScrollDownHalf => config.scroll_down_half,
LogTabEvent::ScrollUpHalf => config.scroll_up_half,
LogTabEvent::FocusCurrent => config.focus_current,
LogTabEvent::ToggleDiffFormat => config.toggle_diff_format,
LogTabEvent::Refresh => config.refresh,
LogTabEvent::CreateNew { describe: false } => config.create_new,
LogTabEvent::CreateNew { describe: true } => config.create_new_describe,
LogTabEvent::Squash { ignore_immutable: false } => config.squash,
LogTabEvent::Squash { ignore_immutable: true } => config.squash_ignore_immutable,
LogTabEvent::EditChange { ignore_immutable: false } => config.edit_change,
LogTabEvent::EditChange { ignore_immutable: true } => config.edit_change_ignore_immutable,
LogTabEvent::Abandon => config.abandon,
LogTabEvent::Describe => config.describe,
LogTabEvent::EditRevset => config.edit_revset,
LogTabEvent::SetBookmark => config.set_bookmark,
LogTabEvent::OpenFiles => config.open_files,
event_push(false, false) => config.push,
event_push(false, true) => config.push_new,
event_push(true, false) => config.push_all,
event_push(true, true) => config.push_all_new,
LogTabEvent::Fetch { all_remotes: false } => config.fetch,
LogTabEvent::Fetch { all_remotes: true } => config.fetch_all,
LogTabEvent::OpenHelp => config.open_help,
);
}
pub fn make_main_panel_help(&self) -> Vec<(String, String)> {
make_keybinds_help!(
self.keys,
LogTabEvent::ScrollDown => "scroll down",
LogTabEvent::ScrollUp => "scroll up",
LogTabEvent::ScrollDownHalf => "scroll down by ½ page",
LogTabEvent::ScrollUpHalf => "scroll up by ½ page",
LogTabEvent::OpenFiles => "see files",
LogTabEvent::FocusCurrent => "current change",
LogTabEvent::EditRevset => "set revset",
LogTabEvent::Describe => "describe change",
LogTabEvent::EditChange { ignore_immutable: false } => "edit change",
LogTabEvent::EditChange { ignore_immutable: true } => "edit change ignoring immutability",
LogTabEvent::CreateNew { describe: false } => "new change",
LogTabEvent::CreateNew { describe: true } => "new with message",
LogTabEvent::Abandon => "abandon change",
LogTabEvent::Squash { ignore_immutable: false } => "squash @ into the selected change",
LogTabEvent::Squash { ignore_immutable: true } => "squash @ into the selected change ignoring immutability",
LogTabEvent::SetBookmark => "set bookmark",
LogTabEvent::Fetch { all_remotes: false } => "git fetch",
LogTabEvent::Fetch { all_remotes: true } => "git fetch all remotes",
event_push(false, false) => "git push",
event_push(false, true) => "git push with new bookmarks",
event_push(true, false) => "git push all bookmarks, except new",
event_push(true, true) => "git push all bookmarks",
)
}
}
fn event_push(all_bookmarks: bool, allow_new: bool) -> LogTabEvent {
LogTabEvent::Push {
all_bookmarks,
allow_new,
}
}
#[test]
fn test_log_tab_keybinds_default() {
let _ = LogTabKeybinds::default();
}
07070100000020000081A400000000000000000000000168C1DE9400001D01000000000000000000000000000000000000002100000000lazyjj-0.6.1/src/keybinds/mod.rsuse std::{fmt::Display, str::FromStr};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub use config::{Keybind, KeybindsConfig};
pub use log_tab::{LogTabEvent, LogTabKeybinds};
mod config;
mod keybinds_store;
mod log_tab;
/*#[derive(Debug)]
pub struct Keybinds {
log_tab: LogTabKeybinds,
}*/
/// Add keybindings to [`keybinds_store::KeybindsStore`]. Checks that shortcuts not duplicated
#[macro_export]
macro_rules! set_keybinds {
() => {};
($keys:ident, $($action:expr => $shortcut:literal),* $(,)?) => {
let mut __shortcuts_count = 0;
$(
$keys.add_action(Shortcut::from_str($shortcut).unwrap(), $action);
__shortcuts_count += 1;
)*
debug_assert_eq!(__shortcuts_count, $keys.len(), "shortcuts should not duplicate");
};
}
/// Replace keybindings in [`keybinds_store::KeybindsStore`] from config
#[macro_export]
macro_rules! update_keybinds {
() => {};
($keys:expr, $($action:expr => $config:expr),* $(,)?) => {
$(
if let Some(ref k) = $config {
$keys.replace_action_from_config($action, k);
}
)*
};
}
#[macro_export]
macro_rules! make_keybinds_help {
() => {};
($keys:expr, $($action:expr => $desc:literal),* $(,)?) => {
#[allow(clippy::vec_init_then_push)]
{
let mut res = vec![];
$(
let shortcuts = $keys.get_shortcuts($action)
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join("/");
if shortcuts.is_empty() {
res.push(("[disabled]".to_string(), $desc.to_string()));
} else {
res.push((shortcuts, $desc.to_string()));
}
)*
res
}
};
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde_with::DeserializeFromStr)]
pub struct Shortcut {
key: KeyCode,
modifiers: KeyModifiers,
}
impl Shortcut {
pub fn new_mod_key(modifiers: KeyModifiers, key: KeyCode) -> Self {
Self { key, modifiers }
}
pub fn from_event(event: KeyEvent) -> Self {
Self {
key: match event.code {
// when shift is pressed character is in upper case, so normalize it here
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
c => c,
},
modifiers: event.modifiers,
}
}
}
impl FromStr for Shortcut {
type Err = ShortcutParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut modifiers = KeyModifiers::empty();
let mut key = None;
for s in s.to_lowercase().split('+').map(|s| s.trim()) {
match s {
"ctrl" => modifiers |= KeyModifiers::CONTROL,
"shift" => modifiers |= KeyModifiers::SHIFT,
"enter" => key = Some(KeyCode::Enter),
"esc" => key = Some(KeyCode::Esc),
"left" => key = Some(KeyCode::Left),
"right" => key = Some(KeyCode::Right),
"up" => key = Some(KeyCode::Up),
"down" => key = Some(KeyCode::Down),
s if s.starts_with('f') && s.chars().count() > 1 => {
let s = s.trim_start_matches('f');
match s.parse::<u8>() {
Ok(k) => key = Some(KeyCode::F(k)),
Err(_) => return Err(ShortcutParseError::InvalidF),
}
}
s if s.chars().count() == 1 => {
let s = s.chars().next().unwrap();
key = Some(KeyCode::Char(s));
}
_ => (),
}
}
if let Some(key) = key {
Ok(Self::new_mod_key(modifiers, key))
} else {
Err(ShortcutParseError::NoKey)
}
}
}
impl Display for Shortcut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::with_capacity(3);
if self.modifiers.contains(KeyModifiers::CONTROL) {
parts.push("Control".to_string());
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
parts.push("Shift".to_string());
}
let k = match self.key {
KeyCode::Enter => "Enter".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::F(n) => format!("F{n}"),
KeyCode::Char(c) => c.to_string(),
KeyCode::Esc => "Esc".to_string(),
_ => "Unknown".to_string(),
};
parts.push(k);
parts.join("+").fmt(f)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ShortcutParseError {
#[error("invalid number after f")]
InvalidF,
#[error("no key specified")]
NoKey,
}
#[cfg(test)]
mod tests {
use super::*;
impl Shortcut {
pub fn new_mod_char(modifiers: KeyModifiers, key: char) -> Self {
Self::new_mod_key(modifiers, KeyCode::Char(key))
}
pub fn new_char(key: char) -> Self {
Self::new_mod_key(KeyModifiers::empty(), KeyCode::Char(key))
}
pub fn new_key(key: KeyCode) -> Self {
Self::new_mod_key(KeyModifiers::empty(), key)
}
}
#[test]
fn test_shortcut_from_str() {
let ctrl = KeyModifiers::CONTROL;
let shift = KeyModifiers::SHIFT;
let ctrl_shift = ctrl | shift;
let table = [
("q", Ok(Shortcut::new_char('q'))),
("Q", Ok(Shortcut::new_char('q'))),
("f", Ok(Shortcut::new_char('f'))),
("@", Ok(Shortcut::new_char('@'))),
("super+q", Ok(Shortcut::new_char('q'))),
("ctrl+q", Ok(Shortcut::new_mod_char(ctrl, 'q'))),
("ctrl+a+q", Ok(Shortcut::new_mod_char(ctrl, 'q'))),
("ctrl+Q", Ok(Shortcut::new_mod_char(ctrl, 'q'))),
("ctrl+ctrl+q", Ok(Shortcut::new_mod_char(ctrl, 'q'))),
("ctrl+shift+q", Ok(Shortcut::new_mod_char(ctrl_shift, 'q'))),
(
"ctrl+shift+f5",
Ok(Shortcut::new_mod_key(ctrl_shift, KeyCode::F(5))),
),
(
"ctrl+shift+f25",
Ok(Shortcut::new_mod_key(ctrl_shift, KeyCode::F(25))),
),
("enter", Ok(Shortcut::new_key(KeyCode::Enter))),
(
"ctrl+enter",
Ok(Shortcut::new_mod_key(ctrl, KeyCode::Enter)),
),
("esc", Ok(Shortcut::new_key(KeyCode::Esc))),
("left", Ok(Shortcut::new_key(KeyCode::Left))),
("right", Ok(Shortcut::new_key(KeyCode::Right))),
("up", Ok(Shortcut::new_key(KeyCode::Up))),
("down", Ok(Shortcut::new_key(KeyCode::Down))),
("ctrl+ff", Err(ShortcutParseError::InvalidF)),
("qq", Err(ShortcutParseError::NoKey)),
("", Err(ShortcutParseError::NoKey)),
];
for (s, expected) in table {
assert_eq!(
Shortcut::from_str(s),
expected,
"Shortcut::from_str(\"{s}\")"
);
}
}
}
07070100000021000081A400000000000000000000000168C1DE9400001C56000000000000000000000000000000000000001900000000lazyjj-0.6.1/src/main.rsextern crate thiserror;
use std::{
env::current_dir,
fs::{OpenOptions, canonicalize},
io::{self, ErrorKind},
process::Command,
time::Instant,
};
use anyhow::{Context, Result, bail};
use clap::Parser;
use ratatui::{
Terminal,
backend::{Backend, CrosstermBackend},
crossterm::{
event::{
self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture,
Event, KeyboardEnhancementFlags, MouseEvent, MouseEventKind,
PushKeyboardEnhancementFlags,
},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
},
layout::{Alignment, Rect},
widgets::Paragraph,
};
use tracing::{info, trace_span};
use tracing_chrome::ChromeLayerBuilder;
use tracing_subscriber::layer::SubscriberExt;
mod app;
mod commander;
mod env;
mod keybinds;
mod ui;
use crate::{
app::App,
commander::Commander,
env::Env,
ui::{ComponentAction, ui},
};
/// Simple program to greet a person
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Path to jj repo. Defaults to current directory
#[arg(short, long)]
path: Option<String>,
/// Default revset
#[arg(short, long)]
revisions: Option<String>,
/// Path to jj binary
#[arg(long, env = "JJ_BIN")]
jj_bin: Option<String>,
/// Do not exit if jj version check fails
#[arg(long)]
ignore_jj_version: bool,
}
fn main() -> Result<()> {
let should_log = std::env::var("LAZYJJ_LOG")
.map(|log| log == "1" || log.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let log_layer = if should_log {
let log_file = OpenOptions::new()
.append(true)
.create(true)
.open("lazyjj.log")
.unwrap();
Some(
tracing_subscriber::fmt::layer()
.compact()
.with_writer(log_file)
// Add log when span ends with their duration
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE),
)
} else {
None
};
let should_trace = std::env::var("LAZYJJ_TRACE")
.map(|log| log == "1" || log.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let (trace_layer, _guard) = if should_trace {
let (chrome_layer, _guard) = ChromeLayerBuilder::new().build();
(Some(chrome_layer), Some(_guard))
} else {
(None, None)
};
let subscriber = tracing_subscriber::Registry::default()
.with(log_layer)
.with(trace_layer);
tracing::subscriber::set_global_default(subscriber)?;
info!("Starting lazyjj");
// Parse arguments and determine path
let args = Args::parse();
let path = match args.path {
Some(path) => {
canonicalize(&path).with_context(|| format!("Could not find path {}", &path))?
}
None => current_dir()?,
};
let jj_bin = args.jj_bin.unwrap_or("jj".to_string());
// Check that jj exists
if let Err(err) = Command::new(&jj_bin).arg("help").output()
&& err.kind() == ErrorKind::NotFound
{
bail!(
"jj command not found. Please make sure it is installed: https://martinvonz.github.io/jj/latest/install-and-setup"
);
}
// Setup environment
let env = Env::new(path, args.revisions, jj_bin)?;
let mut commander = Commander::new(&env);
if !args.ignore_jj_version {
commander.check_jj_version()?;
}
// Setup app
let mut app = App::new(env.clone())?;
let mut terminal = setup_terminal()?;
install_panic_hook();
// Run app
let res = run_app(&mut terminal, &mut app, &mut commander);
restore_terminal()?;
res?;
Ok(())
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
commander: &mut Commander,
) -> Result<()> {
let mut start_time = Instant::now();
loop {
// Draw
let mut terminal_draw_res = Ok(());
terminal.draw(|f| {
// Update current tab
let update_span = trace_span!("update");
terminal_draw_res = update_span.in_scope(|| -> Result<()> {
if let Some(component_action) =
app.get_or_init_current_tab(commander)?.update(commander)?
{
app.handle_action(component_action, commander)?;
}
Ok(())
});
if terminal_draw_res.is_err() {
return;
}
let draw_span = trace_span!("draw");
terminal_draw_res = draw_span.in_scope(|| -> Result<()> {
ui(f, app)?;
{
let paragraph =
Paragraph::new(format!("{}ms", start_time.elapsed().as_millis()))
.alignment(Alignment::Right);
let position = Rect {
x: 0,
y: 1,
height: 1,
width: f.area().width - 1,
};
f.render_widget(paragraph, position);
}
Ok(())
});
})?;
terminal_draw_res?;
// Input
let input_spawn = trace_span!("input");
let event = loop {
match event::read()? {
event::Event::FocusLost => continue,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
..
}) => continue,
event => break event,
}
};
start_time = Instant::now();
let should_stop = input_spawn.in_scope(|| -> Result<bool> {
if app.input(event, commander)? {
return Ok(true);
}
Ok(false)
})?;
if should_stop {
return Ok(());
}
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableFocusChange
)?;
if supports_keyboard_enhancement()? {
execute!(
stdout,
// required to properly detect ctrl+shift
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
}
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
fn restore_terminal() -> Result<()> {
disable_raw_mode()?;
execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableFocusChange
)?;
Ok(())
}
fn install_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Err(err) = restore_terminal() {
eprintln!("Failed to restore terminal: {err}");
}
original_hook(info);
}));
}
enum ComponentInputResult {
Handled,
HandledAction(ComponentAction),
NotHandled,
}
07070100000022000041ED00000000000000000000000268C1DE9400000000000000000000000000000000000000000000001400000000lazyjj-0.6.1/src/ui07070100000023000081A400000000000000000000000168C1DE94000030B2000000000000000000000000000000000000002A00000000lazyjj-0.6.1/src/ui/bookmark_set_popup.rsuse ansi_to_tui::IntoText;
use anyhow::Result;
use anyhow::bail;
use ratatui::{
crossterm::event::{Event, KeyCode, KeyModifiers},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::{Span, Text},
widgets::{Block, BorderType, Borders, Clear, List, ListState, Paragraph},
};
use tui_textarea::TextArea;
use crate::{
ComponentInputResult,
commander::{
Commander,
bookmarks::Bookmark,
ids::{ChangeId, CommitId},
},
env::Config,
ui::{
Component, ComponentAction,
styles::create_popup_block,
utils::{centered_rect, centered_rect_line_height},
},
};
enum BookmarkSetOption {
CreateBookmark,
// Name, exists
GeneratedName(String, bool),
Bookmark(Bookmark),
Error(String),
}
pub struct BookmarkSetPopup<'a> {
pub change_id: Option<ChangeId>,
commit_id: CommitId,
options: Vec<BookmarkSetOption>,
list_state: ListState,
list_height: u16,
config: Config,
creating: Option<TextArea<'a>>,
tx: std::sync::mpsc::Sender<bool>,
}
fn generate_options(
commander: &mut Commander,
change_id: Option<&ChangeId>,
) -> Vec<BookmarkSetOption> {
let bookmarks = commander.get_bookmarks_list(false).map(|bookmarks| {
bookmarks
.into_iter()
.filter(|bookmark| bookmark.remote.is_none())
.collect::<Vec<Bookmark>>()
});
let mut options = vec![BookmarkSetOption::CreateBookmark];
if let Some(change_id) = change_id {
let generated_name = generate_name(&commander.env.config.bookmark_prefix(), change_id);
let exists = bookmarks.as_ref().is_ok_and(|bookmarks| {
bookmarks
.iter()
.any(|bookmark| bookmark.name == generated_name)
});
options.push(BookmarkSetOption::GeneratedName(generated_name, exists));
}
match bookmarks.as_ref() {
Ok(bookmarks) => {
for bookmark in bookmarks
.iter()
.filter(|bookmark| bookmark.remote.is_none())
{
options.push(BookmarkSetOption::Bookmark(bookmark.clone()))
}
}
Err(err) => options.push(BookmarkSetOption::Error(err.to_string())),
}
options
}
fn generate_name(git_push_bookmark_prefix: &str, change_id: &ChangeId) -> String {
let mut change_id = change_id.to_string();
change_id.truncate(12);
format!("{git_push_bookmark_prefix}{change_id}",)
}
impl BookmarkSetPopup<'_> {
pub fn new(
config: Config,
commander: &mut Commander,
change_id: Option<ChangeId>,
commit_id: CommitId,
tx: std::sync::mpsc::Sender<bool>,
) -> Self {
Self {
options: generate_options(commander, change_id.as_ref()),
change_id,
list_state: ListState::default().with_selected(Some(0)),
list_height: 0,
config,
commit_id,
creating: None,
tx,
}
}
fn scroll(&mut self, scroll: isize) {
self.list_state.select(Some(
self.list_state
.selected()
.map(|selected| selected.saturating_add_signed(scroll))
.unwrap_or(0)
.min(self.options.len().saturating_sub(1)),
));
}
fn on_creating(&mut self) {
self.creating = Some(TextArea::default());
}
fn create_bookmark(&self, commander: &mut Commander, name: &str) -> Result<()> {
if commander
.get_bookmarks_list(false)?
.iter()
.any(|bookmark| bookmark.name == name)
{
commander.set_bookmark_commit(name, &self.commit_id)?;
} else {
commander.create_bookmark_commit(name, &self.commit_id)?;
}
Ok(())
}
fn generate_bookmark(&self, commander: &mut Commander) -> Result<()> {
if let Some(change_id) = self.change_id.as_ref() {
let generated_name = generate_name(&commander.env.config.bookmark_prefix(), change_id);
if commander
.get_bookmarks_list(false)?
.iter()
.any(|bookmark| bookmark.name == generated_name)
{
commander.set_bookmark_commit(&generated_name, &self.commit_id)?;
} else {
commander.create_bookmark_commit(&generated_name, &self.commit_id)?;
}
Ok(())
} else {
bail!("No change ID");
}
}
}
impl Component for BookmarkSetPopup<'_> {
fn draw(&mut self, f: &mut ratatui::prelude::Frame<'_>, area: Rect) -> Result<()> {
if let Some(creating) = self.creating.as_ref() {
let block = create_popup_block("Create bookmark");
let area = centered_rect_line_height(area, 30, 5);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
f.render_widget(creating, popup_chunks[0]);
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
} else {
let block = Block::bordered()
.title(Span::styled(
" Select bookmark ",
Style::new().bold().cyan(),
))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let area = centered_rect(area, 40, 60);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
let list_items = self.options.iter().map(|option| match option {
BookmarkSetOption::CreateBookmark => {
Text::raw("(C)reate bookmark").fg(Color::Yellow)
}
BookmarkSetOption::GeneratedName(generated_name, exists) => {
let mut text = format!("(G)enerate bookmark: {generated_name}");
if *exists {
text.push_str(" (exists)");
}
Text::raw(text).fg(Color::Yellow)
}
BookmarkSetOption::Bookmark(bookmark) => {
Text::raw(bookmark.to_string()).fg(Color::Magenta)
}
BookmarkSetOption::Error(err) => err.into_text().unwrap(),
});
let list = List::new(list_items)
.scroll_padding(3)
.highlight_style(Style::default().bg(self.config.highlight_color()));
f.render_stateful_widget(list, popup_chunks[0], &mut self.list_state);
self.list_height = popup_chunks[0].height;
let help = Paragraph::new(vec!["j/k: scroll down/up | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
Ok(())
}
/// Handle input. Returns bool of if to close
fn input(
&mut self,
commander: &mut Commander,
event: Event,
) -> anyhow::Result<crate::ComponentInputResult> {
if let Some(creating) = self.creating.as_mut() {
if let Event::Key(key) = event {
match key.code {
_ if (key.code == KeyCode::Char('s')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| (key.code == KeyCode::Enter) =>
{
let name = &creating.lines().join("\n");
if name.trim().is_empty() {
return Ok(ComponentInputResult::Handled);
}
self.create_bookmark(commander, name)?;
self.tx.send(true)?;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
KeyCode::Esc => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
_ => {}
}
}
creating.input(event);
return Ok(ComponentInputResult::Handled);
}
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.scroll(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll(-1);
}
KeyCode::Char('J') => {
self.scroll(self.list_height as isize / 2);
}
KeyCode::Char('K') => {
self.scroll((self.list_height as isize / 2).saturating_neg());
}
KeyCode::Char('g') => {
self.generate_bookmark(commander)?;
self.tx.send(true)?;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
KeyCode::Char('c') => {
self.on_creating();
}
KeyCode::Enter => {
if let Some(action) = self
.list_state
.selected()
.and_then(|index| self.options.get(index))
{
match action {
BookmarkSetOption::CreateBookmark => {
self.on_creating();
}
BookmarkSetOption::GeneratedName(_, _) => {
self.generate_bookmark(commander)?;
self.tx.send(true)?;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
BookmarkSetOption::Bookmark(bookmark) => {
commander.set_bookmark_commit(&bookmark.name, &self.commit_id)?;
self.tx.send(true)?;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
BookmarkSetOption::Error(_) => {
self.options = generate_options(commander, self.change_id.as_ref());
}
}
}
}
KeyCode::Char('q') | KeyCode::Esc => {
self.tx.send(false)?;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
_ => return Ok(ComponentInputResult::NotHandled),
}
return Ok(ComponentInputResult::Handled);
}
Ok(ComponentInputResult::NotHandled)
}
}
07070100000024000081A400000000000000000000000168C1DE9400009E18000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/ui/bookmarks_tab.rs#![expect(clippy::borrow_interior_mutable_const)]
use crate::{
ComponentInputResult,
commander::{CommandError, Commander, bookmarks::BookmarkLine, ids::ChangeId},
env::{Config, DiffFormat},
ui::{
Component, ComponentAction,
details_panel::DetailsPanel,
help_popup::HelpPopup,
message_popup::MessagePopup,
utils::{centered_rect, centered_rect_line_height, tabs_to_spaces},
},
};
use ansi_to_tui::IntoText;
use anyhow::Result;
use ratatui::{
crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers},
prelude::*,
widgets::*,
};
use tracing::instrument;
use tui_confirm_dialog::{ButtonLabel, ConfirmDialog, ConfirmDialogState, Listener};
use tui_textarea::{CursorMove, TextArea};
struct CreateBookmark<'a> {
textarea: TextArea<'a>,
error: Option<anyhow::Error>,
}
struct RenameBookmark<'a> {
textarea: TextArea<'a>,
name: String,
error: Option<anyhow::Error>,
}
struct DeleteBookmark {
name: String,
}
struct ForgetBookmark {
name: String,
}
const DELETE_BRANCH_POPUP_ID: u16 = 1;
const FORGET_BRANCH_POPUP_ID: u16 = 2;
const NEW_POPUP_ID: u16 = 3;
const EDIT_POPUP_ID: u16 = 4;
/// Bookmarks tab. Shows bookmarks in main panel and selected bookmark current change in details panel.
pub struct BookmarksTab<'a> {
bookmarks_output: Result<Vec<BookmarkLine>, CommandError>,
bookmarks_list_state: ListState,
bookmarks_height: u16,
show_all: bool,
bookmark: Option<BookmarkLine>,
bookmark_panel: DetailsPanel,
bookmark_output: Option<Result<String, CommandError>>,
create: Option<CreateBookmark<'a>>,
rename: Option<RenameBookmark<'a>>,
delete: Option<DeleteBookmark>,
forget: Option<ForgetBookmark>,
describe_textarea: Option<TextArea<'a>>,
describe_after_new: bool,
describe_after_new_change: Option<ChangeId>,
edit_ignore_immutable: bool,
popup: ConfirmDialogState,
popup_tx: std::sync::mpsc::Sender<Listener>,
popup_rx: std::sync::mpsc::Receiver<Listener>,
diff_format: DiffFormat,
config: Config,
}
fn get_current_bookmark_index(
current_bookmark: Option<&BookmarkLine>,
bookmarks_output: &Result<Vec<BookmarkLine>, CommandError>,
) -> Option<usize> {
match bookmarks_output {
Ok(bookmarks_output) => current_bookmark.as_ref().and_then(|current_bookmark| {
bookmarks_output
.iter()
.position(|bookmark| match (current_bookmark, bookmark) {
(
BookmarkLine::Parsed {
bookmark: current_bookmark,
..
},
BookmarkLine::Parsed { bookmark, .. },
) => {
current_bookmark.name == bookmark.name
&& current_bookmark.remote == bookmark.remote
}
(
BookmarkLine::Unparsable(current_bookmark),
BookmarkLine::Unparsable(bookmark),
) => current_bookmark == bookmark,
_ => false,
})
}),
Err(_) => None,
}
}
impl BookmarksTab<'_> {
#[instrument(level = "trace", skip(commander))]
pub fn new(commander: &mut Commander) -> Result<Self> {
let diff_format = commander.env.config.diff_format();
let show_all = false;
let bookmarks_output = commander.get_bookmarks(show_all);
let bookmark = bookmarks_output
.as_ref()
.ok()
.and_then(|bookmarks_output| bookmarks_output.first())
.map(|bookmarks_output| bookmarks_output.to_owned());
let bookmarks_list_state = ListState::default().with_selected(get_current_bookmark_index(
bookmark.as_ref(),
&bookmarks_output,
));
let bookmark_output = bookmark.as_ref().and_then(|bookmark| match bookmark {
BookmarkLine::Parsed { bookmark, .. } => Some(
commander
.get_bookmark_show(bookmark, &diff_format, true)
.map(|diff| tabs_to_spaces(&diff)),
),
_ => None,
});
let (popup_tx, popup_rx) = std::sync::mpsc::channel();
Ok(Self {
bookmarks_output,
bookmark,
bookmarks_list_state,
bookmarks_height: 0,
show_all,
bookmark_panel: DetailsPanel::new(),
bookmark_output,
create: None,
rename: None,
delete: None,
forget: None,
describe_after_new: false,
describe_textarea: None,
describe_after_new_change: None,
edit_ignore_immutable: false,
popup: ConfirmDialogState::default(),
popup_tx,
popup_rx,
diff_format,
config: commander.env.config.clone(),
})
}
pub fn get_current_bookmark_index(&self) -> Option<usize> {
get_current_bookmark_index(self.bookmark.as_ref(), &self.bookmarks_output)
}
pub fn refresh_bookmarks(&mut self, commander: &mut Commander) {
self.bookmarks_output = commander.get_bookmarks(self.show_all);
}
pub fn refresh_bookmark(&mut self, commander: &mut Commander) {
self.bookmark_output = self.bookmark.as_ref().and_then(|bookmark| match bookmark {
BookmarkLine::Parsed { bookmark, .. } => Some(
commander
.get_bookmark_show(bookmark, &self.diff_format, true)
.map(|diff| tabs_to_spaces(&diff)),
),
_ => None,
});
self.bookmark_panel.scroll = 0;
}
fn scroll_bookmarks(&mut self, commander: &mut Commander, scroll: isize) {
let bookmarks = Vec::new();
let bookmarks = self.bookmarks_output.as_ref().unwrap_or(&bookmarks);
let current_bookmark_index = self.get_current_bookmark_index();
let next_bookmark = match current_bookmark_index {
Some(current_bookmark_index) => bookmarks.get(
current_bookmark_index
.saturating_add_signed(scroll)
.min(bookmarks.len() - 1),
),
None => bookmarks.first(),
}
.map(|x| x.to_owned());
if let Some(next_bookmark) = next_bookmark {
self.bookmark = Some(next_bookmark);
self.refresh_bookmark(commander);
}
}
}
impl Component for BookmarksTab<'_> {
fn focus(&mut self, commander: &mut Commander) -> Result<()> {
self.refresh_bookmarks(commander);
self.refresh_bookmark(commander);
Ok(())
}
fn update(&mut self, commander: &mut Commander) -> Result<Option<ComponentAction>> {
// Check for popup action
if let Ok(res) = self.popup_rx.try_recv()
&& res.1.unwrap_or(false)
{
match res.0 {
DELETE_BRANCH_POPUP_ID => {
if let Some(delete) = self.delete.as_ref() {
match commander.delete_bookmark(&delete.name) {
Ok(_) => {
self.refresh_bookmarks(commander);
let bookmarks = Vec::new();
let bookmarks =
self.bookmarks_output.as_ref().unwrap_or(&bookmarks);
self.bookmark =
bookmarks.first().map(|bookmark| bookmark.to_owned());
self.refresh_bookmark(commander);
}
Err(err) => {
return Ok(Some(ComponentAction::SetPopup(Some(Box::new(
MessagePopup {
title: "Delete error".into(),
messages: err.to_string().into_text()?,
text_align: None,
},
)))));
}
}
}
}
FORGET_BRANCH_POPUP_ID => {
if let Some(forget) = self.forget.as_ref() {
match commander.forget_bookmark(&forget.name) {
Ok(_) => {
self.refresh_bookmarks(commander);
let bookmarks = Vec::new();
let bookmarks =
self.bookmarks_output.as_ref().unwrap_or(&bookmarks);
self.bookmark =
bookmarks.first().map(|bookmark| bookmark.to_owned());
self.refresh_bookmark(commander);
}
Err(err) => {
return Ok(Some(ComponentAction::SetPopup(Some(Box::new(
MessagePopup {
title: "Forget error".into(),
messages: err.to_string().into_text()?,
text_align: None,
},
)))));
}
}
}
}
NEW_POPUP_ID => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
commander.run_new(&bookmark.to_string())?;
let head = commander.get_current_head()?;
if self.describe_after_new {
self.describe_after_new_change = Some(head.change_id);
self.describe_after_new = false;
let textarea = TextArea::default();
self.describe_textarea = Some(textarea);
return Ok(None);
} else {
return Ok(Some(ComponentAction::ViewLog(head)));
}
}
}
EDIT_POPUP_ID => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
commander.run_edit(&bookmark.to_string(), self.edit_ignore_immutable)?;
let head = commander.get_current_head()?;
return Ok(Some(ComponentAction::ViewLog(head)));
}
}
_ => {}
}
}
Ok(None)
}
fn draw(
&mut self,
f: &mut ratatui::prelude::Frame<'_>,
area: ratatui::prelude::Rect,
) -> Result<()> {
let chunks = Layout::default()
.direction(self.config.layout().into())
.constraints([
Constraint::Percentage(self.config.layout_percent()),
Constraint::Percentage(100 - self.config.layout_percent()),
])
.split(area);
// Draw bookmarks
{
let current_bookmark_index = self.get_current_bookmark_index();
let bookmark_lines: Vec<Line> = match self.bookmarks_output.as_ref() {
Ok(bookmarks_output) => bookmarks_output
.iter()
.enumerate()
.map(|(i, bookmark)| -> Result<Vec<Line>, ansi_to_tui::Error> {
let bookmark_text = bookmark.to_text()?;
Ok(bookmark_text
.iter()
.map(|line| {
let mut line = line.to_owned();
// Add padding at start
line.spans.insert(0, Span::from(" "));
if current_bookmark_index == Some(i) {
line = line.bg(self.config.highlight_color());
line.spans = line
.spans
.iter_mut()
.map(|span| {
span.to_owned().bg(self.config.highlight_color())
})
.collect();
}
line
})
.collect::<Vec<Line>>())
})
.collect::<Result<Vec<Vec<Line>>, ansi_to_tui::Error>>()?
.into_iter()
.flatten()
.collect(),
Err(err) => [
vec![Line::raw("Error getting bookmarks").bold().fg(Color::Red)],
// TODO: Remove when jj 0.20 is released
if let CommandError::Status(output, _) = err {
if output.contains("unexpected argument '-T' found") {
vec![
Line::raw(""),
Line::raw("Please update jj to >0.18 for -T support to bookmarks")
.bold()
.fg(Color::Red),
]
} else {
vec![]
}
} else {
vec![]
},
vec![Line::raw(""), Line::raw("")],
err.to_string().into_text()?.lines,
]
.concat(),
};
let lines = if bookmark_lines.is_empty() {
vec![Line::from(" No bookmarks").fg(Color::DarkGray).italic()]
} else {
bookmark_lines
};
let bookmarks_block = Block::bordered()
.title(" Bookmarks ")
.border_type(BorderType::Rounded);
self.bookmarks_height = bookmarks_block.inner(chunks[0]).height;
let bookmarks = List::new(lines).block(bookmarks_block).scroll_padding(3);
*self.bookmarks_list_state.selected_mut() = current_bookmark_index;
f.render_stateful_widget(bookmarks, chunks[0], &mut self.bookmarks_list_state);
}
// Draw bookmark
{
let title = if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref()
{
format!(" Bookmark {bookmark} ")
} else {
" Bookmark ".to_owned()
};
let bookmark_block = Block::bordered()
.title(title)
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1));
let bookmark_content: Vec<Line> = match self.bookmark_output.as_ref() {
Some(Ok(bookmark_output)) => bookmark_output.into_text()?.lines,
Some(Err(err)) => err.into_text("Error getting bookmark")?.lines,
None => vec![],
};
let bookmark = self
.bookmark_panel
.render(bookmark_content, bookmark_block.inner(chunks[1]))
.block(bookmark_block);
f.render_widget(bookmark, chunks[1]);
}
// Draw popup
if self.popup.is_opened() {
let popup = ConfirmDialog::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green))
.selected_button_style(
Style::default()
.bg(self.config.highlight_color())
.underlined(),
);
f.render_stateful_widget(popup, area, &mut self.popup);
}
// Draw create textarea
{
if let Some(create) = self.create.as_mut() {
let block = Block::bordered()
.title(Span::styled(
" Create bookmark ",
Style::new().bold().cyan(),
))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let error_lines = create
.error
.as_ref()
.map(|error| error.to_string().into_text().unwrap().lines);
let error_height = if let Some(error_lines) = error_lines.as_ref() {
error_lines.len() + 1
} else {
0
};
let area = centered_rect_line_height(area, 30, 5 + error_height as u16);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(error_height as u16),
Constraint::Length(2),
])
.split(block.inner(area));
f.render_widget(&create.textarea, popup_chunks[0]);
if let Some(error_lines) = error_lines {
let help = Paragraph::new(error_lines).block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[2]);
}
}
// Draw rename textarea
{
if let Some(rename) = self.rename.as_mut() {
let block = Block::bordered()
.title(Span::styled(
" Rename bookmark ",
Style::new().bold().cyan(),
))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let error_lines = rename
.error
.as_ref()
.map(|error| error.to_string().into_text().unwrap().lines);
let error_height = if let Some(error_lines) = error_lines.as_ref() {
error_lines.len() + 1
} else {
0
};
let area = centered_rect_line_height(area, 30, 5 + error_height as u16);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(error_height as u16),
Constraint::Length(2),
])
.split(block.inner(area));
f.render_widget(&rename.textarea, popup_chunks[0]);
if let Some(error_lines) = error_lines {
let help = Paragraph::new(error_lines).block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[2]);
}
}
// Draw describe textarea
{
if let Some(describe_textarea) = self.describe_textarea.as_mut() {
let block = Block::bordered()
.title(Span::styled(" Describe ", Style::new().bold().cyan()))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let area = centered_rect(area, 50, 50);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
f.render_widget(&*describe_textarea, popup_chunks[0]);
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
}
Ok(())
}
fn input(&mut self, commander: &mut Commander, event: Event) -> Result<ComponentInputResult> {
if let Some(create) = self.create.as_mut() {
if let Event::Key(key) = event {
match key.code {
_ if (key.code == KeyCode::Char('s')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| (key.code == KeyCode::Enter) =>
{
let name = create.textarea.lines().join("\n");
if name.trim().is_empty() {
create.error =
Some(anyhow::Error::msg("Bookmark name cannot be empty"));
return Ok(ComponentInputResult::Handled);
}
if let Err(err) = commander.create_bookmark(&name) {
create.error = Some(anyhow::Error::new(err));
return Ok(ComponentInputResult::Handled);
}
self.create = None;
self.refresh_bookmarks(commander);
// Select new bookmark
if let Some(bookmark) =
self.bookmarks_output
.as_ref()
.ok()
.and_then(|bookmarks_output| {
bookmarks_output.iter().find(|bookmark| match bookmark {
BookmarkLine::Unparsable(_) => false,
BookmarkLine::Parsed { bookmark, .. } => {
bookmark.name == name
}
})
})
{
self.bookmark = Some(bookmark.clone());
}
self.refresh_bookmark(commander);
return Ok(ComponentInputResult::Handled);
}
KeyCode::Esc => {
self.create = None;
return Ok(ComponentInputResult::Handled);
}
_ => {}
}
}
create.textarea.input(event);
return Ok(ComponentInputResult::Handled);
}
if let Some(rename) = self.rename.as_mut() {
if let Event::Key(key) = event {
match key.code {
_ if (key.code == KeyCode::Char('s')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| (key.code == KeyCode::Enter) =>
{
let new = rename.textarea.lines().join("\n");
if new.trim().is_empty() {
rename.error =
Some(anyhow::Error::msg("Bookmark name cannot be empty"));
return Ok(ComponentInputResult::Handled);
}
let old = rename.name.clone();
if let Err(err) = commander.rename_bookmark(&old, &new) {
rename.error = Some(anyhow::Error::new(err));
return Ok(ComponentInputResult::Handled);
}
self.rename = None;
self.refresh_bookmarks(commander);
// Select new bookmark
if let Some(bookmark) =
self.bookmarks_output
.as_ref()
.ok()
.and_then(|bookmarks_output| {
bookmarks_output.iter().find(|bookmark| match bookmark {
BookmarkLine::Unparsable(_) => false,
BookmarkLine::Parsed { bookmark, .. } => {
bookmark.name == new
}
})
})
{
self.bookmark = Some(bookmark.clone());
}
self.refresh_bookmark(commander);
return Ok(ComponentInputResult::Handled);
}
KeyCode::Esc => {
self.rename = None;
return Ok(ComponentInputResult::Handled);
}
_ => {}
}
}
rename.textarea.input(event);
return Ok(ComponentInputResult::Handled);
}
if let (Some(describe_textarea), Some(describe_after_new_change)) = (
self.describe_textarea.as_mut(),
self.describe_after_new_change.as_ref(),
) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// TODO: Handle error
commander.run_describe(
describe_after_new_change.as_str(),
&describe_textarea.lines().join("\n"),
)?;
self.describe_textarea = None;
self.describe_after_new_change = None;
return Ok(ComponentInputResult::HandledAction(
ComponentAction::ViewLog(commander.get_current_head()?),
));
}
KeyCode::Esc => {
self.describe_textarea = None;
self.describe_after_new_change = None;
return Ok(ComponentInputResult::Handled);
}
_ => {}
}
}
describe_textarea.input(event);
return Ok(ComponentInputResult::Handled);
}
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
return Ok(ComponentInputResult::Handled);
}
if self.popup.is_opened() {
if key.code == KeyCode::Char('q') || key.code == KeyCode::Esc {
self.popup = ConfirmDialogState::default();
} else {
self.popup.handle(&key);
}
return Ok(ComponentInputResult::Handled);
}
if self.bookmark_panel.input(key) {
return Ok(ComponentInputResult::Handled);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.scroll_bookmarks(commander, 1),
KeyCode::Char('k') | KeyCode::Up => self.scroll_bookmarks(commander, -1),
KeyCode::Char('J') => {
self.scroll_bookmarks(commander, self.bookmarks_height as isize / 2);
}
KeyCode::Char('K') => {
self.scroll_bookmarks(
commander,
(self.bookmarks_height as isize / 2).saturating_neg(),
);
}
KeyCode::Char('w') => {
self.diff_format = self.diff_format.get_next(self.config.diff_tool());
self.refresh_bookmark(commander);
}
KeyCode::Char('R') | KeyCode::F(5) => {
self.refresh_bookmarks(commander);
self.refresh_bookmark(commander);
}
KeyCode::Char('a') => {
self.show_all = !self.show_all;
self.refresh_bookmarks(commander);
}
KeyCode::Char('c') => {
let textarea = TextArea::default();
self.create = Some(CreateBookmark {
textarea,
error: None,
});
return Ok(ComponentInputResult::Handled);
}
KeyCode::Char('r') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
let mut textarea = TextArea::new(vec![bookmark.name.clone()]);
textarea.move_cursor(CursorMove::End);
self.rename = Some(RenameBookmark {
textarea,
name: bookmark.name.clone(),
error: None,
});
return Ok(ComponentInputResult::Handled);
}
}
KeyCode::Char('d') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
self.delete = Some(DeleteBookmark {
name: bookmark.name.clone(),
});
self.popup = ConfirmDialogState::new(
DELETE_BRANCH_POPUP_ID,
Span::styled(" Delete ", Style::new().bold().cyan()),
Text::from(vec![Line::from(format!(
"Are you sure you want to delete the {} bookmark?",
bookmark.name
))]),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
}
}
KeyCode::Char('f') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
self.forget = Some(ForgetBookmark {
name: bookmark.name.clone(),
});
self.popup = ConfirmDialogState::new(
FORGET_BRANCH_POPUP_ID,
Span::styled(" Forget ", Style::new().bold().cyan()),
Text::from(vec![Line::from(format!(
"Are you sure you want to forget the {} bookmark?",
bookmark.name
))]),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
}
}
// TODO: Ask for confirmation?
KeyCode::Char('t') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref()
&& bookmark.remote.is_some()
&& bookmark.present
{
commander.track_bookmark(bookmark)?;
self.refresh_bookmarks(commander);
self.refresh_bookmark(commander);
}
}
KeyCode::Char('T') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref()
&& bookmark.remote.is_some()
&& bookmark.present
{
commander.untrack_bookmark(bookmark)?;
self.refresh_bookmarks(commander);
self.refresh_bookmark(commander);
}
}
KeyCode::Char('n') | KeyCode::Char('N') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref()
&& bookmark.present
{
self.popup = ConfirmDialogState::new(
NEW_POPUP_ID,
Span::styled(" New ", Style::new().bold().cyan()),
Text::from(vec![
Line::from("Are you sure you want to create a new change?"),
Line::from(format!("Bookmark: {bookmark}")),
]),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
self.describe_after_new = key.code == KeyCode::Char('N');
}
}
KeyCode::Char('e') | KeyCode::Char('E') => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref() {
let ignore_immutable = key.code == KeyCode::Char('E');
if bookmark.present {
if commander.check_revision_immutable(&bookmark.to_string())?
&& !ignore_immutable
{
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Edit".into(),
messages: vec![
"The change cannot be edited because it is immutable."
.into(),
]
.into(),
text_align: None,
}))),
));
}
self.popup = ConfirmDialogState::new(
EDIT_POPUP_ID,
Span::styled(" Edit ", Style::new().bold().cyan()),
Text::from(vec![
Line::from("Are you sure you want to edit an existing change?"),
Line::from(format!("Bookmark: {bookmark}")),
]),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
self.edit_ignore_immutable = ignore_immutable;
}
}
}
KeyCode::Enter => {
if let Some(BookmarkLine::Parsed { bookmark, .. }) = self.bookmark.as_ref()
&& bookmark.present
{
return Ok(ComponentInputResult::HandledAction(
ComponentAction::ViewLog(commander.get_bookmark_head(bookmark)?),
));
}
}
KeyCode::Char('?') => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(HelpPopup::new(
vec![
("j/k".to_owned(), "scroll down/up".to_owned()),
("J/K".to_owned(), "scroll down by ½ page".to_owned()),
("a".to_owned(), "show all remotes".to_owned()),
("c".to_owned(), "create bookmark".to_owned()),
("r".to_owned(), "rename bookmark".to_owned()),
("d/f".to_owned(), "delete/forget bookmark".to_owned()),
("t/T".to_owned(), "track/untrack bookmark".to_owned()),
("Enter".to_owned(), "view in log".to_owned()),
("n".to_owned(), "new from bookmark".to_owned()),
("N".to_owned(), "new and describe".to_owned()),
("e".to_owned(), "edit bookmark".to_owned()),
],
vec![
("Ctrl+e/Ctrl+y".to_owned(), "scroll down/up".to_owned()),
(
"Ctrl+d/Ctrl+u".to_owned(),
"scroll down/up by ½ page".to_owned(),
),
(
"Ctrl+f/Ctrl+b".to_owned(),
"scroll down/up by page".to_owned(),
),
("w".to_owned(), "toggle diff format".to_owned()),
("W".to_owned(), "toggle wrapping".to_owned()),
],
)))),
));
}
_ => return Ok(ComponentInputResult::NotHandled),
};
}
Ok(ComponentInputResult::Handled)
}
}
07070100000025000081A400000000000000000000000168C1DE9400002A92000000000000000000000000000000000000002700000000lazyjj-0.6.1/src/ui/command_log_tab.rsuse std::borrow::Borrow;
use anyhow::Result;
use ansi_to_tui::IntoText;
use ratatui::{
crossterm::event::{Event, KeyCode, KeyEventKind},
prelude::*,
widgets::*,
};
use tracing::instrument;
use crate::{
ComponentInputResult,
commander::{CommandLogItem, Commander},
env::Config,
ui::{
Component, ComponentAction, details_panel::DetailsPanel, help_popup::HelpPopup,
utils::tabs_to_spaces,
},
};
/// Command log tab. Shows list of commands exectured by lazyjj in main panel and selected command
/// output in details panel
pub struct CommandLogTab {
command_history: Vec<CommandLogItem>,
commands_list_state: ListState,
commands_height: u16,
output_panel: DetailsPanel,
config: Config,
}
impl CommandLogTab {
#[instrument(level = "trace", skip(commander))]
pub fn new(commander: &mut Commander) -> Result<Self> {
let command_history = commander.command_history.lock().unwrap().clone();
let selected_index = command_history.first().map(|_| 0);
let commands_list_state = ListState::default().with_selected(selected_index);
Ok(Self {
commands_height: 0,
commands_list_state,
command_history,
output_panel: DetailsPanel::new(),
config: commander.env.config.clone(),
})
}
pub fn get_output_lines<'a>(&self) -> Result<Vec<Line<'a>>> {
let mut output_lines = vec![];
if let Some(command) = self
.commands_list_state
.selected()
.and_then(|selected_index| self.command_history.iter().rev().nth(selected_index))
{
match command.output.clone().borrow() {
Ok(output) => {
output_lines.push(Line::default().spans([
"Command: ".into(),
Span::raw(command.program.to_owned()).fg(Color::Blue),
" ".into(),
command.args.join(" ").fg(Color::Blue),
]));
output_lines.push(Line::default().spans([
("Status code: ").into(),
output.status.code().map_or("?".into(), |code| {
Span::raw(code.to_string()).fg(if code > 0 {
Color::Red
} else {
Color::Yellow
})
}),
]));
output_lines.push(
Line::default().spans([
Span::raw("Time: "),
Span::raw(command.time.format("%Y-%m-%d %H:%M:%S").to_string())
.fg(Color::Cyan),
]),
);
output_lines.push(
Line::default().spans([
Span::raw("Duration: "),
Span::raw(format!("{}ms", command.duration.num_milliseconds()))
.fg(Color::Cyan),
]),
);
output_lines.push(Line::default());
let mut has_output = false;
let stdout = &mut String::from_utf8_lossy(&output.stdout);
if !(stdout.is_empty()) {
output_lines.push(
Line::default().spans([Span::raw("Output:").fg(Color::Green).bold()]),
);
output_lines.push(Line::default());
output_lines.append(&mut tabs_to_spaces(stdout).into_text()?.lines);
has_output = true;
}
let stderr = &mut String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() && !stderr.is_empty() {
output_lines.push(Line::default());
output_lines.push(Line::default());
}
if !(stderr.is_empty()) {
output_lines.push(
Line::default()
.spans([Span::raw("Error output:").fg(Color::Green).bold()]),
);
output_lines.push(Line::default());
output_lines.append(&mut stderr.as_ref().into_text()?.lines);
has_output = true;
}
if !has_output {
output_lines.push(
Line::default()
.spans([Span::raw("No output").fg(Color::DarkGray).italic()]),
);
}
}
Err(err) => {
output_lines.push(Line::default().spans(["Error: ".into(), err.to_string()]))
}
}
}
Ok(output_lines)
}
fn scroll_commands(&mut self, scroll: isize) {
*self.commands_list_state.selected_mut() = Some(
(self
.commands_list_state
.selected()
.map(|selected_index| selected_index.saturating_add_signed(scroll))
.unwrap_or(0))
.min(self.command_history.len() - 1)
.max(0),
);
self.output_panel.scroll = 0;
}
}
impl Component for CommandLogTab {
fn focus(&mut self, commander: &mut Commander) -> Result<()> {
let command_history = commander.command_history.lock().unwrap().clone();
let selected_index = command_history.first().map(|_| 0);
self.commands_list_state.select(selected_index);
self.command_history = command_history;
Ok(())
}
fn draw(
&mut self,
f: &mut ratatui::prelude::Frame<'_>,
area: ratatui::prelude::Rect,
) -> Result<()> {
let chunks = Layout::default()
.direction(self.config.layout().into())
.constraints([
Constraint::Percentage(self.config.layout_percent()),
Constraint::Percentage(100 - self.config.layout_percent()),
])
.split(area);
// Draw commands
{
let command_lines = self
.command_history
.iter()
.rev()
.enumerate()
.map(|(i, command)| {
let mut line = Line::default()
.spans([
" ".into(),
Span::raw(command.program.clone()),
" ".into(),
command.args.join(" ").into(),
])
.fg(
if command
.output
.as_ref()
.as_ref()
.is_ok_and(|output| output.status.success())
{
Color::Blue
} else {
Color::Red
},
);
if self.commands_list_state.selected() == Some(i) {
line = line.bg(self.config.highlight_color());
}
line
})
.collect::<Vec<Line>>();
let commands = List::new(command_lines)
.block(
Block::bordered()
.title(" Commands ")
.border_type(BorderType::Rounded),
)
.scroll_padding(3);
f.render_stateful_widget(commands, chunks[0], &mut self.commands_list_state);
self.commands_height = chunks[0].height.saturating_sub(2);
}
// Draw output
{
let output_block = Block::bordered()
.title(" Output ")
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1));
let output = self
.output_panel
.render(self.get_output_lines()?, output_block.inner(chunks[1]))
.block(output_block);
f.render_widget(output, chunks[1]);
}
Ok(())
}
fn input(&mut self, _commander: &mut Commander, event: Event) -> Result<ComponentInputResult> {
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
return Ok(ComponentInputResult::Handled);
}
if self.output_panel.input(key) {
return Ok(ComponentInputResult::Handled);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.scroll_commands(1);
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll_commands(-1);
}
KeyCode::Char('J') => {
self.scroll_commands(self.commands_height as isize / 2);
}
KeyCode::Char('K') => {
self.scroll_commands((self.commands_height as isize / 2).saturating_neg());
}
KeyCode::Char('@') => {
self.scroll_commands(isize::MIN);
}
KeyCode::Char('?') => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(HelpPopup::new(
vec![
("j/k".to_owned(), "scroll down/up".to_owned()),
("J/K".to_owned(), "scroll down by ½ page".to_owned()),
("@".to_owned(), "latest command".to_owned()),
],
vec![
("Ctrl+e/Ctrl+y".to_owned(), "scroll down/up".to_owned()),
(
"Ctrl+d/Ctrl+u".to_owned(),
"scroll down/up by ½ page".to_owned(),
),
(
"Ctrl+f/Ctrl+b".to_owned(),
"scroll down/up by page".to_owned(),
),
("W".to_owned(), "toggle wrapping".to_owned()),
],
)))),
));
}
_ => return Ok(ComponentInputResult::NotHandled),
};
}
Ok(ComponentInputResult::Handled)
}
}
07070100000026000081A400000000000000000000000168C1DE9400001344000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/ui/command_popup.rsuse anyhow::{Context, Result};
use ratatui::{
crossterm::event::{Event, KeyCode},
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Style, Stylize},
text::Span,
widgets::{Block, BorderType, Borders, Clear, Paragraph},
};
use shell_words::split;
use tui_textarea::TextArea;
use crate::{
ComponentInputResult,
commander::Commander,
ui::{
Component, ComponentAction, message_popup::MessagePopup, utils::centered_rect_line_height,
},
};
pub struct CommandPopup<'a> {
command_textarea: TextArea<'a>,
}
impl CommandPopup<'_> {
pub fn new() -> Self {
Self {
command_textarea: TextArea::new(vec![]),
}
}
}
impl Component for CommandPopup<'_> {
fn draw(
&mut self,
f: &mut ratatui::Frame<'_>,
area: ratatui::prelude::Rect,
) -> anyhow::Result<()> {
let block = Block::bordered()
.title(Span::styled(" Command ", Style::new().bold().cyan()))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let area = centered_rect_line_height(area, 60, 5);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
f.render_widget(&self.command_textarea, popup_chunks[0]);
let help = Paragraph::new(vec!["Enter: run | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
Ok(())
}
fn input(
&mut self,
commander: &mut Commander,
event: Event,
) -> anyhow::Result<ComponentInputResult> {
if let Event::Key(key) = event {
match key.code {
KeyCode::Enter => {
let command_input = self.command_textarea.lines().join(" ");
let mut command_input = command_input.as_str();
if command_input.trim().is_empty() {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
if command_input == "jj" {
command_input = "";
}
command_input = command_input.trim_start_matches("jj ");
let res: Result<String> = split(command_input)
.context("Failed to split command input")
.and_then(|command| {
// TODO: Support color. PopupMessage (used by MessagePopup) breaks when colored
Ok(commander.execute_jj_command(command, false, false)?)
});
let message = match res {
Ok(str) => str,
Err(err) => [
format!("Failed to execute jj command: jj {command_input}"),
String::new(),
err.to_string(),
]
.join("\n"),
};
if message.trim().is_empty() {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::Multiple(vec![
ComponentAction::SetPopup(None),
ComponentAction::RefreshTab(),
]),
));
}
return Ok(ComponentInputResult::HandledAction(
ComponentAction::Multiple(vec![
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: format!("jj {command_input}").into(),
messages: message.into(),
text_align: Alignment::Left.into(),
}))),
ComponentAction::RefreshTab(),
]),
));
}
KeyCode::Esc => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(None),
));
}
_ => {}
}
};
self.command_textarea.input(event);
Ok(ComponentInputResult::Handled)
}
}
07070100000027000081A400000000000000000000000168C1DE9400000D5B000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/ui/details_panel.rsuse ratatui::{
crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
text::Text,
widgets::{Paragraph, Wrap},
};
/// Details panel used for the right side of each tab.
/// This handles scrolling and wrapping.
pub struct DetailsPanel {
pub scroll: u16,
height: u16,
lines: u16,
wrap: bool,
}
/// Commands that can be handled by the details panel
pub enum DetailsPanelEvent {
ScrollDown,
ScrollUp,
ScrollDownHalfPage,
ScrollUpHalfPage,
ScrollDownPage,
ScrollUpPage,
ToggleWrap,
}
impl DetailsPanel {
pub fn new() -> Self {
Self {
scroll: 0,
height: 0,
lines: 0,
wrap: true,
}
}
/// Render the parent into the area.
pub fn render<'a, T>(&mut self, content: T, area: Rect) -> Paragraph<'a>
where
T: Into<Text<'a>>,
{
let mut paragraph = Paragraph::new(content);
if self.wrap {
paragraph = paragraph.wrap(Wrap { trim: false });
}
self.height = area.height;
self.lines = paragraph.line_count(area.width) as u16;
paragraph = paragraph.scroll((self.scroll.min(self.lines.saturating_sub(1)), 0));
paragraph
}
pub fn scroll(&mut self, scroll: isize) {
self.scroll = (self.scroll.saturating_add_signed(scroll as i16)).min(self.lines - 1)
}
pub fn handle_event(&mut self, details_panel_event: DetailsPanelEvent) {
match details_panel_event {
DetailsPanelEvent::ScrollDown => self.scroll(1),
DetailsPanelEvent::ScrollUp => self.scroll(-1),
DetailsPanelEvent::ScrollDownHalfPage => self.scroll(self.height as isize / 2),
DetailsPanelEvent::ScrollUpHalfPage => {
self.scroll((self.height as isize / 2).saturating_neg())
}
DetailsPanelEvent::ScrollDownPage => self.scroll(self.height as isize),
DetailsPanelEvent::ScrollUpPage => self.scroll((self.height as isize).saturating_neg()),
DetailsPanelEvent::ToggleWrap => self.wrap = !self.wrap,
}
}
/// Handle input. Returns bool of if event was handled
pub fn input(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollDown)
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollUp)
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollDownHalfPage)
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollUpHalfPage)
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollDownPage)
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.handle_event(DetailsPanelEvent::ScrollUpPage)
}
KeyCode::Char('W') => self.handle_event(DetailsPanelEvent::ToggleWrap),
_ => return false,
};
true
}
}
07070100000028000081A400000000000000000000000168C1DE9400003734000000000000000000000000000000000000002100000000lazyjj-0.6.1/src/ui/files_tab.rsuse std::vec;
use anyhow::Result;
use tracing::instrument;
use crate::{
ComponentInputResult,
commander::{
CommandError, Commander,
files::{Conflict, File},
log::Head,
},
env::{Config, DiffFormat},
ui::{
Component, ComponentAction, details_panel::DetailsPanel, help_popup::HelpPopup,
message_popup::MessagePopup, utils::tabs_to_spaces,
},
};
use ansi_to_tui::IntoText;
use ratatui::{
crossterm::event::{Event, KeyCode, KeyEventKind},
prelude::*,
widgets::*,
};
/// Files tab. Shows files in selected change in main panel and selected file diff in details panel
pub struct FilesTab {
head: Head,
is_current_head: bool,
files_output: Result<Vec<File>, CommandError>,
conflicts_output: Vec<Conflict>,
files_list_state: ListState,
files_height: u16,
pub file: Option<File>,
diff_panel: DetailsPanel,
diff_output: Result<Option<String>, CommandError>,
diff_format: DiffFormat,
config: Config,
}
fn get_current_file_index(
current_file: Option<&File>,
files_output: Result<&Vec<File>, &CommandError>,
) -> Option<usize> {
if let (Some(current_file), Ok(files_output)) = (current_file, files_output)
&& let Some(path) = current_file.path.as_ref()
{
return files_output
.iter()
.position(|file| file.path.as_ref() == Some(path));
}
None
}
impl FilesTab {
#[instrument(level = "trace", skip(commander))]
pub fn new(commander: &mut Commander, head: &Head) -> Result<Self> {
let head = head.clone();
let is_current_head = head == commander.get_current_head()?;
let diff_format = commander.env.config.diff_format();
let files_output = commander.get_files(&head);
let conflicts_output = commander.get_conflicts(&head.commit_id)?;
let current_file = files_output
.as_ref()
.ok()
.and_then(|files_output| files_output.first())
.map(|file| file.to_owned());
let diff_output = current_file
.as_ref()
.map(|current_change| {
commander.get_file_diff(&head, current_change, &diff_format, true)
})
.map_or(Ok(None), |r| {
r.map(|diff| diff.map(|diff| tabs_to_spaces(&diff)))
});
let files_list_state = ListState::default().with_selected(get_current_file_index(
current_file.as_ref(),
files_output.as_ref(),
));
Ok(Self {
head,
is_current_head,
files_output,
file: current_file,
files_list_state,
files_height: 0,
conflicts_output,
diff_output,
diff_format,
diff_panel: DetailsPanel::new(),
config: commander.env.config.clone(),
})
}
pub fn set_head(&mut self, commander: &mut Commander, head: &Head) -> Result<()> {
self.head = head.clone();
self.is_current_head = self.head == commander.get_current_head()?;
self.refresh_files(commander)?;
self.file = self
.files_output
.as_ref()
.ok()
.and_then(|files_output| files_output.first())
.map(|file| file.to_owned());
self.refresh_diff(commander)?;
Ok(())
}
pub fn get_current_file_index(&self) -> Option<usize> {
get_current_file_index(self.file.as_ref(), self.files_output.as_ref())
}
pub fn refresh_files(&mut self, commander: &mut Commander) -> Result<()> {
self.files_output = commander.get_files(&self.head);
self.conflicts_output = commander.get_conflicts(&self.head.commit_id)?;
Ok(())
}
pub fn refresh_diff(&mut self, commander: &mut Commander) -> Result<()> {
self.diff_output = self
.file
.as_ref()
.map(|current_file| {
commander.get_file_diff(&self.head, current_file, &self.diff_format, true)
})
.map_or(Ok(None), |r| {
r.map(|diff| diff.map(|diff| tabs_to_spaces(&diff)))
});
self.diff_panel.scroll = 0;
Ok(())
}
pub fn untrack_file(&mut self, commander: &mut Commander) -> Result<()> {
self.file
.as_ref()
.map(|current_file| commander.untrack_file(current_file))
.transpose()?;
Ok(())
}
fn scroll_files(&mut self, commander: &mut Commander, scroll: isize) -> Result<()> {
if let Ok(files) = self.files_output.as_ref() {
let current_file_index = self.get_current_file_index();
let next_file = match current_file_index {
Some(current_file_index) => files.get(
current_file_index
.saturating_add_signed(scroll)
.min(files.len() - 1),
),
None => files.first(),
}
.map(|x| x.to_owned());
if let Some(next_file) = next_file {
self.file = Some(next_file.to_owned());
self.refresh_diff(commander)?;
}
}
Ok(())
}
}
impl Component for FilesTab {
fn focus(&mut self, commander: &mut Commander) -> Result<()> {
self.is_current_head = self.head == commander.get_current_head()?;
self.head = commander.get_head_latest(&self.head)?;
self.refresh_files(commander)?;
self.refresh_diff(commander)?;
Ok(())
}
fn draw(
&mut self,
f: &mut ratatui::prelude::Frame<'_>,
area: ratatui::prelude::Rect,
) -> Result<()> {
let chunks = Layout::default()
.direction(self.config.layout().into())
.constraints([
Constraint::Percentage(self.config.layout_percent()),
Constraint::Percentage(100 - self.config.layout_percent()),
])
.split(area);
// Draw files
{
let current_file_index = self.get_current_file_index();
let mut lines: Vec<Line> = match self.files_output.as_ref() {
Ok(files_output) => {
let files_lines = files_output
.iter()
.enumerate()
.flat_map(|(i, file)| {
file.line
.to_text()
.unwrap()
.iter()
.map(|line| {
let mut line = line.to_owned();
// Add padding at start
line.spans.insert(0, Span::from(" "));
if let Some(diff_type) = file.diff_type.as_ref() {
line.spans = line
.spans
.iter_mut()
.map(|span| span.to_owned().fg(diff_type.color()))
.collect();
}
if current_file_index == Some(i) {
line = line.bg(self.config.highlight_color());
line.spans = line
.spans
.iter_mut()
.map(|span| {
span.to_owned().bg(self.config.highlight_color())
})
.collect();
}
line
})
.collect::<Vec<Line>>()
})
.collect::<Vec<Line>>();
if files_lines.is_empty() {
vec![
Line::from(" No changed files in change")
.fg(Color::DarkGray)
.italic(),
]
} else {
files_lines
}
}
Err(err) => err.into_text("Error getting files")?.lines,
};
let title_change = if self.is_current_head {
format!("@ ({})", self.head.change_id)
} else {
self.head.change_id.as_string()
};
if !self.conflicts_output.is_empty() {
lines.push(Line::default());
for conflict in &self.conflicts_output {
lines.push(Line::raw(format!("C {}", &conflict.path)).fg(Color::Red));
}
}
let files = List::new(lines)
.block(
Block::bordered()
.title(" Files for ".to_owned() + &title_change + " ")
.border_type(BorderType::Rounded),
)
.scroll_padding(3);
*self.files_list_state.selected_mut() = current_file_index;
f.render_stateful_widget(files, chunks[0], &mut self.files_list_state);
self.files_height = chunks[0].height - 2;
}
// Draw diff
{
let diff_block = Block::bordered()
.title(" Diff ")
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1));
let diff_content = match self.diff_output.as_ref() {
Ok(Some(diff_content)) => diff_content.into_text()?,
Ok(None) => Text::default(),
Err(err) => err.into_text("Error getting diff")?,
};
let diff = self
.diff_panel
.render(diff_content, diff_block.inner(chunks[1]))
.block(diff_block);
f.render_widget(diff, chunks[1]);
}
Ok(())
}
fn input(&mut self, commander: &mut Commander, event: Event) -> Result<ComponentInputResult> {
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
return Ok(ComponentInputResult::Handled);
}
if self.diff_panel.input(key) {
return Ok(ComponentInputResult::Handled);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.scroll_files(commander, 1)?,
KeyCode::Char('k') | KeyCode::Up => self.scroll_files(commander, -1)?,
KeyCode::Char('J') => {
self.scroll_files(commander, self.files_height as isize / 2)?;
}
KeyCode::Char('K') => {
self.scroll_files(
commander,
(self.files_height as isize / 2).saturating_neg(),
)?;
}
KeyCode::Char('w') => {
self.diff_format = self.diff_format.get_next(self.config.diff_tool());
self.refresh_diff(commander)?;
}
KeyCode::Char('x') => {
// this works even for deleted files because jj doesn't return error in that case
if self.untrack_file(commander).is_err() {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Can't untrack file".into(),
messages: "Make sure that file is ignored".into(),
text_align: None,
}))),
));
}
self.set_head(commander, &commander.get_current_head()?)?;
}
KeyCode::Char('R') | KeyCode::F(5) => {
self.head = commander.get_head_latest(&self.head)?;
self.refresh_files(commander)?;
self.refresh_diff(commander)?;
}
KeyCode::Char('@') => {
let head = &commander.get_current_head()?;
self.set_head(commander, head)?;
}
KeyCode::Char('?') => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(HelpPopup::new(
vec![
("j/k".to_owned(), "scroll down/up".to_owned()),
("J/K".to_owned(), "scroll down by ½ page".to_owned()),
("x".to_owned(), "untrack file".to_owned()),
("@".to_owned(), "view current change files".to_owned()),
],
vec![
("Ctrl+e/Ctrl+y".to_owned(), "scroll down/up".to_owned()),
(
"Ctrl+d/Ctrl+u".to_owned(),
"scroll down/up by ½ page".to_owned(),
),
(
"Ctrl+f/Ctrl+b".to_owned(),
"scroll down/up by page".to_owned(),
),
("w".to_owned(), "toggle diff format".to_owned()),
("W".to_owned(), "toggle wrapping".to_owned()),
],
)))),
));
}
_ => return Ok(ComponentInputResult::NotHandled),
};
}
Ok(ComponentInputResult::Handled)
}
}
07070100000029000081A400000000000000000000000168C1DE9400000CA6000000000000000000000000000000000000002200000000lazyjj-0.6.1/src/ui/help_popup.rsuse ratatui::{
crossterm::event::{self, Event, KeyCode},
layout::{Constraint, Direction, Layout},
style::Stylize,
text::Span,
widgets::{Block, Clear, Row, Table},
};
use crate::{
ComponentInputResult,
ui::{Component, styles::create_popup_block, utils::centered_rect},
};
pub struct HelpPopup {
pub left_items: Vec<(String, String)>,
pub right_items: Vec<(String, String)>,
height: u16,
scroll: usize,
}
impl HelpPopup {
pub fn new(left_items: Vec<(String, String)>, right_items: Vec<(String, String)>) -> Self {
Self {
left_items,
right_items,
height: 0,
// Can't use TableState as it's broken: https://github.com/ratatui-org/ratatui/issues/1179
scroll: 0,
}
}
fn create_table(&self, items: &[(String, String)], title: String) -> Table<'_> {
let items: Vec<&(String, String)> = items.iter().skip(self.scroll).collect();
let max_first_row_width = items.iter().map(|row| row.0.len()).max().unwrap_or(0);
let rows: Vec<Row> = items
.iter()
.map(|row| Row::new([row.0.clone(), row.1.clone()]))
.collect();
let widths = [
Constraint::Length(max_first_row_width as u16 + 2),
Constraint::Fill(1),
];
Table::new(rows, widths).block(Block::new().title(Span::from(title).bold()))
}
}
impl Component for HelpPopup {
fn draw(
&mut self,
f: &mut ratatui::prelude::Frame<'_>,
area: ratatui::prelude::Rect,
) -> anyhow::Result<()> {
let area = centered_rect(area, 60, 60);
f.render_widget(Clear, area);
let block = create_popup_block("Help");
let block_inner = block.inner(area);
self.height = block_inner.height;
f.render_widget(&block, area);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Fill(1),
])
.split(block_inner);
f.render_widget(
self.create_table(&self.left_items, "Main panel".into()),
chunks[0],
);
f.render_widget(
self.create_table(&self.right_items, "Details panel".into()),
chunks[2],
);
Ok(())
}
fn input(
&mut self,
_commander: &mut crate::commander::Commander,
event: Event,
) -> anyhow::Result<crate::ComponentInputResult> {
if let Event::Key(key) = event
&& key.kind == event::KeyEventKind::Press
{
match key.code {
KeyCode::Char('j') => {
let max = self.left_items.len().max(self.right_items.len());
self.scroll = (self.scroll + 1).min(max.saturating_sub(self.height as usize));
}
KeyCode::Char('k') => self.scroll = self.scroll.saturating_sub(1),
_ => return Ok(ComponentInputResult::NotHandled),
}
return Ok(ComponentInputResult::Handled);
}
Ok(ComponentInputResult::NotHandled)
}
}
0707010000002A000081A400000000000000000000000168C1DE940000895D000000000000000000000000000000000000001F00000000lazyjj-0.6.1/src/ui/log_tab.rs#![expect(clippy::borrow_interior_mutable_const)]
use ansi_to_tui::IntoText;
use anyhow::Result;
use ratatui::{
crossterm::event::{Event, KeyEventKind, MouseEvent, MouseEventKind},
layout::Rect,
prelude::*,
widgets::*,
};
use tracing::instrument;
use tui_confirm_dialog::{ButtonLabel, ConfirmDialog, ConfirmDialogState, Listener};
use tui_textarea::{CursorMove, TextArea};
use crate::{
ComponentInputResult,
commander::{
CommandError, Commander,
log::{Head, LogOutput},
},
env::{Config, DiffFormat},
keybinds::{LogTabEvent, LogTabKeybinds},
ui::{
Component, ComponentAction,
bookmark_set_popup::BookmarkSetPopup,
details_panel::DetailsPanel,
details_panel::DetailsPanelEvent,
help_popup::HelpPopup,
message_popup::MessagePopup,
utils::{centered_rect, centered_rect_line_height, tabs_to_spaces},
},
};
const NEW_POPUP_ID: u16 = 1;
const EDIT_POPUP_ID: u16 = 2;
const ABANDON_POPUP_ID: u16 = 3;
const SQUASH_POPUP_ID: u16 = 4;
/// Log tab. Shows `jj log` in main panel and shows selected change details of in details panel.
pub struct LogTab<'a> {
log_output: Result<LogOutput, CommandError>,
log_output_text: Text<'a>,
log_list_state: ListState,
log_height: u16,
log_revset: Option<String>,
log_revset_textarea: Option<TextArea<'a>>,
head_panel: DetailsPanel,
head_output: Result<String, CommandError>,
head: Head,
// Rect of panels [0] = log, [1] = details
panel_rect: [Rect; 2],
diff_format: DiffFormat,
popup: ConfirmDialogState,
popup_tx: std::sync::mpsc::Sender<Listener>,
popup_rx: std::sync::mpsc::Receiver<Listener>,
bookmark_set_popup_tx: std::sync::mpsc::Sender<bool>,
bookmark_set_popup_rx: std::sync::mpsc::Receiver<bool>,
describe_textarea: Option<TextArea<'a>>,
describe_after_new: bool,
squash_ignore_immutable: bool,
edit_ignore_immutable: bool,
config: Config,
keybinds: LogTabKeybinds,
}
fn get_head_index(head: &Head, log_output: &Result<LogOutput, CommandError>) -> Option<usize> {
match log_output {
Ok(log_output) => log_output
.heads
.iter()
.position(|heads| heads == head)
.or_else(|| {
log_output
.heads
.iter()
.position(|commit| commit.change_id == head.change_id)
}),
Err(_) => None,
}
}
impl LogTab<'_> {
#[instrument(level = "trace", skip(commander))]
pub fn new(commander: &mut Commander) -> Result<Self> {
let diff_format = commander.env.config.diff_format();
let log_revset = commander.env.default_revset.clone();
let log_output = commander.get_log(&log_revset);
let head = commander.get_current_head()?;
let log_list_state = ListState::default().with_selected(get_head_index(&head, &log_output));
let head_output = commander
.get_commit_show(&head.commit_id, &diff_format, true)
.map(|text| tabs_to_spaces(&text));
let (popup_tx, popup_rx) = std::sync::mpsc::channel();
let (bookmark_set_popup_tx, bookmark_set_popup_rx) = std::sync::mpsc::channel();
let mut keybinds = LogTabKeybinds::default();
if let Some(new_keybinds) = commander
.env
.config
.keybinds()
.and_then(|k| k.log_tab.clone())
{
keybinds.extend_from_config(&new_keybinds);
}
Ok(Self {
log_output_text: match log_output.as_ref() {
Ok(log_output) => log_output
.graph
.into_text()
.unwrap_or(Text::from("Could not turn text into TUI text (coloring)")),
Err(_) => Text::default(),
},
log_output,
log_list_state,
log_height: 0,
log_revset,
log_revset_textarea: None,
head,
head_panel: DetailsPanel::new(),
head_output,
panel_rect: [Rect::ZERO, Rect::ZERO],
diff_format,
popup: ConfirmDialogState::default(),
popup_tx,
popup_rx,
bookmark_set_popup_tx,
bookmark_set_popup_rx,
describe_textarea: None,
describe_after_new: false,
squash_ignore_immutable: false,
edit_ignore_immutable: false,
config: commander.env.config.clone(),
keybinds,
})
}
fn get_current_head_index(&self) -> Option<usize> {
get_head_index(&self.head, &self.log_output)
}
fn refresh_log_output(&mut self, commander: &mut Commander) {
self.log_output = commander.get_log(&self.log_revset);
self.log_output_text = match self.log_output.as_ref() {
Ok(log_output) => log_output
.graph
.into_text()
.unwrap_or(Text::from("Could not turn text into TUI text (coloring)")),
Err(_) => Text::default(),
};
}
fn refresh_head_output(&mut self, commander: &mut Commander) {
self.head_output = commander
.get_commit_show(&self.head.commit_id, &self.diff_format, true)
.map(|text| tabs_to_spaces(&text));
self.head_panel.scroll = 0;
}
fn scroll_log(&mut self, commander: &mut Commander, scroll: isize) {
let log_output = match self.log_output.as_ref() {
Ok(log_output) => log_output,
Err(_) => return,
};
let heads: &Vec<Head> = log_output.heads.as_ref();
let current_head_index = self.get_current_head_index();
let next_head = match current_head_index {
Some(current_head_index) => heads.get(
current_head_index
.saturating_add_signed(scroll)
.min(heads.len() - 1),
),
None => heads.first(),
};
if let Some(next_head) = next_head {
self.set_head(commander, next_head.clone());
}
}
pub fn set_head(&mut self, commander: &mut Commander, head: Head) {
head.clone_into(&mut self.head);
self.refresh_head_output(commander);
}
fn handle_event(
&mut self,
commander: &mut Commander,
log_tab_event: LogTabEvent,
) -> Result<ComponentInputResult> {
match log_tab_event {
LogTabEvent::ScrollDown => {
self.scroll_log(commander, 1);
}
LogTabEvent::ScrollUp => {
self.scroll_log(commander, -1);
}
LogTabEvent::ScrollDownHalf => {
self.scroll_log(commander, self.log_height as isize / 2 / 2);
}
LogTabEvent::ScrollUpHalf => {
self.scroll_log(
commander,
(self.log_height as isize / 2 / 2).saturating_neg(),
);
}
LogTabEvent::FocusCurrent => {
self.head = commander.get_current_head()?;
self.refresh_head_output(commander);
}
LogTabEvent::ToggleDiffFormat => {
self.diff_format = self.diff_format.get_next(self.config.diff_tool());
self.refresh_head_output(commander);
}
LogTabEvent::Refresh => {
self.refresh_log_output(commander);
self.refresh_head_output(commander);
}
LogTabEvent::CreateNew { describe } => {
self.popup = ConfirmDialogState::new(
NEW_POPUP_ID,
Span::styled(" New ", Style::new().bold().cyan()),
Text::from(vec![
Line::from("Are you sure you want to create a new change?"),
Line::from(format!("New parent: {}", self.head.change_id.as_str())),
])
.fg(Color::default()),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
self.describe_after_new = describe;
}
LogTabEvent::Squash { ignore_immutable } => {
if self.head.change_id == commander.get_current_head()?.change_id {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Squash".into(),
messages: "Cannot squash onto current change".into_text()?,
text_align: None,
}))),
));
}
if self.head.immutable && !ignore_immutable {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Squash".into(),
messages: "Cannot squash onto immutable change".into_text()?,
text_align: None,
}))),
));
}
let mut lines = vec![
Line::from("Are you sure you want to squash @ into this change?"),
Line::from(format!("Squash into {}", self.head.change_id.as_str())),
];
if ignore_immutable {
lines.push(Line::from("This change is immutable."));
}
self.popup = ConfirmDialogState::new(
SQUASH_POPUP_ID,
Span::styled(" Squash ", Style::new().bold().cyan()),
Text::from(lines).fg(Color::default()),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
self.squash_ignore_immutable = ignore_immutable;
}
LogTabEvent::EditChange { ignore_immutable } => {
if self.head.immutable && !ignore_immutable {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: " Edit ".into(),
messages: vec![
"The change cannot be edited because it is immutable.".into(),
]
.into(),
text_align: None,
}))),
));
}
let mut lines = vec![
Line::from("Are you sure you want to edit an existing change?"),
Line::from(format!("Change: {}", self.head.change_id.as_str())),
];
if ignore_immutable {
lines.push(Line::from("This change is immutable."))
}
self.popup = ConfirmDialogState::new(
EDIT_POPUP_ID,
Span::styled(" Edit ", Style::new().bold().cyan()),
Text::from(lines).fg(Color::default()),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
self.edit_ignore_immutable = ignore_immutable;
}
LogTabEvent::Abandon => {
if self.head.immutable {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Abandon".into(),
messages: vec![
"The change cannot be abandoned because it is immutable.".into(),
]
.into(),
text_align: None,
}))),
));
} else {
self.popup = ConfirmDialogState::new(
ABANDON_POPUP_ID,
Span::styled(" Abandon ", Style::new().bold().cyan()),
Text::from(vec![
Line::from("Are you sure you want to abandon this change?"),
Line::from(format!("Change: {}", self.head.change_id.as_str())),
])
.fg(Color::default()),
);
self.popup
.with_yes_button(ButtonLabel::YES.clone())
.with_no_button(ButtonLabel::NO.clone())
.with_listener(Some(self.popup_tx.clone()))
.open();
}
}
LogTabEvent::Describe => {
if self.head.immutable {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Describe".into(),
messages: vec![
"The change cannot be described because it is immutable.".into(),
]
.into(),
text_align: None,
}))),
));
} else {
let mut textarea = TextArea::new(
commander
.get_commit_description(&self.head.commit_id)?
.split("\n")
.map(|line| line.to_string())
.collect(),
);
textarea.move_cursor(CursorMove::End);
self.describe_textarea = Some(textarea);
return Ok(ComponentInputResult::Handled);
}
}
LogTabEvent::EditRevset => {
let mut textarea = TextArea::new(
self.log_revset
.as_ref()
.unwrap_or(&"".to_owned())
.lines()
.map(String::from)
.collect(),
);
textarea.move_cursor(CursorMove::End);
self.log_revset_textarea = Some(textarea);
return Ok(ComponentInputResult::Handled);
}
LogTabEvent::SetBookmark => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(BookmarkSetPopup::new(
self.config.clone(),
commander,
Some(self.head.change_id.clone()),
self.head.commit_id.clone(),
self.bookmark_set_popup_tx.clone(),
)))),
));
}
LogTabEvent::OpenFiles => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::ViewFiles(self.head.clone()),
));
}
LogTabEvent::Push {
all_bookmarks,
allow_new,
} => {
match commander.git_push(all_bookmarks, allow_new, &self.head.commit_id) {
Ok(result) if !result.is_empty() => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Push message".into(),
messages: result.into_text()?,
text_align: None,
}))),
));
}
Err(err) => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Push error".into(),
messages: err.into_text("")?,
text_align: None,
}))),
));
}
_ => (),
}
self.refresh_log_output(commander);
self.refresh_head_output(commander);
}
LogTabEvent::Fetch { all_remotes } => {
match commander.git_fetch(all_remotes) {
Ok(result) if !result.is_empty() => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Fetch message".into(),
messages: result.into_text()?,
text_align: None,
}))),
));
}
Err(err) => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(MessagePopup {
title: "Fetch error".into(),
messages: err.into_text("")?,
text_align: None,
}))),
));
}
_ => (),
}
self.refresh_log_output(commander);
self.refresh_head_output(commander);
}
LogTabEvent::OpenHelp => {
return Ok(ComponentInputResult::HandledAction(
ComponentAction::SetPopup(Some(Box::new(HelpPopup::new(
self.keybinds.make_main_panel_help(),
vec![
("Ctrl+e/Ctrl+y".to_owned(), "scroll down/up".to_owned()),
(
"Ctrl+d/Ctrl+u".to_owned(),
"scroll down/up by ½ page".to_owned(),
),
(
"Ctrl+f/Ctrl+b".to_owned(),
"scroll down/up by page".to_owned(),
),
("w".to_owned(), "toggle diff format".to_owned()),
("W".to_owned(), "toggle wrapping".to_owned()),
],
)))),
));
}
LogTabEvent::Save
| LogTabEvent::Cancel
| LogTabEvent::ClosePopup
| LogTabEvent::Unbound => return Ok(ComponentInputResult::NotHandled),
};
Ok(ComponentInputResult::Handled)
}
}
impl Component for LogTab<'_> {
fn focus(&mut self, commander: &mut Commander) -> Result<()> {
let latest_head = commander.get_head_latest(&self.head)?;
if latest_head != self.head {
self.head = latest_head;
}
self.refresh_log_output(commander);
self.refresh_head_output(commander);
Ok(())
}
fn update(&mut self, commander: &mut Commander) -> Result<Option<ComponentAction>> {
// Check for popup action
if let Ok(res) = self.popup_rx.try_recv()
&& res.1.unwrap_or(false)
{
match res.0 {
NEW_POPUP_ID => {
commander.run_new(self.head.commit_id.as_str())?;
self.head = commander.get_current_head()?;
self.refresh_log_output(commander);
self.refresh_head_output(commander);
if self.describe_after_new {
self.describe_after_new = false;
let textarea = TextArea::default();
self.describe_textarea = Some(textarea);
}
return Ok(Some(ComponentAction::ChangeHead(self.head.clone())));
}
EDIT_POPUP_ID => {
commander.run_edit(self.head.commit_id.as_str(), self.edit_ignore_immutable)?;
self.refresh_log_output(commander);
self.refresh_head_output(commander);
return Ok(Some(ComponentAction::ChangeHead(self.head.clone())));
}
ABANDON_POPUP_ID => {
if self.head == commander.get_current_head()? {
commander.run_abandon(&self.head.commit_id)?;
self.refresh_log_output(commander);
self.head = commander.get_current_head()?;
self.refresh_head_output(commander);
return Ok(Some(ComponentAction::ChangeHead(self.head.clone())));
} else {
let head_parent = commander.get_commit_parent(&self.head.commit_id)?;
commander.run_abandon(&self.head.commit_id)?;
self.refresh_log_output(commander);
self.head = head_parent;
self.refresh_head_output(commander);
}
}
SQUASH_POPUP_ID => {
commander
.run_squash(self.head.commit_id.as_str(), self.squash_ignore_immutable)?;
self.head = commander.get_current_head()?;
self.refresh_log_output(commander);
self.refresh_head_output(commander);
return Ok(Some(ComponentAction::ChangeHead(self.head.clone())));
}
_ => {}
}
}
if let Ok(true) = self.bookmark_set_popup_rx.try_recv() {
self.refresh_log_output(commander);
self.refresh_head_output(commander)
}
Ok(None)
}
fn draw(
&mut self,
f: &mut ratatui::prelude::Frame<'_>,
area: ratatui::prelude::Rect,
) -> Result<()> {
let chunks = Layout::default()
.direction(self.config.layout().into())
.constraints([
Constraint::Percentage(self.config.layout_percent()),
Constraint::Percentage(100 - self.config.layout_percent()),
])
.split(area);
self.panel_rect = [chunks[0], chunks[1]];
// Draw log
{
let mut scroll_offset = 0;
let log_lines = match self.log_output.as_ref() {
Ok(log_output) => {
let log_lines: Vec<Line> = self
.log_output_text
.iter()
.enumerate()
.map(|(i, line)| {
let mut line = line.to_owned();
// Add padding at start
line.spans.insert(0, Span::from(" "));
let line_head = log_output.graph_heads.get(i).unwrap_or(&None);
match line_head {
Some(line_change) => {
if line_change == &self.head {
line = line.bg(self.config.highlight_color());
line.spans = line
.spans
.iter_mut()
.map(|span| {
span.to_owned().bg(self.config.highlight_color())
})
.collect();
}
}
_ => scroll_offset += 1,
};
line
})
.collect();
self.log_list_state
.select(log_lines.iter().enumerate().position(|(i, _)| {
log_output
.graph_heads
.get(i)
.unwrap_or(&None)
.as_ref()
.is_some_and(|h| h == &self.head)
}));
log_lines
}
Err(err) => err.into_text("Error getting log")?.lines,
};
let title = match &self.log_revset {
Some(log_revset) => &format!(" Log for: {log_revset} "),
None => " Log ",
};
let log_block = Block::bordered()
.title(title)
.border_type(BorderType::Rounded);
self.log_height = log_block.inner(chunks[0]).height;
let log = List::new(log_lines).block(log_block).scroll_padding(7);
f.render_stateful_widget(log, chunks[0], &mut self.log_list_state);
}
// Draw change details
{
let head_content = match self.head_output.as_ref() {
Ok(head_output) => head_output.into_text()?.lines,
Err(err) => err.into_text("Error getting head details")?.lines,
};
let head_block = Block::bordered()
.title(format!(" Details for {} ", self.head.change_id))
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1));
let head = self
.head_panel
.render(head_content, head_block.inner(chunks[1]))
.block(head_block);
f.render_widget(head, chunks[1]);
}
// Draw popup
if self.popup.is_opened() {
let popup = ConfirmDialog::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green))
.selected_button_style(
Style::default()
.bg(self.config.highlight_color())
.underlined(),
);
f.render_stateful_widget(popup, area, &mut self.popup);
}
// Draw describe textarea
{
if let Some(describe_textarea) = self.describe_textarea.as_mut() {
let block = Block::bordered()
.title(Span::styled(" Describe ", Style::new().bold().cyan()))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let area = centered_rect(area, 50, 50);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
f.render_widget(&*describe_textarea, popup_chunks[0]);
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
}
// Draw revset textarea
{
if let Some(log_revset_textarea) = self.log_revset_textarea.as_mut() {
let block = Block::bordered()
.title(Span::styled(" Revset ", Style::new().bold().cyan()))
.title_alignment(Alignment::Center)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
let area = centered_rect_line_height(area, 30, 7);
f.render_widget(Clear, area);
f.render_widget(&block, area);
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(block.inner(area));
f.render_widget(&*log_revset_textarea, popup_chunks[0]);
let help = Paragraph::new(vec!["Ctrl+s: save | Escape: cancel".into()])
.fg(Color::DarkGray)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(help, popup_chunks[1]);
}
}
Ok(())
}
fn input(&mut self, commander: &mut Commander, event: Event) -> Result<ComponentInputResult> {
if let Some(describe_textarea) = self.describe_textarea.as_mut() {
if let Event::Key(key) = event {
match self.keybinds.match_event(key) {
LogTabEvent::Save => {
// TODO: Handle error
commander.run_describe(
self.head.commit_id.as_str(),
&describe_textarea.lines().join("\n"),
)?;
self.head = commander.get_head_latest(&self.head)?;
self.refresh_log_output(commander);
self.refresh_head_output(commander);
self.describe_textarea = None;
return Ok(ComponentInputResult::Handled);
}
LogTabEvent::Cancel => {
self.describe_textarea = None;
return Ok(ComponentInputResult::Handled);
}
_ => (),
}
}
describe_textarea.input(event);
return Ok(ComponentInputResult::Handled);
}
if let Some(log_revset_textarea) = self.log_revset_textarea.as_mut() {
if let Event::Key(key) = event {
match self.keybinds.match_event(key) {
LogTabEvent::Save => {
let log_revset = log_revset_textarea.lines().join("\n");
self.log_revset = if log_revset.trim().is_empty() {
None
} else {
Some(log_revset)
};
self.refresh_log_output(commander);
self.log_revset_textarea = None;
return Ok(ComponentInputResult::Handled);
}
LogTabEvent::Cancel => {
self.log_revset_textarea = None;
return Ok(ComponentInputResult::Handled);
}
_ => (),
}
}
log_revset_textarea.input(event);
return Ok(ComponentInputResult::Handled);
}
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
return Ok(ComponentInputResult::Handled);
}
if self.popup.is_opened() {
if matches!(
self.keybinds.match_event(key),
LogTabEvent::ClosePopup | LogTabEvent::Cancel
) {
self.popup = ConfirmDialogState::default();
} else {
self.popup.handle(&key);
}
return Ok(ComponentInputResult::Handled);
}
if self.head_panel.input(key) {
return Ok(ComponentInputResult::Handled);
}
let log_tab_event = self.keybinds.match_event(key);
return self.handle_event(commander, log_tab_event);
}
if let Event::Mouse(mouse_event) = event {
// Determine if mouse event is inside log-view or details-view
fn contains(rect: &Rect, mouse_event: &MouseEvent) -> bool {
rect.x <= mouse_event.column
&& mouse_event.column < rect.x + rect.width
&& rect.y <= mouse_event.row
&& mouse_event.row < rect.y + rect.height
}
let find_panel = || -> Option<usize> {
for (i, rect) in self.panel_rect.iter().enumerate() {
if contains(rect, &mouse_event) {
return Some(i);
}
}
None
};
let panel = find_panel();
// Execute command dependent on panel and event kind
const LOG_PANEL: Option<usize> = Some(0);
const DETAILS_PANEL: Option<usize> = Some(1);
match (panel, mouse_event.kind) {
(LOG_PANEL, MouseEventKind::ScrollUp) => {
self.handle_event(commander, LogTabEvent::ScrollUp)?;
}
(LOG_PANEL, MouseEventKind::ScrollDown) => {
self.handle_event(commander, LogTabEvent::ScrollDown)?;
}
(DETAILS_PANEL, MouseEventKind::ScrollUp) => {
self.head_panel.handle_event(DetailsPanelEvent::ScrollUp);
self.head_panel.handle_event(DetailsPanelEvent::ScrollUp);
self.head_panel.handle_event(DetailsPanelEvent::ScrollUp);
}
(DETAILS_PANEL, MouseEventKind::ScrollDown) => {
self.head_panel.handle_event(DetailsPanelEvent::ScrollDown);
self.head_panel.handle_event(DetailsPanelEvent::ScrollDown);
self.head_panel.handle_event(DetailsPanelEvent::ScrollDown);
}
_ => {} // Handle other mouse events if necessary
}
}
Ok(ComponentInputResult::Handled)
}
}
0707010000002B000081A400000000000000000000000168C1DE9400000626000000000000000000000000000000000000002500000000lazyjj-0.6.1/src/ui/message_popup.rsuse anyhow::Result;
use ratatui::{
Frame,
crossterm::event::Event,
layout::{Alignment, Rect},
style::{Color, Style, Stylize},
text::{Span, Text},
widgets::{BorderType, Borders, block::Title},
};
use tui_confirm_dialog::PopupMessage;
use crate::{ComponentInputResult, commander::Commander, ui::Component};
pub struct MessagePopup<'a> {
pub title: Title<'a>,
pub messages: Text<'a>,
pub text_align: Option<Alignment>,
}
impl Component for MessagePopup<'_> {
/// Render the parent into the area.
fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
let mut title = self.title.clone();
title.content.spans = [
vec![Span::raw(" ")],
title.content.spans,
vec![Span::raw(" ")],
]
.concat();
title.content = title.content.fg(Color::Cyan).bold();
let text_align = match self.text_align {
Some(align) => align,
None => Alignment::Center,
};
// TODO: Support scrolling long messages
let popup = PopupMessage::new(title, self.messages.clone())
.title_alignment(Alignment::Center)
.text_alignment(text_align)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green));
f.render_widget(popup, area);
Ok(())
}
fn input(&mut self, _commander: &mut Commander, _event: Event) -> Result<ComponentInputResult> {
Ok(ComponentInputResult::NotHandled)
}
}
0707010000002C000081A400000000000000000000000168C1DE9400000CA5000000000000000000000000000000000000001B00000000lazyjj-0.6.1/src/ui/mod.rspub mod bookmark_set_popup;
pub mod bookmarks_tab;
pub mod command_log_tab;
pub mod command_popup;
pub mod details_panel;
pub mod files_tab;
pub mod help_popup;
pub mod log_tab;
pub mod message_popup;
pub mod styles;
pub mod utils;
use std::time::Instant;
use crate::{
ComponentInputResult,
app::{App, Tab},
commander::{Commander, log::Head},
};
use anyhow::Result;
use ratatui::{
Frame,
crossterm::event::Event,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
symbols,
};
use ratatui::{prelude::*, widgets::*};
pub enum ComponentAction {
ViewFiles(Head),
ViewLog(Head),
ChangeHead(Head),
SetPopup(Option<Box<dyn Component>>),
Multiple(Vec<ComponentAction>),
RefreshTab(),
}
pub trait Component {
// Called when switching to tab
fn focus(&mut self, _commander: &mut Commander) -> Result<()> {
Ok(())
}
fn update(&mut self, _commander: &mut Commander) -> Result<Option<ComponentAction>> {
Ok(None)
}
fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>;
fn input(&mut self, commander: &mut Commander, event: Event) -> Result<ComponentInputResult>;
}
pub fn ui(f: &mut Frame, app: &mut App) -> Result<()> {
let start_time = Instant::now();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(f.area());
let header_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[0]);
{
let tabs = Tabs::new(
Tab::VALUES
.iter()
.enumerate()
.map(|(i, tab)| format!("[{}] {}", i + 1, tab)),
)
.block(
Block::bordered()
.title(" Tabs ")
.border_type(BorderType::Rounded),
)
.highlight_style(Style::default().bg(app.env.config.highlight_color()))
.select(
Tab::VALUES
.iter()
.position(|tab| tab == &app.current_tab)
.unwrap_or(0),
)
.divider(symbols::line::VERTICAL);
f.render_widget(tabs, header_chunks[0]);
}
{
let tabs = Paragraph::new("q: quit | ?: help | R: refresh | 1/2/3/4: change tab")
.fg(Color::DarkGray)
.block(
Block::bordered()
.title(" lazyjj ")
.border_type(BorderType::Rounded)
.fg(Color::default()),
);
f.render_widget(tabs, header_chunks[1]);
}
if let Some(current_tab) = app.get_current_tab() {
current_tab.draw(f, chunks[1])?;
}
if let Some(popup) = app.popup.as_mut() {
popup.draw(f, f.area())?;
}
{
let paragraph = Paragraph::new(format!("{}ms", start_time.elapsed().as_millis()))
.alignment(Alignment::Right);
let position = Rect {
x: 0,
y: 1,
height: 1,
width: f.area().width - 1,
};
f.render_widget(paragraph, position);
}
Ok(())
}
0707010000002D000081A400000000000000000000000168C1DE94000002D1000000000000000000000000000000000000001E00000000lazyjj-0.6.1/src/ui/styles.rsuse std::sync::LazyLock;
use ratatui::{
layout::Alignment,
style::{Color, Style, Stylize},
text::Span,
widgets::{Block, BorderType, Padding},
};
pub static POPUP_BLOCK: LazyLock<Block<'static>> = LazyLock::new(|| {
Block::<'static>::bordered()
.padding(Padding::horizontal(1))
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Green))
});
pub static POPUP_BLOCK_TITLE_STYLE: LazyLock<Style> = LazyLock::new(|| Style::new().bold().cyan());
pub fn create_popup_block(title: &str) -> Block<'_> {
POPUP_BLOCK
.clone()
.title(Span::styled(format!(" {title} "), *POPUP_BLOCK_TITLE_STYLE))
.title_alignment(Alignment::Center)
}
0707010000002E000081A400000000000000000000000168C1DE9400000C3D000000000000000000000000000000000000001D00000000lazyjj-0.6.1/src/ui/utils.rsuse ratatui::layout::{Constraint, Direction, Layout, Rect};
pub fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn centered_rect_line_height(r: Rect, percent_x: u16, lines_y: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(lines_y),
Constraint::Fill(1),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
/// replaces tabs in a string by spaces
///
/// ratatui doesn't work well displaying tabs, so any
/// string that is rendered and might contain tabs
/// needs to have the tabs converted to spaces.
///
/// this function aligns tabs in the input string to
/// virtual tab stops 4 spaces apart, taking care
/// to count ansi control sequences as zero width.
pub fn tabs_to_spaces(line: &str) -> String {
const TAB_WIDTH: usize = 4;
enum AnsiState {
Neutral,
Escape,
Csi,
}
let mut out = String::new();
let mut x = 0;
let mut ansi_state = AnsiState::Neutral;
for c in line.chars() {
match ansi_state {
AnsiState::Neutral => {
if c == '\t' {
loop {
out.push(' ');
x += 1;
if x % TAB_WIDTH == 0 {
break;
}
}
} else {
out.push(c);
if c == '\x1b' {
ansi_state = AnsiState::Escape;
} else {
x += 1;
}
}
if c == '\r' || c == '\n' {
x = 0;
}
}
AnsiState::Escape => {
out.push(c);
ansi_state = if c == '[' {
AnsiState::Csi
} else {
AnsiState::Neutral
};
}
AnsiState::Csi => {
out.push(c);
if ('\x40'..='\x7f').contains(&c) {
ansi_state = AnsiState::Neutral;
}
}
}
}
out
}
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!618 blocks