diff --git a/Cargo.lock b/Cargo.lock index c9b95234..5b69a379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -148,12 +148,27 @@ dependencies = [ name = "cardinal-syntax" version = "0.1.0" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.59" @@ -172,12 +187,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.44" @@ -269,15 +278,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - [[package]] name = "cobs" version = "0.3.0" @@ -293,6 +293,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[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", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -390,12 +404,71 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[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 0.38.44", + "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 = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -435,12 +508,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" - [[package]] name = "enumn" version = "0.1.14" @@ -465,15 +532,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - [[package]] name = "fastrand" version = "2.4.0" @@ -581,6 +642,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -603,15 +666,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -642,6 +696,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.1" @@ -654,6 +714,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -696,7 +778,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -763,6 +845,12 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -784,6 +872,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lsf" version = "0.1.0" @@ -793,13 +890,16 @@ dependencies = [ "clap", "clap-verbosity-flag", "crossbeam-channel", + "crossterm", "fswalk", + "jiff", "namepool", "query-segmentation", - "rustyline", + "ratatui", "search-cache", "search-cancel", "serde", + "toml", "tracing-subscriber", ] @@ -827,6 +927,18 @@ dependencies = [ "libc", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "namepool" version = "0.1.0" @@ -840,34 +952,13 @@ dependencies = [ "serde", ] -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1148,6 +1239,12 @@ dependencies = [ "windows-link", ] +[[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.17" @@ -1287,16 +1384,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type", - "nibble_vec", -] - [[package]] name = "rand" version = "0.4.6" @@ -1325,6 +1412,27 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" +[[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", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.11.0" @@ -1407,6 +1515,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1416,8 +1537,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -1427,25 +1548,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "rustyline" -version = "18.0.0" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" -dependencies = [ - "bitflags", - "cfg-if", - "clipboard-win", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "unicode-segmentation", - "unicode-width", - "utf8parse", - "windows-sys", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -1570,6 +1676,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1585,6 +1700,37 @@ 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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1613,12 +1759,40 @@ 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.117" @@ -1649,8 +1823,8 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -1747,6 +1921,45 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tracing" version = "0.1.44" @@ -1838,11 +2051,28 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[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.2.2" +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 = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -1882,6 +2112,12 @@ dependencies = [ "crossbeam", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -2011,7 +2247,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2079,6 +2315,15 @@ 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", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2088,6 +2333,76 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2183,7 +2498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/lsf/Cargo.toml b/lsf/Cargo.toml index 243d3761..5907587f 100644 --- a/lsf/Cargo.toml +++ b/lsf/Cargo.toml @@ -15,5 +15,8 @@ clap = { version = "4", features = ["derive"] } anyhow = "1.0.97" crossbeam-channel = "0.5.15" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -clap-verbosity-flag = { version = "3.0.4", features = ["tracing"] } -rustyline = "18.0.0" +clap-verbosity-flag = {version = "3.0.4", features = ["tracing"]} +ratatui = "0.29.0" +crossterm = "0.28.1" +jiff = "0.2" +toml = "1.1" diff --git a/lsf/src/cli.rs b/lsf/src/cli.rs index 482c8ea4..6f0308eb 100644 --- a/lsf/src/cli.rs +++ b/lsf/src/cli.rs @@ -6,8 +6,21 @@ pub struct Cli { #[clap(long, default_value = "false")] /// Open enabled, cache was ignored and filesystem will be rewalked. pub refresh: bool, + #[clap(long, default_value = "false")] + /// Launch the ratatui search interface instead of the line-based prompt. + pub tui: bool, + #[clap(long, default_value = "false")] + /// Exit the TUI immediately without a quit confirmation prompt. + pub no_quit_confirm: bool, + #[clap(long, default_value = "~/.cardinal/cache.zstd")] + /// Cache file path. Supports a leading `~/`. + pub cache_path: PathBuf, #[clap(long, default_value = "/")] pub path: PathBuf, + /// Path to a TOML keybinding file. Defaults to ~/.cardinal/lsf-keys.toml + /// if the file exists; built-in defaults are used otherwise. + #[clap(long)] + pub keymap: Option, #[command(flatten)] pub verbosity: clap_verbosity_flag::Verbosity, } diff --git a/lsf/src/lib.rs b/lsf/src/lib.rs new file mode 100644 index 00000000..10925a28 --- /dev/null +++ b/lsf/src/lib.rs @@ -0,0 +1 @@ +pub mod tui; diff --git a/lsf/src/main.rs b/lsf/src/main.rs index 348d6e56..5896e6fd 100644 --- a/lsf/src/main.rs +++ b/lsf/src/main.rs @@ -1,23 +1,19 @@ mod cli; -use anyhow::{Context, Result}; -use cardinal_sdk::EventWatcher; +use anyhow::Result; use clap::Parser; use cli::Cli; -use crossbeam_channel::{Sender, bounded, unbounded}; -use rustyline::{DefaultEditor, error::ReadlineError}; -use search_cache::{HandleFSEError, SearchCache, SearchResultNode}; -use search_cancel::CancellationToken; +use lsf::tui::{ + app::{AppConfig, AppRuntime, resolve_cache_path}, + keymap::Keymap, + run_with_options, +}; use std::{ - path::{Path, PathBuf}, - sync::atomic::AtomicBool, + io::{self, Write}, + path::PathBuf, }; use tracing_subscriber::EnvFilter; -const CACHE_PATH: &str = "target/cache.zstd"; -const IGNORE_PATH: &str = "/System/Volumes/Data"; // macOS specific ignore path -static NEVER_STOPPED: AtomicBool = AtomicBool::new(false); - fn main() -> Result<()> { let cli = Cli::parse(); @@ -28,123 +24,45 @@ fn main() -> Result<()> { builder.with_max_level(cli.verbosity.tracing_level()).init(); } - let path = cli.path; - let ignore_paths = vec![PathBuf::from(IGNORE_PATH)]; - let mut cache = if cli.refresh { - println!("Walking filesystem..."); - SearchCache::walk_fs_with_ignore(&path, &ignore_paths) - } else { - println!("Try reading cache..."); - SearchCache::try_read_persistent_cache( - &path, - Path::new(CACHE_PATH), - &ignore_paths, - &NEVER_STOPPED, - ) - .unwrap_or_else(|e| { - println!("Failed to read cache: {e:?}. Re-walking filesystem..."); - SearchCache::walk_fs_with_ignore(&path, &ignore_paths) - }) - }; + let runtime = AppRuntime::start(AppConfig { + path: cli.path, + cache_path: resolve_cache_path(&cli.cache_path)?, + refresh: cli.refresh, + })?; - println!("Cache is: {cache:?}"); + if cli.tui { + let keymap = load_keymap(cli.keymap)?; + let tui_result = run_with_options(&runtime, !cli.no_quit_confirm, keymap); + runtime.shutdown()?; + return tui_result; + } - let (finish_tx, finish_rx) = bounded::>(1); - let (search_tx, search_rx) = unbounded::(); - let (search_result_tx, search_result_rx) = unbounded::>>(); + loop { + print!("> "); + io::stdout().flush()?; - std::thread::spawn(move || { - let (dev, mut event_watcher) = EventWatcher::spawn( - "/".to_string(), - cache.last_event_id(), - 0.1, - cache.ignore_paths(), - ); - println!("Processing changes of dev:{dev} during preparation."); - loop { - crossbeam_channel::select! { - recv(finish_rx) -> tx => { - let tx = tx.expect("finish_tx is closed"); - tx.send(cache).expect("finish_tx is closed"); - break; - } - recv(search_rx) -> query => { - let query = query.expect("search_tx is closed"); - let files = cache.query_files(query, CancellationToken::noop()).map(|x| x.unwrap()); - search_result_tx - .send(files) - .expect("search_result_tx is closed"); - } - recv(event_watcher) -> events => { - let events = events.expect("event_stream is closed"); - if let Err(HandleFSEError::Rescan) = cache.handle_fs_events(events) { - println!("!!!!!!!!!! Rescan triggered !!!!!!!!"); - // Here we clear event_watcher first as rescan may take a lot of time - #[allow(unused_assignments)] - { - event_watcher = EventWatcher::noop(); - } - let mut scan_root = PathBuf::new(); - let mut scan_ignore_paths = Vec::new(); - let walk_data = cache.walk_data( - &mut scan_root, - &mut scan_ignore_paths, - CancellationToken::new_scan(), - ); - let _ = cache.rescan_with_walk_data(&walk_data); - event_watcher = EventWatcher::spawn( - "/".to_string(), - cache.last_event_id(), - 0.1, - cache.ignore_paths(), - ) - .1; - } - } - } + let mut line = String::new(); + let read = io::stdin().read_line(&mut line)?; + if read == 0 { + eprintln!("EOF"); + break; } - println!("fsevent processing is done"); - }); - let mut rl = DefaultEditor::new().expect("Failed to create rustyline editor"); - loop { - let readline = rl.readline("> "); - match readline { - Ok(line) => { - let line = line.trim(); - if line.is_empty() { - continue; - } else if line == "/bye" { - break; - } + let line = line.trim(); + if line.is_empty() { + continue; + } else if line == "/bye" { + break; + } - let _ = rl.add_history_entry(line); + runtime.record_history(line)?; - search_tx - .send(line.to_string()) - .context("search_tx is closed")?; - let search_result = search_result_rx - .recv() - .context("search_result_rx is closed")?; - match search_result { - Ok(path_set) => { - for (i, path) in path_set.into_iter().enumerate() { - println!("[{i}] {:?} {:?}", path.path, path.metadata); - } - } - Err(e) => { - eprintln!("Failed to search: {e:?}"); - } + match runtime.search(line.to_string()) { + Ok(path_set) => { + for (i, path) in path_set.results.into_iter().enumerate() { + println!("[{i}] {:?} {:?}", path.path, path.metadata); } } - Err(ReadlineError::Interrupted) => { - eprintln!("Interrupted (Ctrl-C)"); - break; - } - Err(ReadlineError::Eof) => { - eprintln!("EOF (Ctrl-D)"); - break; - } Err(err) => { eprintln!("Error: {:?}", err); break; @@ -152,13 +70,17 @@ fn main() -> Result<()> { } } - let (cache_tx, cache_rx) = bounded::(1); - finish_tx.send(cache_tx).context("cache_tx is closed")?; - let cache = cache_rx.recv().context("cache_tx is closed")?; - println!("start writing cache: {cache:?}"); - cache - .flush_to_file(Path::new(CACHE_PATH)) - .context("Failed to write cache to file")?; + runtime.shutdown() +} - Ok(()) +/// Resolve and load the keymap. Explicit `--keymap` path is required to exist; +/// the default path is tried silently and falls back to built-in defaults. +fn load_keymap(explicit: Option) -> Result { + if let Some(path) = explicit { + return Keymap::load(&path); + } + let default_path = std::env::var_os("HOME") + .map(|h| PathBuf::from(h).join(".cardinal").join("lsf-keys.toml")) + .unwrap_or_default(); + Keymap::load(&default_path) } diff --git a/lsf/src/tui/actions.rs b/lsf/src/tui/actions.rs new file mode 100644 index 00000000..e3c6ece7 --- /dev/null +++ b/lsf/src/tui/actions.rs @@ -0,0 +1,190 @@ +use super::state::TuiApp; +use anyhow::Result; +use ratatui::DefaultTerminal; +use std::{ + env, + path::Path, + process::{Command, ExitStatus}, +}; + +pub(super) fn open_selected_in_editor( + terminal: &mut DefaultTerminal, + app: &mut TuiApp, +) -> Result<()> { + let Some(result) = app.selected_result() else { + app.status = "No selection to open.".to_string(); + return Ok(()); + }; + + let path = result.path.clone(); + ratatui::restore(); + let editor_result = launch_editor(&path); + *terminal = ratatui::init(); + + match editor_result { + Ok(editor) => { + app.status = format!("Opened {} with {}", path.display(), editor); + } + Err(err) => { + app.status = format!("Failed to open {}: {}", path.display(), err); + } + } + + Ok(()) +} + +pub(super) fn open_selected_item(app: &mut TuiApp) { + let Some(result) = app.selected_result() else { + app.status = "No selection to open.".to_string(); + return; + }; + let path = result.path.clone(); + match Command::new("open").arg(&path).status() { + Ok(status) if status.success() => { + app.status = format!("Opened {}", path.display()); + } + Ok(status) => { + app.status = format!( + "Failed to open {}: {}", + path.display(), + format_exit_status("open", status) + ); + } + Err(err) => { + app.status = format!("Failed to open {}: {err}", path.display()); + } + } +} + +pub(super) fn reveal_in_finder(app: &mut TuiApp) { + let Some(result) = app.selected_result() else { + app.status = "No selection to reveal.".to_string(); + return; + }; + let path = result.path.clone(); + match Command::new("open").arg("-R").arg(&path).status() { + Ok(status) if status.success() => { + app.status = format!("Revealed {}", path.display()); + } + Ok(status) => { + app.status = format!( + "Failed to reveal {}: {}", + path.display(), + format_exit_status("open -R", status) + ); + } + Err(err) => { + app.status = format!("Failed to reveal {}: {err}", path.display()); + } + } +} + +pub(super) fn copy_selected_filename(app: &mut TuiApp) { + let Some(result) = app.selected_result() else { + app.status = "No selection to copy.".to_string(); + return; + }; + let filename = result + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| result.path.display().to_string()); + copy_to_clipboard(&filename, app); + app.status = format!("Copied filename: {filename}"); +} + +pub(super) fn copy_selected_path(app: &mut TuiApp) { + let Some(result) = app.selected_result() else { + app.status = "No selection to copy.".to_string(); + return; + }; + let path = result.path.display().to_string(); + copy_to_clipboard(&path, app); + app.status = format!("Copied path: {path}"); +} + +fn copy_to_clipboard(text: &str, app: &mut TuiApp) { + use std::io::Write; + let mut child = match Command::new("pbcopy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) => { + app.status = format!("Failed to copy to clipboard: {err}"); + return; + } + }; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); +} + +pub(super) fn quick_look_selected(app: &mut TuiApp) { + let Some(result) = app.selected_result() else { + app.status = "No selection to preview.".to_string(); + return; + }; + let path = result.path.clone(); + match Command::new("qlmanage") + .arg("-p") + .arg(&path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(_) => { + app.status = format!("Quick Look: {}", path.display()); + } + Err(err) => { + app.status = format!("Failed to preview {}: {err}", path.display()); + } + } +} + +fn launch_editor(path: &Path) -> Result { + if let Some(editor) = env::var_os("VISUAL") { + run_shell_editor(&editor.to_string_lossy(), path)?; + return Ok(editor.to_string_lossy().into_owned()); + } + if let Some(editor) = env::var_os("EDITOR") { + run_shell_editor(&editor.to_string_lossy(), path)?; + return Ok(editor.to_string_lossy().into_owned()); + } + + for editor in ["nvim", "vim"] { + match Command::new(editor).arg(path).status() { + Ok(status) if status.success() => return Ok(editor.to_string()), + Ok(status) => return Err(anyhow::anyhow!(format_exit_status(editor, status))), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => return Err(err.into()), + } + } + + Err(anyhow::anyhow!( + "no editor found; set $VISUAL or $EDITOR, or install nvim/vim" + )) +} + +fn run_shell_editor(editor: &str, path: &Path) -> Result<()> { + let command = format!("{editor} {}", shell_escape(path)); + let status = Command::new("sh").arg("-lc").arg(command).status()?; + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!(format_exit_status(editor, status))) + } +} + +fn shell_escape(path: &Path) -> String { + let path = path.display().to_string().replace('\'', "'\\''"); + format!("'{path}'") +} + +fn format_exit_status(command: &str, status: ExitStatus) -> String { + match status.code() { + Some(code) => format!("{command} exited with status {code}"), + None => format!("{command} terminated by signal"), + } +} diff --git a/lsf/src/tui/app.rs b/lsf/src/tui/app.rs new file mode 100644 index 00000000..dfc8fdff --- /dev/null +++ b/lsf/src/tui/app.rs @@ -0,0 +1,378 @@ +use anyhow::{Context, Result}; +use cardinal_sdk::EventWatcher; +use crossbeam_channel::{Receiver, Sender, bounded, unbounded}; +use fswalk::WalkData; +use search_cache::{HandleFSEError, SearchCache, SearchOptions, SearchResultNode}; +use search_cancel::CancellationToken; +use std::{ + env, fs, + path::{Path, PathBuf}, + sync::{ + Arc, RwLock, + atomic::{AtomicBool, Ordering}, + }, + thread::JoinHandle, + time::Duration, +}; + +const HISTORY_PATH: &str = "target/search-history.txt"; +const IGNORE_PATH: &str = "/System/Volumes/Data"; +const MAX_HISTORY_ENTRIES: usize = 200; +static NEVER_STOPPED: AtomicBool = AtomicBool::new(false); + +pub struct AppConfig { + /// The root path to watch and search. + pub path: PathBuf, + /// The path to store the search cache. + pub cache_path: PathBuf, + /// If we should refresh the cache by full re-scan. + pub refresh: bool, +} + +pub struct AppRuntime { + command_tx: Sender, + worker: Option>>, + status: Arc>, + history: Arc>>, +} + +#[derive(Debug)] +pub struct SearchResponse { + pub results: Vec, + pub total_indexed: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppLifecycleStatus { + /// Initializing the cache by restoring the previous + /// cache or walking the filesystem if no cache is available. + Initializing, + /// Handling filesystem events and updating the cache accordingly. + Updating, + /// Cache is ready and search requests can be handled. + Ready, +} + +#[derive(Debug, Clone)] +pub struct RuntimeStatus { + pub lifecycle: AppLifecycleStatus, + pub scanned_files: usize, + pub processed_events: usize, +} + +fn set_status( + status: &Arc>, + lifecycle: AppLifecycleStatus, + scanned_files: usize, +) { + if let Ok(mut state) = status.write() { + state.lifecycle = lifecycle; + state.scanned_files = scanned_files; + } +} + +fn add_processed_events(status: &Arc>, count: usize) { + if let Ok(mut state) = status.write() { + state.processed_events += count; + } +} + +enum Command { + Search { + query: String, + respond_to: Sender>, + }, + Shutdown { + respond_to: Sender>, + }, +} + +impl AppRuntime { + pub fn start(config: AppConfig) -> Result { + let watch_path = watcher_path(&config.path); + let (command_tx, command_rx) = unbounded(); + let status = Arc::new(RwLock::new(RuntimeStatus { + lifecycle: AppLifecycleStatus::Initializing, + scanned_files: 0, + processed_events: 0, + })); + let history = Arc::new(RwLock::new(load_history(Path::new(HISTORY_PATH))?)); + let worker_status = Arc::clone(&status); + let worker = + std::thread::spawn(move || worker_loop(config, watch_path, worker_status, command_rx)); + + Ok(Self { + command_tx, + worker: Some(worker), + status, + history, + }) + } + + pub fn search(&self, query: impl Into) -> Result { + let (respond_to, response_rx) = bounded(1); + self.command_tx + .send(Command::Search { + query: query.into(), + respond_to, + }) + .context("search worker is closed")?; + response_rx + .recv() + .context("search response channel is closed")? + } + + pub fn record_history(&self, query: impl Into) -> Result> { + let query = query.into(); + let mut history = self + .history + .write() + .map_err(|_| anyhow::anyhow!("history lock poisoned"))?; + record_history_entry(&mut history, &query)?; + save_history(Path::new(HISTORY_PATH), &history)?; + Ok(history.clone()) + } + + pub fn history(&self) -> Result> { + self.history + .read() + .map(|history| history.clone()) + .map_err(|_| anyhow::anyhow!("history lock poisoned")) + } + + pub fn status(&self) -> Result { + self.status + .read() + .map(|status| status.clone()) + .map_err(|_| anyhow::anyhow!("runtime status lock poisoned")) + } + + pub fn shutdown(mut self) -> Result<()> { + let (respond_to, response_rx) = bounded(1); + self.command_tx + .send(Command::Shutdown { respond_to }) + .context("search worker is closed")?; + response_rx + .recv() + .context("shutdown response channel is closed")??; + + if let Some(worker) = self.worker.take() { + worker + .join() + .map_err(|_| anyhow::anyhow!("search worker panicked"))??; + } + + Ok(()) + } +} + +fn load_cache(config: &AppConfig, status: &Arc>) -> Result { + let ignore_paths = vec![PathBuf::from(IGNORE_PATH)]; + if config.refresh { + return walk_cache_with_progress(&config.path, &ignore_paths, status); + } + + SearchCache::try_read_persistent_cache( + &config.path, + &config.cache_path, + &ignore_paths, + &NEVER_STOPPED, + ) + .or_else(|_| walk_cache_with_progress(&config.path, &ignore_paths, status)) +} + +fn worker_loop( + config: AppConfig, + watch_path: String, + status: Arc>, + command_rx: Receiver, +) -> Result<()> { + let mut cache = load_cache(&config, &status)?; + set_status(&status, AppLifecycleStatus::Ready, cache.get_total_files()); + let (_, mut event_watcher) = EventWatcher::spawn( + watch_path.clone(), + cache.last_event_id(), + 0.1, + cache.ignore_paths(), + ); + let mut cache = Some(cache); + + loop { + crossbeam_channel::select! { + recv(command_rx) -> command => match command.context("command channel is closed")? { + Command::Search { query, respond_to } => { + let cache = cache.as_mut().expect("cache must exist before shutdown"); + let response = cache + .search_with_options(&query, SearchOptions::default(), CancellationToken::noop()) + .map(|outcome| SearchResponse { + results: outcome + .nodes + .map(|nodes| cache.expand_file_nodes(&nodes)) + .unwrap_or_default(), + total_indexed: cache.get_total_files(), + }); + let _ = respond_to.send(response); + } + Command::Shutdown { respond_to } => { + let result = cache + .take() + .expect("cache must exist before shutdown") + .flush_to_file(&config.cache_path) + .context("failed to write cache to file"); + let _ = respond_to.send(result); + break; + } + }, + recv(event_watcher) -> events => { + let cache = cache.as_mut().expect("cache must exist before shutdown"); + let events = events.context("event stream is closed")?; + add_processed_events(&status, events.len()); + if let Err(HandleFSEError::Rescan) = cache.handle_fs_events(events) { + set_status(&status, AppLifecycleStatus::Updating, cache.get_total_files()); + #[allow(unused_assignments)] + { + event_watcher = EventWatcher::noop(); + } + let mut scan_root = PathBuf::new(); + let mut scan_ignore_paths = Vec::new(); + let walk_data = cache.walk_data( + &mut scan_root, + &mut scan_ignore_paths, + CancellationToken::new_scan(), + ); + let _ = cache.rescan_with_walk_data(&walk_data); + set_status(&status, AppLifecycleStatus::Ready, cache.get_total_files()); + event_watcher = EventWatcher::spawn( + watch_path.clone(), + cache.last_event_id(), + 0.1, + cache.ignore_paths(), + ) + .1; + } + } + } + } + + Ok(()) +} + +fn watcher_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +pub fn resolve_cache_path(path: &Path) -> Result { + let raw = path.to_string_lossy(); + if raw == "~" || raw.starts_with("~/") { + let home = env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?; + let mut expanded = PathBuf::from(home); + if raw.len() > 2 { + expanded.push(&raw[2..]); + } + Ok(expanded) + } else { + Ok(path.to_path_buf()) + } +} + +fn walk_cache_with_progress( + path: &Path, + ignore_paths: &[PathBuf], + status: &Arc>, +) -> Result { + let walk_data = WalkData::new(path, ignore_paths, false, || false); + let done = AtomicBool::new(false); + + std::thread::scope(|scope| { + scope.spawn(|| { + while !done.load(Ordering::Relaxed) { + let scanned = walk_data.num_files.load(Ordering::Relaxed) + + walk_data.num_dirs.load(Ordering::Relaxed); + set_status(status, AppLifecycleStatus::Initializing, scanned); + std::thread::sleep(Duration::from_millis(75)); + } + }); + + let cache = SearchCache::walk_fs_with_walk_data(&walk_data, &NEVER_STOPPED) + .expect("filesystem walk should complete"); + done.store(true, Ordering::Relaxed); + let scanned = walk_data.num_files.load(Ordering::Relaxed) + + walk_data.num_dirs.load(Ordering::Relaxed); + set_status(status, AppLifecycleStatus::Initializing, scanned); + Ok(cache) + }) +} + +fn load_history(path: &Path) -> Result> { + match fs::read_to_string(path) { + Ok(contents) => Ok(contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(err) => Err(err.into()), + } +} + +fn save_history(path: &Path, history: &[String]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut content = history.join("\n"); + if !content.is_empty() { + content.push('\n'); + } + fs::write(path, content)?; + Ok(()) +} + +fn record_history_entry(history: &mut Vec, query: &str) -> Result<()> { + let query = query.trim(); + if query.is_empty() { + return Ok(()); + } + if query.contains('\n') { + anyhow::bail!("history entries must be single-line"); + } + history.retain(|entry| entry != query); + history.push(query.to_string()); + if history.len() > MAX_HISTORY_ENTRIES { + let drain = history.len() - MAX_HISTORY_ENTRIES; + history.drain(..drain); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{record_history_entry, resolve_cache_path, watcher_path}; + use std::path::Path; + + #[test] + fn watcher_path_uses_requested_root() { + assert_eq!( + watcher_path(Path::new("/usr/local/go-1.20")), + "/usr/local/go-1.20" + ); + } + + #[test] + fn watcher_path_preserves_filesystem_root() { + assert_eq!(watcher_path(Path::new("/")), "/"); + } + + #[test] + fn record_history_moves_existing_query_to_end() { + let mut history = vec!["alpha".to_string(), "beta".to_string()]; + record_history_entry(&mut history, "alpha").unwrap(); + assert_eq!(history, vec!["beta".to_string(), "alpha".to_string()]); + } + + #[test] + fn resolve_cache_path_keeps_absolute_path() { + let path = Path::new("/tmp/cardinal.zstd"); + assert_eq!(resolve_cache_path(path).unwrap(), path); + } +} diff --git a/lsf/src/tui/input.rs b/lsf/src/tui/input.rs new file mode 100644 index 00000000..d011f2e1 --- /dev/null +++ b/lsf/src/tui/input.rs @@ -0,0 +1,249 @@ +use super::{ + actions::{ + copy_selected_filename, copy_selected_path, open_selected_in_editor, open_selected_item, + quick_look_selected, reveal_in_finder, + }, + app::{AppLifecycleStatus, AppRuntime}, + keymap::match_key, + render::{layout_for_area, render}, + state::{Focus, SortKey, TuiApp, scroll_results}, +}; +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; +use ratatui::{DefaultTerminal, layout::Rect}; +use std::time::{Duration, Instant}; + +pub(super) fn run_app( + terminal: &mut DefaultTerminal, + runtime: &AppRuntime, + confirm_quit: bool, + keymap: super::keymap::Keymap, +) -> Result<()> { + let mut app = TuiApp::new(); + app.confirm_quit = confirm_quit; + app.keymap = keymap; + app.set_history(runtime.history()?); + app.update_runtime_status(runtime.status()?); + if app.runtime_status.lifecycle == AppLifecycleStatus::Ready { + app.search_if_ready(runtime); + } + + loop { + app.tick = app.tick.wrapping_add(1); + let latest_status = runtime.status()?; + let became_ready = app.runtime_status.lifecycle != AppLifecycleStatus::Ready + && latest_status.lifecycle == AppLifecycleStatus::Ready; + app.update_runtime_status(latest_status); + if became_ready { + app.search_if_ready(runtime); + } + if let Some(deadline) = app.pending_search_at + && Instant::now() >= deadline + { + app.search_if_ready(runtime); + } + terminal.draw(|frame| render(&app, frame))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } + + if app.quit_confirm_open { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => break, + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.close_quit_confirm(); + } + _ => {} + } + continue; + } + + if app.details_popup_open { + let km = &app.keymap; + if key.code == KeyCode::Enter + || key.code == KeyCode::Esc + || match_key(&km.global.quit, key.code, key.modifiers) + { + app.close_popup(); + } else if match_key(&km.results.open_editor, key.code, key.modifiers) { + open_selected_in_editor(terminal, &mut app)?; + } else if match_key(&km.results.open_item, key.code, key.modifiers) { + open_selected_item(&mut app); + } else if match_key(&km.results.reveal_in_finder, key.code, key.modifiers) { + reveal_in_finder(&mut app); + } else if match_key(&km.results.copy_filename, key.code, key.modifiers) { + copy_selected_filename(&mut app); + } else if match_key(&km.results.copy_path, key.code, key.modifiers) { + copy_selected_path(&mut app); + } else if match_key(&km.results.quick_look, key.code, key.modifiers) { + quick_look_selected(&mut app); + } else if match_key(&km.results.sort_filename, key.code, key.modifiers) { + app.toggle_sort(SortKey::Filename); + } else if match_key(&km.results.sort_path, key.code, key.modifiers) { + app.toggle_sort(SortKey::FullPath); + } else if match_key(&km.results.sort_size, key.code, key.modifiers) { + app.toggle_sort(SortKey::Size); + } else if match_key(&km.results.sort_modified, key.code, key.modifiers) { + app.toggle_sort(SortKey::Modified); + } else if match_key(&km.results.sort_created, key.code, key.modifiers) { + app.toggle_sort(SortKey::Created); + } + continue; + } + + if app.help_open { + let km = &app.keymap; + if match_key(&km.leader.help, key.code, key.modifiers) + || match_key(&km.global.quit, key.code, key.modifiers) + || key.code == KeyCode::Esc + || key.code == KeyCode::Enter + { + app.help_open = false; + app.help_scroll = 0; + } else if match_key(&km.results.scroll_down, key.code, key.modifiers) { + app.help_scroll = app.help_scroll.saturating_add(1); + } else if match_key(&km.results.scroll_up, key.code, key.modifiers) { + app.help_scroll = app.help_scroll.saturating_sub(1); + } + continue; + } + + if app.pending_ctrl_w { + let km = &app.keymap; + if match_key(&km.leader.focus_query, key.code, key.modifiers) { + app.set_focus(Focus::Query); + } else if match_key(&km.leader.focus_results, key.code, key.modifiers) { + app.set_focus(Focus::Results); + } else if match_key(&km.leader.help, key.code, key.modifiers) { + app.toggle_help(); + } + app.clear_ctrl_w_pending(); + continue; + } + + // --- global leader --- + if match_key(&app.keymap.global.leader, key.code, key.modifiers) { + app.start_ctrl_w(); + continue; + } + + if app.focus == Focus::Query { + if match_key(&app.keymap.query.clear, key.code, key.modifiers) { + if !app.query.is_empty() { + app.clear_query(); + app.schedule_search(); + } else if app.request_quit() { + break; + } + } else if key.code == KeyCode::Backspace { + if app.delete_backwards() { + app.schedule_search(); + } + } else if match_key(&app.keymap.query.submit, key.code, key.modifiers) { + let query = app.query.trim().to_string(); + if !query.is_empty() { + app.set_history(runtime.record_history(&query)?); + app.status = format!("Saved query to history: {query}"); + } + app.search_if_ready(runtime); + app.set_focus(Focus::Results); + } else if match_key( + &app.keymap.query.history_older, + key.code, + key.modifiers, + ) { + app.browse_history_older(); + app.schedule_search(); + } else if match_key( + &app.keymap.query.history_newer, + key.code, + key.modifiers, + ) { + if app.history_index.is_some() { + app.browse_history_newer(); + app.schedule_search(); + } + } else if match_key(&app.keymap.query.cursor_left, key.code, key.modifiers) + { + app.move_cursor_left(); + } else if match_key(&app.keymap.query.cursor_right, key.code, key.modifiers) + { + app.move_cursor_right(); + } else if match_key(&app.keymap.query.cursor_home, key.code, key.modifiers) + { + app.move_cursor_home(); + } else if match_key(&app.keymap.query.cursor_end, key.code, key.modifiers) { + app.move_cursor_end(); + } else if let KeyCode::Char(ch) = key.code + && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + { + app.insert_char(ch); + app.schedule_search(); + } + } else { + // Focus::Results + let km = &app.keymap; + if match_key(&km.global.quit, key.code, key.modifiers) { + if app.request_quit() { + break; + } + } else if match_key(&km.results.focus_out, key.code, key.modifiers) { + app.set_focus(Focus::Query); + } else if match_key(&km.results.scroll_down, key.code, key.modifiers) { + scroll_results(&mut app, 1); + } else if match_key(&km.results.scroll_up, key.code, key.modifiers) { + scroll_results(&mut app, -1); + } else if match_key(&km.results.open_details, key.code, key.modifiers) { + app.open_popup(); + } else if match_key(&km.results.open_editor, key.code, key.modifiers) { + open_selected_in_editor(terminal, &mut app)?; + } else if match_key(&km.results.open_item, key.code, key.modifiers) { + open_selected_item(&mut app); + } else if match_key(&km.results.reveal_in_finder, key.code, key.modifiers) { + reveal_in_finder(&mut app); + } else if match_key(&km.results.copy_filename, key.code, key.modifiers) { + copy_selected_filename(&mut app); + } else if match_key(&km.results.copy_path, key.code, key.modifiers) { + copy_selected_path(&mut app); + } else if match_key(&km.results.quick_look, key.code, key.modifiers) { + quick_look_selected(&mut app); + } else if match_key(&km.results.sort_filename, key.code, key.modifiers) { + app.toggle_sort(SortKey::Filename); + } else if match_key(&km.results.sort_path, key.code, key.modifiers) { + app.toggle_sort(SortKey::FullPath); + } else if match_key(&km.results.sort_size, key.code, key.modifiers) { + app.toggle_sort(SortKey::Size); + } else if match_key(&km.results.sort_modified, key.code, key.modifiers) { + app.toggle_sort(SortKey::Modified); + } else if match_key(&km.results.sort_created, key.code, key.modifiers) { + app.toggle_sort(SortKey::Created); + } + } + } + Event::Mouse(mouse) if app.focus == Focus::Results => { + app.pending_ctrl_w = false; + if app.details_popup_open || app.quit_confirm_open { + continue; + } + let size = terminal.size()?; + let layout = layout_for_area(Rect::new(0, 0, size.width, size.height)); + if !layout.results.contains((mouse.column, mouse.row).into()) { + continue; + } + app.set_focus(Focus::Results); + match mouse.kind { + MouseEventKind::ScrollDown => scroll_results(&mut app, 3), + MouseEventKind::ScrollUp => scroll_results(&mut app, -3), + _ => {} + } + } + _ => {} + } + } + } + + Ok(()) +} diff --git a/lsf/src/tui/keymap.rs b/lsf/src/tui/keymap.rs new file mode 100644 index 00000000..779fd248 --- /dev/null +++ b/lsf/src/tui/keymap.rs @@ -0,0 +1,445 @@ +//! Configurable key bindings for the TUI. +//! +//! # Example config +//! ```toml +//! [global] +//! quit = "q" +//! leader = "ctrl+w" +//! +//! [leader] +//! focus_query = ["j", "up"] +//! focus_results = ["k", "down"] +//! help = "?" +//! +//! [query] +//! clear = ["esc", "ctrl+u"] +//! submit = "enter" +//! history_older = "up" +//! history_newer = "down" +//! +//! [results] +//! scroll_down = ["j", "down"] +//! scroll_up = ["k", "up"] +//! open_details = "enter" +//! open_editor = "v" +//! sort_filename = "1" +//! sort_path = "2" +//! sort_size = "3" +//! sort_modified = "4" +//! sort_created = "5" +//! ``` + +use anyhow::{Context, Result}; +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::{Deserialize, Deserializer, de}; +use std::{fmt, path::Path, str::FromStr}; + +// ── KeySpec ────────────────────────────────────────────────────────────────── + +/// A single parsed key: a [`KeyCode`] plus a [`KeyModifiers`] bitmask. +/// +/// Accepted string syntax: `[modifier+…+]key` +/// where modifier is `ctrl`, `shift`, or `alt`, and key is a named key +/// (`enter`, `esc`, `up`, `down`, `left`, `right`, `home`, `end`, +/// `backspace`, `delete`, `tab`, `space`, `f1`–`f12`) or a single character. +/// +/// Examples: `"q"`, `"ctrl+w"`, `"shift+enter"`, `"f5"` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeySpec { + pub code: KeyCode, + pub mods: KeyModifiers, +} + +impl KeySpec { + pub fn new(code: KeyCode, mods: KeyModifiers) -> Self { + Self { code, mods } + } + + pub fn plain(code: KeyCode) -> Self { + Self { + code, + mods: KeyModifiers::NONE, + } + } + + pub fn ctrl(ch: char) -> Self { + Self { + code: KeyCode::Char(ch), + mods: KeyModifiers::CONTROL, + } + } + + pub fn plain_char(ch: char) -> Self { + Self::plain(KeyCode::Char(ch)) + } + + /// Returns `true` if this spec matches the given code/modifiers pair. + pub fn matches(self, code: KeyCode, mods: KeyModifiers) -> bool { + self.code == code && self.mods == mods + } +} + +impl FromStr for KeySpec { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + let parts: Vec<&str> = s.split('+').collect(); + if parts.is_empty() { + return Err("empty key spec".to_string()); + } + + let key_str = parts.last().unwrap(); + let modifier_parts = &parts[..parts.len() - 1]; + + let mut mods = KeyModifiers::NONE; + for m in modifier_parts { + match m.to_lowercase().as_str() { + "ctrl" | "control" => mods |= KeyModifiers::CONTROL, + "shift" => mods |= KeyModifiers::SHIFT, + "alt" | "meta" => mods |= KeyModifiers::ALT, + "" => {} + other => return Err(format!("unknown modifier: {other}")), + } + } + + let code = match key_str.to_lowercase().as_str() { + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "backspace" | "bs" => KeyCode::Backspace, + "delete" | "del" => KeyCode::Delete, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" | "pgup" | "page_up" => KeyCode::PageUp, + "pagedown" | "pgdn" | "page_down" => KeyCode::PageDown, + "tab" => KeyCode::Tab, + "space" => KeyCode::Char(' '), + f if f.starts_with('f') && f.len() > 1 => { + let n: u8 = f[1..] + .parse() + .map_err(|_| format!("unknown key: {key_str}"))?; + KeyCode::F(n) + } + c if c.chars().count() == 1 => KeyCode::Char(c.chars().next().unwrap()), + other => return Err(format!("unknown key: {other}")), + }; + + Ok(KeySpec { code, mods }) + } +} + +impl<'de> Deserialize<'de> for KeySpec { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse::().map_err(de::Error::custom) + } +} + +// ── Key list deserialization (string OR array) ──────────────────────────────── + +fn deser_key_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = Vec; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "a key string or list of key strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(vec![v.parse::().map_err(E::custom)?]) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(s) = seq.next_element::()? { + out.push(s.parse::().map_err(de::Error::custom)?); + } + Ok(out) + } + } + + deserializer.deserialize_any(Visitor) +} + +// ── Default helpers ─────────────────────────────────────────────────────────── + +fn k(s: &str) -> KeySpec { + s.parse().expect("hard-coded default key spec is valid") +} + +fn ks(specs: &[&str]) -> Vec { + specs.iter().map(|s| k(s)).collect() +} + +// Global +fn default_quit() -> Vec { + ks(&["q"]) +} +fn default_leader() -> Vec { + ks(&["ctrl+w"]) +} + +// Leader +fn default_focus_query() -> Vec { + ks(&["j", "up"]) +} +fn default_focus_results() -> Vec { + ks(&["k", "down"]) +} +fn default_help() -> Vec { + ks(&["?"]) +} + +// Query +fn default_query_clear() -> Vec { + ks(&["esc", "ctrl+u"]) +} +fn default_query_submit() -> Vec { + ks(&["enter", "down"]) +} +fn default_history_older() -> Vec { + ks(&["up"]) +} +fn default_history_newer() -> Vec { + ks(&["down"]) +} +fn default_cursor_left() -> Vec { + ks(&["left"]) +} +fn default_cursor_right() -> Vec { + ks(&["right"]) +} +fn default_cursor_home() -> Vec { + ks(&["home"]) +} +fn default_cursor_end() -> Vec { + ks(&["end"]) +} + +// Results +fn default_scroll_down() -> Vec { + ks(&["j", "down"]) +} +fn default_scroll_up() -> Vec { + ks(&["k", "up"]) +} +fn default_open_details() -> Vec { + ks(&["enter"]) +} +fn default_open_editor() -> Vec { + ks(&["v"]) +} +fn default_sort_filename() -> Vec { + ks(&["1"]) +} +fn default_sort_path() -> Vec { + ks(&["2"]) +} +fn default_sort_size() -> Vec { + ks(&["3"]) +} +fn default_sort_modified() -> Vec { + ks(&["4"]) +} +fn default_sort_created() -> Vec { + ks(&["5"]) +} +fn default_open_item() -> Vec { + ks(&["o"]) +} +fn default_reveal_in_finder() -> Vec { + ks(&["r"]) +} +fn default_copy_filename() -> Vec { + ks(&["y"]) +} +fn default_copy_path() -> Vec { + ks(&["c"]) +} +fn default_quick_look() -> Vec { + ks(&["space"]) +} +fn default_focus_out() -> Vec { + ks(&["esc"]) +} + +// ── Keymap ──────────────────────────────────────────────────────────────────── + +/// Top-level keymap with four sections. +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct Keymap { + pub global: GlobalKeys, + pub leader: LeaderKeys, + pub query: QueryKeys, + pub results: ResultKeys, +} + +/// Global keys active in both focus modes (Results) plus the leader prefix. +#[derive(Deserialize)] +pub struct GlobalKeys { + /// Quit the TUI (active only when Results has focus and no popup is open). + #[serde(deserialize_with = "deser_key_list", default = "default_quit")] + pub quit: Vec, + /// Leader prefix key (default `Ctrl+W`). + #[serde(deserialize_with = "deser_key_list", default = "default_leader")] + pub leader: Vec, +} + +/// Keys active while waiting for the second key of a leader sequence. +#[derive(Deserialize)] +pub struct LeaderKeys { + #[serde(deserialize_with = "deser_key_list", default = "default_focus_query")] + pub focus_query: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_focus_results")] + pub focus_results: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_help")] + pub help: Vec, +} + +/// Keys active when the Query box has focus. +#[derive(Deserialize)] +pub struct QueryKeys { + /// Clear the query (and quit if already empty). + #[serde(deserialize_with = "deser_key_list", default = "default_query_clear")] + pub clear: Vec, + /// Submit the query and move focus to Results. + #[serde(deserialize_with = "deser_key_list", default = "default_query_submit")] + pub submit: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_history_older")] + pub history_older: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_history_newer")] + pub history_newer: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_cursor_left")] + pub cursor_left: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_cursor_right")] + pub cursor_right: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_cursor_home")] + pub cursor_home: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_cursor_end")] + pub cursor_end: Vec, +} + +/// Keys active when the Results table has focus. +#[derive(Deserialize)] +pub struct ResultKeys { + #[serde(deserialize_with = "deser_key_list", default = "default_scroll_down")] + pub scroll_down: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_scroll_up")] + pub scroll_up: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_open_details")] + pub open_details: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_open_editor")] + pub open_editor: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_open_item")] + pub open_item: Vec, + #[serde( + deserialize_with = "deser_key_list", + default = "default_reveal_in_finder" + )] + pub reveal_in_finder: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_copy_filename")] + pub copy_filename: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_copy_path")] + pub copy_path: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_quick_look")] + pub quick_look: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_sort_filename")] + pub sort_filename: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_sort_path")] + pub sort_path: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_sort_size")] + pub sort_size: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_sort_modified")] + pub sort_modified: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_sort_created")] + pub sort_created: Vec, + #[serde(deserialize_with = "deser_key_list", default = "default_focus_out")] + pub focus_out: Vec, // TODO: add default? +} + +impl Default for GlobalKeys { + fn default() -> Self { + Self { + quit: default_quit(), + leader: default_leader(), + } + } +} + +impl Default for LeaderKeys { + fn default() -> Self { + Self { + focus_query: default_focus_query(), + focus_results: default_focus_results(), + help: default_help(), + } + } +} + +impl Default for QueryKeys { + fn default() -> Self { + Self { + clear: default_query_clear(), + submit: default_query_submit(), + history_older: default_history_older(), + history_newer: default_history_newer(), + cursor_left: default_cursor_left(), + cursor_right: default_cursor_right(), + cursor_home: default_cursor_home(), + cursor_end: default_cursor_end(), + } + } +} + +impl Default for ResultKeys { + fn default() -> Self { + Self { + scroll_down: default_scroll_down(), + scroll_up: default_scroll_up(), + open_details: default_open_details(), + open_editor: default_open_editor(), + open_item: default_open_item(), + reveal_in_finder: default_reveal_in_finder(), + copy_filename: default_copy_filename(), + copy_path: default_copy_path(), + quick_look: default_quick_look(), + sort_filename: default_sort_filename(), + sort_path: default_sort_path(), + sort_size: default_sort_size(), + sort_modified: default_sort_modified(), + sort_created: default_sort_created(), + focus_out: default_focus_out(), + } + } +} + +// ── Loading ─────────────────────────────────────────────────────────────────── + +impl Keymap { + /// Load a keymap from a TOML file, merging with built-in defaults for any + /// missing sections or keys. Returns the default keymap if the file does + /// not exist. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(path) + .with_context(|| format!("reading keymap file {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("parsing keymap file {}", path.display())) + } +} + +/// Returns `true` if any binding in `specs` matches `(code, mods)`. +pub fn match_key(specs: &[KeySpec], code: KeyCode, mods: KeyModifiers) -> bool { + specs.iter().any(|s| s.matches(code, mods)) +} diff --git a/lsf/src/tui/mod.rs b/lsf/src/tui/mod.rs new file mode 100644 index 00000000..d5abfda7 --- /dev/null +++ b/lsf/src/tui/mod.rs @@ -0,0 +1,30 @@ +mod actions; +pub mod app; +mod input; +pub mod keymap; +mod render; +mod sort; +mod state; + +use anyhow::Result; +use app::AppRuntime; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, +}; +use input::run_app; +use keymap::Keymap; +use std::io::stdout; + +pub fn run(runtime: &AppRuntime) -> Result<()> { + run_with_options(runtime, true, Keymap::default()) +} + +pub fn run_with_options(runtime: &AppRuntime, confirm_quit: bool, keymap: Keymap) -> Result<()> { + execute!(stdout(), EnableMouseCapture)?; + let mut terminal = ratatui::init(); + let result = run_app(&mut terminal, runtime, confirm_quit, keymap); + ratatui::restore(); + execute!(stdout(), DisableMouseCapture)?; + result +} diff --git a/lsf/src/tui/render.rs b/lsf/src/tui/render.rs new file mode 100644 index 00000000..c4eb3aaa --- /dev/null +++ b/lsf/src/tui/render.rs @@ -0,0 +1,434 @@ +use super::{ + app::AppLifecycleStatus, + state::{Focus, SortDirection, SortKey, SortState, TuiApp, display_width}, +}; +use jiff::Timestamp; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap}, +}; +use search_cache::SearchResultNode; +use std::{num::NonZeroU32, time::Duration}; + +pub(super) struct AppLayout { + pub query: Rect, + pub results: Rect, + pub status: Rect, +} + +pub(super) fn render(app: &TuiApp, frame: &mut Frame) { + let layout = layout_for_area(frame.area()); + + // 1. query input box + let query_border = if app.focus == Focus::Query { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let search = Paragraph::new(app.query.as_str()).block( + Block::default() + .borders(Borders::ALL) + .title("Query") + .border_style(query_border), + ); + frame.render_widget(search, layout.query); + if app.focus == Focus::Query { + frame.set_cursor_position(( + layout.query.x + 1 + display_width(&app.query[..app.cursor]) as u16, + layout.query.y + 1, + )); + } + + // 2. result table or indexing panel (the result is not ready yet) + if app.runtime_status.lifecycle == AppLifecycleStatus::Ready { + let rows: Vec = app + .results + .iter() + .map(|result| { + let columns = result_columns(result); + Row::new([ + Cell::from(columns.filename), + Cell::from(columns.directory), + Cell::from(columns.size), + Cell::from(columns.modified), + Cell::from(columns.created), + ]) + }) + .collect(); + + let mut table_state = + TableState::default().with_selected((!app.results.is_empty()).then_some(app.selected)); + let header = Row::new([ + header_label("Filename", SortKey::Filename, app.sort), + header_label("Path", SortKey::FullPath, app.sort), + header_label("Size", SortKey::Size, app.sort), + header_label("Modified", SortKey::Modified, app.sort), + header_label("Created", SortKey::Created, app.sort), + ]) + .style( + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + ); + let results_border = if app.focus == Focus::Results { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + let table = Table::new( + rows, + [ + Constraint::Percentage(22), + Constraint::Percentage(34), + Constraint::Length(10), + Constraint::Length(21), + Constraint::Length(21), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title("Results") + .border_style(results_border), + ) + .row_highlight_style( + Style::default() + .bg(Color::Rgb(25, 52, 77)) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + frame.render_stateful_widget(table, layout.results, &mut table_state); + } else { + frame.render_widget(indexing_panel(app), layout.results); + } + + // 3. status bar + let status = Paragraph::new(status_bar_line(app)).block(Block::default().borders(Borders::TOP)); + frame.render_widget(status, layout.status); + + if app.details_popup_open { + render_popup(frame, app); + } + if app.quit_confirm_open { + render_quit_confirm(frame); + } + if app.help_open { + render_help(frame, app.help_scroll); + } +} + +/// Render the indexing panel with a progress bar and status text. +fn indexing_panel(app: &TuiApp) -> Paragraph<'static> { + let lifecycle = match app.runtime_status.lifecycle { + AppLifecycleStatus::Initializing => "Initializing index", + AppLifecycleStatus::Updating => "Updating index", + _ => unreachable!(), + }; + let width = 32usize; + let pos = (app.tick as usize) % (width + 6); + let bar: String = (0..width) + .map(|idx| { + if idx >= pos.saturating_sub(5) && idx <= pos.min(width.saturating_sub(1)) { + '=' + } else { + ' ' + } + }) + .collect(); + let text = format!( + "{lifecycle}\n\n[{}]\n\nScanned files: {}\n\nYou can type a query now; results will appear when indexing is ready.", + bar, app.runtime_status.scanned_files + ); + Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Indexing")) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }) +} + +fn status_bar_line(app: &TuiApp) -> Line<'static> { + let lifecycle = match app.runtime_status.lifecycle { + AppLifecycleStatus::Initializing => "○ Initializing", + AppLifecycleStatus::Updating => "◑ Updating", + AppLifecycleStatus::Ready => "● Ready", + }; + let results = if app.runtime_status.lifecycle == AppLifecycleStatus::Ready { + format!("results {}", app.results.len()) + } else { + "results --".to_string() + }; + let sort = match app.sort { + Some(sort) => format!("sort {} {}", sort.key.label(), sort.direction.label()), + None => "sort off".to_string(), + }; + let events = app.runtime_status.processed_events; + Line::from(format!( + "{} | indexed {} | events {} | {} | {} | {}", + lifecycle, app.runtime_status.scanned_files, events, results, sort, app.status + )) +} + +struct ResultColumns { + filename: String, + directory: String, + size: String, + modified: String, + created: String, +} + +fn result_columns(result: &SearchResultNode) -> ResultColumns { + let full_path = result.path.display().to_string(); + let filename = result + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| full_path.clone()); + let directory = result + .path + .parent() + .map(|parent| parent.display().to_string()) + .unwrap_or_else(|| "/".to_string()); + let metadata = result.metadata.as_ref(); + let size = metadata + .as_ref() + .map(|metadata| format_size(metadata.size())) + .unwrap_or_else(|| "—".to_string()); + let modified = metadata + .as_ref() + .and_then(|metadata| metadata.mtime()) + .map(format_unix_timestamp) + .unwrap_or_else(|| "—".to_string()); + let created = metadata + .as_ref() + .and_then(|metadata| metadata.ctime()) + .map(format_unix_timestamp) + .unwrap_or_else(|| "—".to_string()); + + ResultColumns { + filename, + directory, + size, + modified, + created, + } +} + +pub(super) fn popup_details(result: &SearchResultNode) -> String { + let kind = result + .metadata + .as_ref() + .as_ref() + .map(|metadata| format_file_type(metadata.r#type())) + .unwrap_or_else(|| "unknown".to_string()); + let size = result + .metadata + .as_ref() + .as_ref() + .map(|metadata| format_size(metadata.size())) + .unwrap_or_else(|| "n/a".to_string()); + let modified = result + .metadata + .as_ref() + .and_then(|metadata| metadata.mtime()) + .map(format_unix_timestamp) + .unwrap_or_else(|| "n/a".to_string()); + let created = result + .metadata + .as_ref() + .and_then(|metadata| metadata.ctime()) + .map(format_unix_timestamp) + .unwrap_or_else(|| "n/a".to_string()); + + format!( + "Path: {}\nType: {}\nSize: {}\nModified: {}\nCreated: {}\n\nPress Enter or Esc to close.", + result.path.display(), + kind, + size, + modified, + created + ) +} + +fn render_popup(frame: &mut Frame, app: &TuiApp) { + let Some(result) = app.results.get(app.selected) else { + return; + }; + let area = centered_rect(70, 55, frame.area()); + frame.render_widget(Clear, area); + let popup = Paragraph::new(popup_details(result)) + .block( + Block::default() + .title("Item Details") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(popup, area); +} + +fn render_help(frame: &mut Frame, scroll: u16) { + let area = centered_rect(64, 80, frame.area()); + frame.render_widget(Clear, area); + let text = "\ +Keyboard Shortcuts + +── Query box ─────────────────────────────── + Type Edit search query (live search) + Enter Save query to history & search + Esc Clear query (or quit if empty) + Ctrl+U Clear query + ← → Move cursor left / right + Home / End Move cursor to start / end + ↑ ↓ Browse query history + +── Results table ─────────────────────────── + j / ↓ Move selection down + k / ↑ Move selection up + Enter Open item details popup + o Open item (default app) + v Open selected file in editor + r Reveal in Finder + y Copy filename to clipboard + c Copy path to clipboard + Space Quick Look preview + 1 Sort by filename + 2 Sort by path + 3 Sort by size + 4 Sort by modified date + 5 Sort by created date + +── Global ────────────────────────────────── + Ctrl+W j/↑ Switch focus to query box + Ctrl+W k/↓ Switch focus to results table + Ctrl+W ? Toggle this help panel + q Quit lsf + +── Popups ────────────────────────────────── + Esc / Enter Close popup + q Close details popup + o Open item (default app) + v Open file in editor (details) + r Reveal in Finder + y Copy filename to clipboard + c Copy path to clipboard + Space Quick Look preview + +Press q, ?, Esc or Enter to close."; + let popup = Paragraph::new(text) + .block( + Block::default() + .title(" Help ") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ) + .scroll((scroll, 0)); + frame.render_widget(popup, area); +} + +fn render_quit_confirm(frame: &mut Frame) { + let area = centered_rect(44, 24, frame.area()); + frame.render_widget(Clear, area); + let popup = Paragraph::new("Quit lsf?\n\nPress Enter or y to quit.\nPress Esc or n to stay.") + .block( + Block::default() + .title("Confirm Quit") + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }); + frame.render_widget(popup, area); +} + +fn centered_rect(horizontal_percent: u16, vertical_percent: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - vertical_percent) / 2), + Constraint::Percentage(vertical_percent), + Constraint::Percentage((100 - vertical_percent) / 2), + ]) + .split(area); + let horizontal = Layout::horizontal([ + Constraint::Percentage((100 - horizontal_percent) / 2), + Constraint::Percentage(horizontal_percent), + Constraint::Percentage((100 - horizontal_percent) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +/// Layout: 3 vertical sections - query, results, status +pub(super) fn layout_for_area(area: Rect) -> AppLayout { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + AppLayout { + query: vertical[0], + results: vertical[1], + status: vertical[2], + } +} + +fn header_label(label: &str, key: SortKey, sort: Option) -> String { + match sort { + Some(active) if active.key == key => { + let arrow = match active.direction { + SortDirection::Asc => "↑", + SortDirection::Desc => "↓", + }; + format!("{label} {arrow}") + } + _ => label.to_string(), + } +} + +fn format_file_type(file_type: fswalk::NodeFileType) -> String { + match file_type { + fswalk::NodeFileType::File => "file".to_string(), + fswalk::NodeFileType::Dir => "dir".to_string(), + fswalk::NodeFileType::Symlink => "symlink".to_string(), + fswalk::NodeFileType::Unknown => "unknown".to_string(), + } +} + +fn format_size(size: i64) -> String { + if size < 0 { + return "-".to_string(); + } + + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = size as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + + if unit == 0 { + format!("{} {}", size, UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +fn format_unix_timestamp(timestamp: NonZeroU32) -> String { + Timestamp::from_second(timestamp.get() as i64) + .map(|ts| ts.to_string()) + .unwrap_or_else(|_| { + format!( + "unix:{}", + Duration::from_secs(timestamp.get() as u64).as_secs() + ) + }) +} diff --git a/lsf/src/tui/sort.rs b/lsf/src/tui/sort.rs new file mode 100644 index 00000000..217a8097 --- /dev/null +++ b/lsf/src/tui/sort.rs @@ -0,0 +1,73 @@ +use super::state::{SortDirection, SortKey, SortState}; +use search_cache::SearchResultNode; +use std::cmp::Ordering; + +pub(super) fn compare_results( + left: &SearchResultNode, + right: &SearchResultNode, + sort: SortState, +) -> Ordering { + let ordering = match sort.key { + SortKey::Filename => compare_strings(filename_for_sort(left), filename_for_sort(right)), + SortKey::FullPath => compare_strings( + &left.path.display().to_string(), + &right.path.display().to_string(), + ), + SortKey::Size => compare_option_u32(size_for_sort(left), size_for_sort(right)) + .then_with(|| compare_strings(filename_for_sort(left), filename_for_sort(right))), + SortKey::Modified => compare_option_u32(mtime_for_sort(left), mtime_for_sort(right)) + .then_with(|| compare_strings(filename_for_sort(left), filename_for_sort(right))), + SortKey::Created => compare_option_u32(ctime_for_sort(left), ctime_for_sort(right)) + .then_with(|| compare_strings(filename_for_sort(left), filename_for_sort(right))), + }; + + match sort.direction { + SortDirection::Asc => ordering, + SortDirection::Desc => ordering.reverse(), + } +} + +fn compare_strings(left: &str, right: &str) -> Ordering { + left.to_lowercase() + .cmp(&right.to_lowercase()) + .then_with(|| left.cmp(right)) +} + +fn compare_option_u32(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.cmp(&right), + (Some(_), None) => Ordering::Greater, + (None, Some(_)) => Ordering::Less, + (None, None) => Ordering::Equal, + } +} + +fn filename_for_sort(result: &SearchResultNode) -> &str { + result + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() +} + +fn size_for_sort(result: &SearchResultNode) -> Option { + result + .metadata + .as_ref() + .as_ref() + .and_then(|metadata| u32::try_from(metadata.size()).ok()) +} + +fn mtime_for_sort(result: &SearchResultNode) -> Option { + result + .metadata + .as_ref() + .and_then(|metadata| metadata.mtime().map(|t| t.get())) +} + +fn ctime_for_sort(result: &SearchResultNode) -> Option { + result + .metadata + .as_ref() + .and_then(|metadata| metadata.ctime().map(|t| t.get())) +} diff --git a/lsf/src/tui/state.rs b/lsf/src/tui/state.rs new file mode 100644 index 00000000..efa72d80 --- /dev/null +++ b/lsf/src/tui/state.rs @@ -0,0 +1,414 @@ +use super::{ + app::{AppLifecycleStatus, AppRuntime, RuntimeStatus, SearchResponse}, + keymap::Keymap, + sort::compare_results, +}; +use search_cache::SearchResultNode; +use std::{ + path::Path, + time::{Duration, Instant}, +}; + +const SEARCH_DEBOUNCE_MS: u64 = 300; + +pub(super) struct TuiApp { + pub query: String, + pub cursor: usize, + pub history: Vec, + pub history_index: Option, + pub history_draft: Option, + pub results: Vec, + pub total_indexed: usize, + pub selected: usize, + pub status: String, + pub focus: Focus, + pub pending_ctrl_w: bool, + pub details_popup_open: bool, + pub quit_confirm_open: bool, + pub help_open: bool, + pub help_scroll: u16, + pub confirm_quit: bool, + pub sort: Option, + pub runtime_status: RuntimeStatus, + pub last_ready: bool, + pub tick: u64, + /// Debounce for scheduling searches while the user is typing. + /// If `Some`, a search is scheduled to run at the specified `Instant`. + pub pending_search_at: Option, + pub keymap: Keymap, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Focus { + Query, + Results, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortKey { + Filename, + FullPath, + Size, + Modified, + Created, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortDirection { + Asc, + Desc, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) struct SortState { + pub key: SortKey, + pub direction: SortDirection, +} + +impl TuiApp { + pub fn new() -> Self { + Self { + query: String::new(), + cursor: 0, + history: Vec::new(), + history_index: None, + history_draft: None, + results: Vec::new(), + total_indexed: 0, + selected: 0, + status: + "Ctrl+W then Up/Down switches focus. Enter in query saves and moves to results." + .to_string(), + focus: Focus::Query, + pending_ctrl_w: false, + details_popup_open: false, + quit_confirm_open: false, + help_open: false, + help_scroll: 0, + confirm_quit: true, + sort: None, + runtime_status: RuntimeStatus { + lifecycle: AppLifecycleStatus::Initializing, + scanned_files: 0, + processed_events: 0, + }, + last_ready: false, + tick: 0, + pending_search_at: None, + keymap: Keymap::default(), + } + } + + /// Apply search results to the app state + pub fn apply_search_result(&mut self, response: SearchResponse, elapsed: Duration) { + let selected_path = self.selected_result().map(|result| result.path.clone()); + self.results = response.results; + self.total_indexed = response.total_indexed; + self.apply_sort(); + self.restore_selection(selected_path.as_deref()); + self.status = format!( + "{} matches from {} indexed entries (search took {:?})", + self.results.len(), + self.total_indexed, + elapsed + ); + } + + pub fn clear_query(&mut self) { + self.query.clear(); + self.cursor = 0; + self.reset_history_navigation(); + } + + pub fn move_cursor_left(&mut self) { + self.cursor = previous_char_boundary(&self.query, self.cursor); + } + + pub fn move_cursor_right(&mut self) { + self.cursor = next_char_boundary(&self.query, self.cursor); + } + + pub fn move_cursor_home(&mut self) { + self.cursor = 0; + } + + pub fn move_cursor_end(&mut self) { + self.cursor = self.query.len(); + } + + pub fn insert_char(&mut self, ch: char) { + self.query.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + self.reset_history_navigation(); + } + + pub fn delete_backwards(&mut self) -> bool { + if self.cursor == 0 { + return false; + } + let start = previous_char_boundary(&self.query, self.cursor); + self.query.drain(start..self.cursor); + self.cursor = start; + self.reset_history_navigation(); + true + } + + pub fn set_focus(&mut self, focus: Focus) { + self.focus = focus; + self.pending_ctrl_w = false; + } + + pub fn open_popup(&mut self) { + if !self.results.is_empty() { + self.details_popup_open = true; + } + } + + pub fn close_popup(&mut self) { + self.details_popup_open = false; + } + + pub fn request_quit(&mut self) -> bool { + if self.confirm_quit { + self.quit_confirm_open = true; + self.status = "Quit requested. Confirm to exit lsf.".to_string(); + false + } else { + true + } + } + + pub fn close_quit_confirm(&mut self) { + self.quit_confirm_open = false; + } + + pub fn selected_result(&self) -> Option<&SearchResultNode> { + self.results.get(self.selected) + } + + pub fn set_history(&mut self, history: Vec) { + self.history = history; + self.reset_history_navigation(); + } + + fn reset_history_navigation(&mut self) { + self.history_index = None; + self.history_draft = None; + } + + pub fn start_ctrl_w(&mut self) { + self.pending_ctrl_w = true; + self.status = "Ctrl+W pending: j/k or Up/Down to switch focus, ? for help.".to_string(); + } + + pub fn toggle_help(&mut self) { + self.help_open = !self.help_open; + self.help_scroll = 0; + self.pending_ctrl_w = false; + } + + pub fn clear_ctrl_w_pending(&mut self) { + self.pending_ctrl_w = false; + self.status = format!( + "{} matches from {} indexed entries", + self.results.len(), + self.total_indexed + ); + } + + pub fn update_runtime_status(&mut self, status: RuntimeStatus) { + let was_ready = self.last_ready; + self.last_ready = status.lifecycle == AppLifecycleStatus::Ready; + self.runtime_status = status; + if !was_ready && self.last_ready { + self.status = format!( + "Index ready: {} files scanned.", + self.runtime_status.scanned_files + ); + } + } + + /// Schedule a search to run after a debounce period. + pub fn schedule_search(&mut self) { + self.pending_search_at = Some(Instant::now() + Duration::from_millis(SEARCH_DEBOUNCE_MS)); + } + + pub fn search_if_ready(&mut self, runtime: &AppRuntime) { + self.clear_pending_search(); + if self.runtime_status.lifecycle == AppLifecycleStatus::Ready { + if self.query.trim().is_empty() { + self.results.clear(); + self.selected = 0; + self.total_indexed = self.runtime_status.scanned_files; + self.status = format!( + "Index ready: {} files scanned. Type a query to search.", + self.runtime_status.scanned_files + ); + return; + } + let now = Instant::now(); + + match runtime.search(self.query.clone()) { + Ok(response) => { + let elapsed = now.elapsed(); + self.apply_search_result(response, elapsed) + } + Err(err) => { + self.results.clear(); + self.selected = 0; + self.total_indexed = self.runtime_status.scanned_files; + self.status = format!("Search error: {err}"); + } + } + } + } + + fn clear_pending_search(&mut self) { + self.pending_search_at = None; + } + + pub fn browse_history_older(&mut self) { + if self.history.is_empty() { + return; + } + let next_index = match self.history_index { + Some(index) => index.saturating_sub(1), + None => { + self.history_draft = Some(self.query.clone()); + self.history.len() - 1 + } + }; + self.apply_history_index(next_index); + } + + pub fn browse_history_newer(&mut self) { + let Some(index) = self.history_index else { + return; + }; + if index + 1 < self.history.len() { + self.apply_history_index(index + 1); + } else { + self.history_index = None; + self.query = self.history_draft.take().unwrap_or_default(); + self.cursor = self.query.len(); + } + } + + fn apply_history_index(&mut self, index: usize) { + self.history_index = Some(index); + self.query = self.history[index].clone(); + self.cursor = self.query.len(); + } + + pub fn toggle_sort(&mut self, key: SortKey) { + let selected_index = self.selected; + self.sort = match self.sort { + Some(current) if current.key == key && current.direction == SortDirection::Asc => { + Some(SortState { + key, + direction: SortDirection::Desc, + }) + } + Some(current) if current.key == key && current.direction == SortDirection::Desc => None, + _ => Some(SortState { + key, + direction: SortDirection::Asc, + }), + }; + self.apply_sort(); + self.restore_selection_by_index(selected_index); + self.status = match self.sort { + Some(sort) => format!( + "Sorted by {} ({})", + sort.key.label(), + sort.direction.label() + ), + None => "Sorting cleared".to_string(), + }; + } + + fn apply_sort(&mut self) { + let Some(sort) = self.sort else { + return; + }; + self.results + .sort_by(|left, right| compare_results(left, right, sort)); + } + + fn restore_selection(&mut self, selected_path: Option<&Path>) { + if self.results.is_empty() { + self.selected = 0; + return; + } + if let Some(path) = selected_path + && let Some(index) = self.results.iter().position(|result| result.path == path) + { + self.selected = index; + return; + } + self.selected = self.selected.min(self.results.len() - 1); + } + + fn restore_selection_by_index(&mut self, selected_index: usize) { + if self.results.is_empty() { + self.selected = 0; + return; + } + self.selected = selected_index.min(self.results.len() - 1); + } +} + +pub(super) fn scroll_results(app: &mut TuiApp, delta: isize) { + if app.results.is_empty() { + app.selected = 0; + return; + } + let max_index = app.results.len() - 1; + app.selected = if delta.is_negative() { + app.selected.saturating_sub(delta.unsigned_abs()) + } else { + app.selected.saturating_add(delta as usize).min(max_index) + }; +} + +impl SortKey { + pub fn label(self) -> &'static str { + match self { + SortKey::Filename => "filename", + SortKey::FullPath => "path", + SortKey::Size => "size", + SortKey::Modified => "modified", + SortKey::Created => "created", + } + } +} + +impl SortDirection { + pub fn label(self) -> &'static str { + match self { + SortDirection::Asc => "asc", + SortDirection::Desc => "desc", + } + } +} + +pub(super) fn previous_char_boundary(text: &str, cursor: usize) -> usize { + text[..cursor] + .char_indices() + .last() + .map_or(0, |(idx, _)| idx) +} + +pub(super) fn next_char_boundary(text: &str, cursor: usize) -> usize { + if cursor >= text.len() { + return text.len(); + } + text[cursor..] + .char_indices() + .nth(1) + .map_or(text.len(), |(idx, _)| cursor + idx) +} + +pub(super) fn display_width(text: &str) -> usize { + text.chars().count() +}