diff --git a/Cargo.lock b/Cargo.lock index 39dcdcf445..9722c4c826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common 0.13.0", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -943,6 +943,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -1055,6 +1064,19 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block" version = "0.1.6" @@ -1735,6 +1757,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -2170,7 +2198,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -2460,13 +2488,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -2477,7 +2526,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2549,6 +2598,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "doxygen-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" +dependencies = [ + "phf 0.11.3", +] + [[package]] name = "dpi" version = "0.1.2" @@ -2916,6 +2974,74 @@ dependencies = [ "subtle", ] +[[package]] +name = "fff-grep" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a741baa9142c1cbe7df7948d93a809296cd3a28f5c117964a0048b4711258" +dependencies = [ + "bstr", + "memchr", +] + +[[package]] +name = "fff-notify-debouncer-full" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a4ebea7b8a2840cd59358bbf396f6f04313ce8eae84ac79703ce80298b8731" +dependencies = [ + "file-id", + "log", + "notify 9.0.0-rc.5", + "notify-types 2.1.0", + "rustc-hash 2.1.3", + "walkdir", +] + +[[package]] +name = "fff-query-parser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1aa71c8268c77b2b7d9982eeaa6aa42abb22eaaaa3b72b1b636946c72a8982" + +[[package]] +name = "fff-search" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a80ed5bdfa89cc81cb314374a4460da880d68e4c81d9574a587f28757a9c943e" +dependencies = [ + "ahash", + "aho-corasick", + "blake3", + "dirs 5.0.1", + "dunce", + "fff-grep", + "fff-notify-debouncer-full", + "fff-query-parser", + "git2", + "glidesort", + "globset", + "heed", + "ignore", + "libc", + "memchr", + "memmap2", + "neo_frizbee", + "notify 9.0.0-rc.5", + "parking_lot", + "pathdiff", + "rayon", + "regex", + "regex-syntax", + "serde", + "signal-hook-registry", + "smallvec", + "thiserror 2.0.20", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2932,6 +3058,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -3490,6 +3625,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "git2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libgit2-sys", + "log", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -3548,6 +3695,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "glob" version = "0.3.4" @@ -4213,6 +4366,44 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heed" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] + +[[package]] +name = "heed-traits" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" + +[[package]] +name = "heed-types" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" +dependencies = [ + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -4691,6 +4882,17 @@ dependencies = [ "libc", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + [[package]] name = "inotify-sys" version = "0.1.8" @@ -5090,6 +5292,18 @@ dependencies = [ "cc", ] +[[package]] +name = "libgit2-sys" +version = "0.18.8+1.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -5135,6 +5349,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -5177,6 +5403,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lmdb-master-sys" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" +dependencies = [ + "cc", + "doxygen-rs", + "libc", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -5826,6 +6063,15 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "neo_frizbee" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb0dd0e69b342eaddb657120a21a56f3e887ddc505fac780cf430dbbbb3aed26" +dependencies = [ + "serde", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -5908,16 +6154,36 @@ dependencies = [ "bitflags 2.13.1", "filetime", "fsevent-sys", - "inotify", + "inotify 0.10.2", "kqueue", "libc", "log", "mio", - "notify-types", + "notify-types 1.0.1", "walkdir", "windows-sys 0.52.0", ] +[[package]] +name = "notify" +version = "9.0.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7768eb7201a1964d8eb10224e850df046ab584ee3c3d26e340a993d81f748148" +dependencies = [ + "bitflags 2.13.1", + "inotify 0.11.5", + "kqueue", + "libc", + "log", + "mio", + "notify-types 2.1.0", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", + "xxhash-rust", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -5941,6 +6207,15 @@ dependencies = [ "instant", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -5959,16 +6234,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nucleo-matcher" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" -dependencies = [ - "memchr", - "unicode-segmentation", -] - [[package]] name = "num" version = "0.4.3" @@ -6334,6 +6599,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -6783,6 +7058,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -6871,6 +7156,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -6986,14 +7277,24 @@ dependencies = [ "pest", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] @@ -7003,8 +7304,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", ] [[package]] @@ -7014,7 +7325,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -7023,13 +7347,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -8017,6 +8350,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -8919,7 +9263,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash 2.1.3", @@ -9558,7 +9902,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -9568,8 +9912,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -9732,6 +10076,12 @@ dependencies = [ "zeno", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -9774,6 +10124,15 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synchronoise" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" +dependencies = [ + "crossbeam-queue", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -10381,6 +10740,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.20", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -11509,7 +11881,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", @@ -12164,6 +12536,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -12182,6 +12563,15 @@ 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.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -12206,6 +12596,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -12215,13 +12620,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -12255,90 +12677,180 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -12428,7 +12940,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -12812,7 +13324,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "core-text", - "dirs", + "dirs 6.0.0", "dwrote", "float-ord", "freetype-sys", @@ -13020,13 +13532,14 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "dunce", + "fff-search", "futures", "gethostname", "ignore", "libc", "loro", - "notify", - "nucleo-matcher", + "notify 7.0.0", "portable-pty", "reqwest", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 71816ff6f5..ed7e709b8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,7 +153,9 @@ ttf-parser = "0.25.1" # Windows system proxy for provider usage probes and other HTTP clients. reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "system-proxy"] } ignore = "0.4" -nucleo-matcher = "0.3" +# Workspace file/content search index (@ mentions, file tree, cmd+K). +# Pinned exactly: crates.io also carries `0.11.1-nightly.*` prereleases. +fff-search = "=0.11.0" [profile.release] # Distribution profile (scripts/package-linux.sh): thin LTO buys most of fat diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7b883cd14b..c8d60bacf6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,11 +35,12 @@ tokio-util = { workspace = true, features = ["rt"] } sha2.workspace = true reqwest.workspace = true ignore.workspace = true -nucleo-matcher.workspace = true +fff-search.workspace = true tempfile.workspace = true [target.'cfg(windows)'.dependencies] +dunce = "1" windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Pipes", "Win32_System_Threading"] } [dev-dependencies] diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs new file mode 100644 index 0000000000..b33aaa4043 --- /dev/null +++ b/crates/engine/examples/workspace_search_bench.rs @@ -0,0 +1,159 @@ +//! Workspace search benchmark: the `SearchFiles` (`@`), +//! `SearchWorkspaceFiles` (file tree) and `SearchWorkspaceContent` (cmd+K) +//! paths against one folder. +//! +//! ```text +//! BENCH_ROOT=/path/to/checkout cargo run --release -p zeron-engine \ +//! --example workspace_search_bench +//! ``` +//! +//! `BENCH_QUERIES` (comma separated) overrides the default queries and +//! `BENCH_RUNS` the number of timed runs per query (default 10, after one +//! warm-up). RSS comes from `/proc/self/status`, so it prints only on Linux. +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use zeron_engine::Repos; + +const DEFAULT_QUERIES: &[&str] = &[ + "composer", + "repos", + "cmdpal", + "Cargo.toml", + "workspace_files", +]; + +fn main() -> Result<(), Box> { + let root = std::env::var_os("BENCH_ROOT") + .map(PathBuf::from) + .ok_or("set BENCH_ROOT to the folder to search")?; + let root = std::fs::canonicalize(root)?; + let queries: Vec = match std::env::var("BENCH_QUERIES") { + Ok(list) => list + .split(',') + .map(str::trim) + .filter(|q| !q.is_empty()) + .map(str::to_owned) + .collect(), + Err(_) => DEFAULT_QUERIES.iter().map(|q| (*q).to_owned()).collect(), + }; + let runs: usize = std::env::var("BENCH_RUNS") + .ok() + .and_then(|runs| runs.parse().ok()) + .unwrap_or(10) + .max(1); + let runtime = tokio::runtime::Runtime::new()?; + let data = tempfile::tempdir()?; + let repos = Repos::with_worktrees_root(data.path(), "bench", data.path().join("worktrees")); + + println!("root: {}", root.display()); + print_rss("baseline"); + + let search = repos.workspace_search(); + let started = Instant::now(); + search.warm(&root)?; + if !search.wait_until_indexed(&root, Duration::from_secs(600)) { + return Err("index did not finish within 10 minutes".into()); + } + println!( + "index ready in {:?}", + started.elapsed() + ); + print_rss("after index"); + + println!(); + println!( + "{:<38} {:>10} {:>10} {:>10} {:>8}", + "query", "min", "median", "max", "results" + ); + for query in &queries { + let (timings, results) = measure(runs, || { + runtime + .block_on(repos.search_files(root.clone(), query.clone(), Vec::new())) + .map(|matches| matches.len()) + })?; + report(&format!("SearchFiles {query}"), &timings, results); + } + for query in &queries { + let (timings, results) = measure(runs, || tree_search(&repos, &root, query, false))?; + report(&format!("SearchWorkspaceFiles {query}"), &timings, results); + } + for query in &queries { + let (timings, results) = measure(runs, || tree_search(&repos, &root, query, true))?; + report(&format!("…including ignored {query}"), &timings, results); + } + for query in &queries { + let (timings, results) = measure(runs, || { + search + .search_content(&root, query, 100, 3) + .map(|found| found.matches.len()) + })?; + report( + &format!("SearchWorkspaceContent {query}"), + &timings, + results, + ); + } + print_rss("after all queries"); + search.release_all(); + // Release runs off-thread; give it and the allocator a moment. + std::thread::sleep(Duration::from_secs(2)); + println!("live indexes after release: {}", search.live()); + print_rss("after evicting the index"); + Ok(()) +} + +fn tree_search( + repos: &Repos, + root: &Path, + query: &str, + include_ignored: bool, +) -> Result> { + Ok(zeron_engine::workspace_files::bench_search_root( + repos.workspace_search(), + root, + query, + include_ignored, + zeron_engine::workspace_files::MAX_SEARCH_RESULTS, + )? + .len()) +} + +/// One warm-up, then `runs` timed calls. +fn measure>>( + runs: usize, + mut search: impl FnMut() -> Result, +) -> Result<(Vec, usize), Box> { + search().map_err(Into::into)?; + let mut timings = Vec::with_capacity(runs); + let mut results = 0; + for _ in 0..runs { + let started = Instant::now(); + results = search().map_err(Into::into)?; + timings.push(started.elapsed()); + } + timings.sort(); + Ok((timings, results)) +} + +fn report(label: &str, timings: &[Duration], results: usize) { + println!( + "{label:<38} {:>10} {:>10} {:>10} {results:>8}", + format_duration(timings[0]), + format_duration(timings[timings.len() / 2]), + format_duration(timings[timings.len() - 1]), + ); +} + +fn format_duration(duration: Duration) -> String { + format!("{:.2}ms", duration.as_secs_f64() * 1000.0) +} + +fn print_rss(label: &str) { + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return; + }; + if let Some(line) = status.lines().find(|line| line.starts_with("VmRSS:")) { + println!("RSS {label}: {}", line.trim_start_matches("VmRSS:").trim()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 330dccb75c..44c4463a1e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -45,6 +45,7 @@ pub mod uploads; pub mod voice; pub mod workspace_files; pub mod workspace_host; +pub mod workspace_search; pub use agent_accounts::{AgentAccounts, AgentAccountsConfig}; pub use auth::{Auth, AuthConfig, AuthState, AuthUser, OrgMembership}; @@ -76,6 +77,7 @@ pub use workspace_files::WorkspaceFiles; pub use workspace_host::{ DEFAULT_ORG_ID, DEFAULT_USER_ID, WORKSPACE_DOC_ID, WorkspaceHost, WorkspaceHostConfig, }; +pub use workspace_search::WorkspaceSearch; pub(crate) const LEGACY_UNKNOWN_DEVICE_NAME: &str = "unknown-device"; diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index b4df7c7a79..be5691f81c 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -12,7 +12,6 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use futures::{StreamExt, stream}; @@ -20,11 +19,13 @@ use sha2::{Digest, Sha256}; use zeron_proto::{ DriveEntry, FileSearchMatch, FolderEntry, FolderListing, GitHistoryCommit, - GitHistoryComparison, GitHistoryPage, GitHistoryRef, GitHistoryRefKind, Repo, RepoRef, - Worktree, + GitHistoryComparison, GitHistoryPage, GitHistoryRef, GitHistoryRefKind, HomeFolderMatch, + HomeFolderSearchResult, Repo, RepoRef, Worktree, }; use crate::EngineError; +use crate::WorkspaceSearch; +use crate::workspace_search::{NameKinds, normalize_name_query}; /// Existence probe timeout for user-chosen / remembered paths, which can point at /// dead network mounts where a bare `stat` hangs for minutes. @@ -41,10 +42,14 @@ const DRIVE_LIST_MAX_ENTRIES: usize = 50; const FILE_SEARCH_MAX_RESULTS: usize = 8; /// A dead network mount must not leave the composer search spinning forever. const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(6); +/// Folders `SearchHomeFolders` returns by default, and at most. +pub const HOME_FOLDER_RESULTS: usize = 20; +pub const HOME_FOLDER_MAX_RESULTS: usize = 50; +/// Candidates taken from the index before re-ranking by name; more for a +/// `/` query, which searches only its last segment. +const HOME_FOLDER_CANDIDATES: usize = 300; +const HOME_FOLDER_PATH_CANDIDATES: usize = 2_000; const GITHUB_AVATAR_TIMEOUT: Duration = Duration::from_secs(6); -const FILE_INDEX_TTL: Duration = Duration::from_secs(10); -const FILE_INDEX_MAX_ENTRIES: usize = 250_000; -const RANK_BUFFER: usize = 1_024; pub const GIT_HISTORY_DEFAULT_LIMIT: usize = 100; pub const GIT_HISTORY_MAX_LIMIT: usize = 200; @@ -133,30 +138,17 @@ struct ReposInner { data_dir: PathBuf, device_id: String, worktrees_root: PathBuf, - file_searches: std::sync::Mutex>>>, http: reqwest::Client, github_avatars: std::sync::Mutex>, github_avatar_pages: std::sync::Mutex>, - file_index: FileIndexCache, + /// The engine's one workspace search index set (`@`, file tree, cmd+K). + workspace_search: WorkspaceSearch, /// HEAD commit → its trunk's root commit. History behind a commit never /// changes, so the full first-parent walk runs once per HEAD, not on /// every spaces repair pass. trunk_roots: std::sync::Mutex>, } -struct IndexedPath { - path: String, - haystack: nucleo_matcher::Utf32String, - is_dir: bool, -} - -struct FileIndex { - entries: Vec, - built: std::time::Instant, -} - -type FileIndexCache = std::sync::Mutex>>; - #[derive(Clone)] pub struct Repos { inner: std::sync::Arc, @@ -167,6 +159,10 @@ impl Repos { &self.inner.data_dir } + pub fn workspace_search(&self) -> &WorkspaceSearch { + &self.inner.workspace_search + } + /// `data_dir` holds `repos.json` + cloned/created repos; the worktree root /// comes from `$ZERON_WORKTREES_DIR` or `~/.zeron/worktrees`. pub fn new(data_dir: &Path, device_id: &str) -> Self { @@ -180,7 +176,6 @@ impl Repos { data_dir: data_dir.to_path_buf(), device_id: device_id.to_string(), worktrees_root, - file_searches: std::sync::Mutex::new(HashMap::new()), http: reqwest::Client::builder() .timeout(GITHUB_AVATAR_TIMEOUT) .user_agent("Comet-Git-History") @@ -189,7 +184,7 @@ impl Repos { github_avatars: std::sync::Mutex::new(HashMap::new()), trunk_roots: std::sync::Mutex::new(HashMap::new()), github_avatar_pages: std::sync::Mutex::new(HashSet::new()), - file_index: std::sync::Mutex::new(HashMap::new()), + workspace_search: WorkspaceSearch::new(), }), } } @@ -1421,54 +1416,53 @@ impl Repos { .await } - /// Search a checkout's files and directories by fuzzy relative path. The - /// `ignore` walker honors `.gitignore`, `.ignore`, and global git excludes. - /// Dotfiles remain searchable; only repository metadata is always pruned. + // ── SearchHomeFolders ─────────────────────────────────────────────────── + + /// Folders anywhere under home whose names match `query`, from the home + /// workspace search index — the one projectless chats use, so it skips + /// dot folders, dependency and cache folders, and each repository's + /// ignored paths. An empty query only starts the index. + pub async fn search_home_folders( + &self, + query: String, + limit: usize, + ) -> Result { + let search = self.inner.workspace_search.clone(); + let worker = disposable_worker("home-folder-search", move || { + search_home_folders_blocking(&search, &query, limit) + }); + match tokio::time::timeout(FILE_SEARCH_TIMEOUT, worker).await { + Ok(Some(result)) => result, + Ok(None) => Err(EngineError::Other("folder search worker exited".into())), + Err(_) => Err(EngineError::Other("folder search timed out".into())), + } + } + + /// The new-project picker is done with the home index: drop it now + /// unless a projectless chat warmed it. + pub fn release_home_folder_search(&self) { + if let Ok(home) = home_search_root() { + self.inner.workspace_search.release_unless_warmed(&home); + } + } + + /// Search a checkout's files and directories by fuzzy relative path, + /// from the workspace search index (fff), which honors `.gitignore`, + /// `.ignore`, and global git excludes. An empty query lists the chat's + /// featured paths first, then recently changed files. pub async fn search_files( &self, root: PathBuf, query: String, featured_paths: Vec, ) -> Result, EngineError> { - let deadline = tokio::time::Instant::now() + FILE_SEARCH_TIMEOUT; - let gate = { - let mut searches = self - .inner - .file_searches - .lock() - .map_err(|_| EngineError::Other("file search registry poisoned".into()))?; - if let Some(gate) = searches.get(&root).and_then(std::sync::Weak::upgrade) { - gate - } else { - let gate = std::sync::Arc::new(tokio::sync::Mutex::new(())); - searches.insert(root.clone(), std::sync::Arc::downgrade(&gate)); - gate - } - }; - let gate = tokio::time::timeout_at(deadline, gate.lock_owned()) - .await - .map_err(|_| EngineError::Other("file search timed out".into()))?; - let cancelled = std::sync::Arc::new(AtomicBool::new(false)); - let _cancel_on_drop = CancelOnDrop(cancelled.clone()); - let worker_cancelled = cancelled.clone(); - let cache = self.inner.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - std::thread::Builder::new() - .name("file-search".into()) - .spawn(move || { - let _gate = gate; - let _ = tx.send(search_files_cached( - &cache.file_index, - &root, - &query, - &featured_paths, - || worker_cancelled.load(Ordering::Relaxed), - )); - }) - .map_err(|e| EngineError::Other(format!("file search failed: {e}")))?; - match tokio::time::timeout_at(deadline, rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => Err(EngineError::Other("file search worker exited".into())), + let search = self.inner.workspace_search.clone(); + let worker = disposable_worker("file-search", move || { + search_files_blocking(&search, &root, &query, &featured_paths) + }); + match tokio::time::timeout(FILE_SEARCH_TIMEOUT, worker).await { + Ok(Some(result)) => result, + Ok(None) => Err(EngineError::Other("file search worker exited".into())), Err(_) => Err(EngineError::Other("file search timed out".into())), } } @@ -1532,14 +1526,6 @@ impl Repos { } } -struct CancelOnDrop(std::sync::Arc); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - self.0.store(true, Ordering::Relaxed); - } -} - async fn disposable_worker( name: &'static str, work: impl FnOnce() -> T + Send + 'static, @@ -1586,7 +1572,7 @@ fn list_folders_blocking(target: &Path) -> Result { let truncated = entries.len() > FOLDER_LIST_MAX_ENTRIES; entries.truncate(FOLDER_LIST_MAX_ENTRIES); Ok(FolderListing { - path: target.to_string_lossy().to_string(), + path: folder_browser_path(target), entries, truncated, }) @@ -1920,246 +1906,214 @@ fn compact_history_commits( .collect() } -type RankedFileMatch = (Option, u32, String, bool); - -fn compare_file_matches( - query: &str, - (featured_a, score_a, path_a, dir_a): &RankedFileMatch, - (featured_b, score_b, path_b, dir_b): &RankedFileMatch, -) -> std::cmp::Ordering { - let empty_query = query.trim().is_empty(); - featured_a - .is_none() - .cmp(&featured_b.is_none()) - .then_with(|| featured_a.cmp(featured_b)) - .then_with(|| score_b.cmp(score_a)) - .then_with(|| { - empty_query - .then(|| path_a.split('/').count().cmp(&path_b.split('/').count())) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .then_with(|| { - empty_query - .then(|| dir_a.cmp(dir_b)) - .unwrap_or_else(|| dir_b.cmp(dir_a)) - }) - .then_with(|| path_a.len().cmp(&path_b.len())) - .then_with(|| path_a.cmp(path_b)) -} - -#[cfg(test)] fn search_files_blocking( + search: &WorkspaceSearch, root: &Path, query: &str, featured_paths: &[String], ) -> Result, EngineError> { - search_files_blocking_with_cancel(root, query, featured_paths, || false) + let root = canonical_search_root(root)?; + let mut matches: Vec = Vec::with_capacity(FILE_SEARCH_MAX_RESULTS); + if query.trim().is_empty() { + for path in featured_paths { + if matches.len() == FILE_SEARCH_MAX_RESULTS { + break; + } + let path = Path::new(path); + let full = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + let Ok(canonical) = std::fs::canonicalize(full) else { + continue; + }; + let Ok(relative) = canonical.strip_prefix(&root) else { + continue; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + if relative.is_empty() + || is_git_metadata(&relative) + || matches.iter().any(|found| found.path == relative) + { + continue; + } + matches.push(FileSearchMatch { + is_dir: canonical.is_dir(), + path: relative, + }); + } + } + let found = search + .search_names_within( + &root, + query, + FILE_SEARCH_MAX_RESULTS + matches.len(), + NameKinds::FilesAndDirectories, + crate::workspace_search::NAME_SCAN_WAIT, + ) + .map_err(|e| EngineError::Other(e.to_string()))?; + for found in found.matches { + if matches.len() == FILE_SEARCH_MAX_RESULTS { + break; + } + if is_git_metadata(&found.path) + || matches.iter().any(|known| known.path == found.path) + || crate::workspace_search::escapes_through_symlink(&root, &found.path, true) + { + continue; + } + matches.push(FileSearchMatch { + path: found.path, + is_dir: found.is_dir, + }); + } + Ok(matches) } -#[cfg(test)] -fn search_files_blocking_with_cancel bool + Sync>( - root: &Path, +/// The home folder as the workspace search index keys it. +fn home_search_root() -> Result { + let home = session_home_dir().map_err(|error| EngineError::Other(error.to_string()))?; + canonical_search_root(&home) +} + +fn search_home_folders_blocking( + search: &WorkspaceSearch, query: &str, - featured_paths: &[String], - cancelled: F, -) -> Result, EngineError> { - let root = canonical_search_root(root)?; - let index = walk_file_index(&root, &cancelled)?; - Ok(rank_file_matches(&index, &root, query, featured_paths)) + limit: usize, +) -> Result { + let mut found = search_folders_under(search, &home_search_root()?, query, limit)?; + // The index keys Home by its canonical path; clients compare results + // with Home and existing projects as the folder browser spells them. A + // symlinked Home (`/home/me` → `/data/home/me`) must not read as a + // second copy of every folder. + if let Ok(home) = session_home_dir() { + for found in &mut found.matches { + found.path = folder_browser_path(&home.join(&found.relative)); + } + } + Ok(found) } -fn search_files_cached bool + Sync>( - cache: &FileIndexCache, - root: &Path, +/// `SearchHomeFolders` against an already-resolved (canonical) root. +fn search_folders_under( + search: &WorkspaceSearch, + home: &Path, query: &str, - featured_paths: &[String], - cancelled: F, -) -> Result, EngineError> { - let root = canonical_search_root(root)?; - let fresh = cache - .lock() - .ok() - .and_then(|indexes| indexes.get(&root).cloned()) - .filter(|index| index.built.elapsed() < FILE_INDEX_TTL); - let index = match fresh { - Some(index) => index, - None => { - let index = std::sync::Arc::new(FileIndex { - entries: walk_file_index(&root, &cancelled)?, - built: std::time::Instant::now(), - }); - if let Ok(mut indexes) = cache.lock() { - indexes.retain(|_, index| index.built.elapsed() < FILE_INDEX_TTL); - indexes.insert(root.clone(), index.clone()); - } - index - } + limit: usize, +) -> Result { + let query = normalize_name_query(query); + let query = query.as_ref(); + // fff scores a `/` query against whole paths, where a folder loses to + // its many descendants; ask for the last segment's folders instead and + // let `rank_home_folders` check the leading segments. + let (index_query, candidates) = match query.trim().trim_end_matches('/').rsplit_once('/') { + Some((_, last)) if !last.is_empty() => (last, HOME_FOLDER_PATH_CANDIDATES), + _ => (query, HOME_FOLDER_CANDIDATES), }; - if cancelled() { - return Err(EngineError::Other("file search cancelled".into())); - } - Ok(rank_file_matches( - &index.entries, - &root, - query, - featured_paths, - )) + let found = search + .search_names(home, index_query, candidates, NameKinds::Directories) + .map_err(|e| EngineError::Other(e.to_string()))?; + // A `.git` probe per candidate (a few hundred stats) lets repositories + // rank first. + let candidates = found + .matches + .into_iter() + .filter(|found| !is_git_metadata(&found.path)) + .map(|found| { + let is_repo = home.join(&found.path).join(".git").exists(); + (found.path, is_repo) + }) + .collect(); + let matches = rank_home_folders(query, candidates) + .into_iter() + .take(limit) + .map(|(relative, is_repo)| HomeFolderMatch { + path: folder_browser_path(&home.join(&relative)), + relative, + is_repo, + }) + .collect(); + Ok(HomeFolderSearchResult { + matches, + indexing: found.indexing, + }) } -fn canonical_search_root(root: &Path) -> Result { - std::fs::canonicalize(root) - .map_err(|e| EngineError::Other(format!("could not search workspace: {e}"))) +/// Keep canonical index keys internal. Ordinary Windows paths are easier for +/// browser clients to compare with Home and existing spaces; retain extended +/// paths when their length or names require them, and for UNC shares. +fn folder_browser_path(path: &Path) -> String { + #[cfg(windows)] + let path = dunce::simplified(path); + path.to_string_lossy().into_owned() } -fn walk_file_index bool + Sync>( - root: &Path, - cancelled: &F, -) -> Result, EngineError> { - if cancelled() { - return Err(EngineError::Other("file search cancelled".into())); - } - let threads = std::thread::available_parallelism() - .map(|n| n.get().min(12)) - .unwrap_or(4); - let collected: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - let was_cancelled = AtomicBool::new(false); - ignore::WalkBuilder::new(root) - .hidden(false) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .threads(threads) - .filter_entry(|entry| entry.depth() == 0 || entry.file_name() != ".git") - .build_parallel() - .run(|| { - const BATCH: usize = 512; - struct Batch<'a> { - items: Vec, - sink: &'a std::sync::Mutex>, +/// Re-rank fuzzy folder candidates `(relative path, is a repository)`, given +/// in index order, by how well the folder itself matches: an exact name, +/// then a name starting with the query, then one containing it. A query +/// with `/` matches the path's last segments instead, each query segment a +/// prefix of its path segment (`dev/com` → `…/development/comet`). Within a +/// tier repositories go first, then shallower folders. Candidates matching +/// only through a typo or a parent's name are kept only when nothing better +/// matched, ordered the same way. +fn rank_home_folders(query: &str, candidates: Vec<(String, bool)>) -> Vec<(String, bool)> { + let query = query.trim().to_lowercase(); + let segments: Vec<&str> = query.split('/').filter(|s| !s.is_empty()).collect(); + let tier = |relative: &str| -> u8 { + let relative = relative.to_lowercase(); + let path: Vec<&str> = relative.split('/').collect(); + if query.contains('/') { + if segments.is_empty() || segments.len() > path.len() { + return 0; } - impl Drop for Batch<'_> { - fn drop(&mut self) { - if self.items.is_empty() { - return; - } - if let Ok(mut all) = self.sink.lock() { - all.append(&mut self.items); - } - } + let tail = &path[path.len() - segments.len()..]; + if !tail + .iter() + .zip(&segments) + .all(|(seg, q)| seg.starts_with(q)) + { + return 0; } - let mut batch = Batch { - items: Vec::with_capacity(BATCH), - sink: &collected, - }; - let was_cancelled = &was_cancelled; - Box::new(move |entry| { - if was_cancelled.load(Ordering::Relaxed) { - return ignore::WalkState::Quit; - } - if cancelled() { - was_cancelled.store(true, Ordering::Relaxed); - return ignore::WalkState::Quit; - } - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - tracing::debug!(%err, "file mention index skipped entry"); - return ignore::WalkState::Continue; - } - }; - let path = entry.path(); - if path == root { - return ignore::WalkState::Continue; - } - let Ok(relative) = path.strip_prefix(root) else { - return ignore::WalkState::Continue; - }; - let relative = relative.to_string_lossy().replace('\\', "/"); - if relative == ".git" || relative.starts_with(".git/") { - return ignore::WalkState::Continue; - } - batch.items.push(IndexedPath { - haystack: nucleo_matcher::Utf32String::from(relative.as_str()), - path: relative, - is_dir: entry.file_type().is_some_and(|kind| kind.is_dir()), - }); - if batch.items.len() >= BATCH - && let Ok(mut all) = batch.sink.lock() - { - all.append(&mut batch.items); - if all.len() >= FILE_INDEX_MAX_ENTRIES { - return ignore::WalkState::Quit; - } - } - ignore::WalkState::Continue - }) - }); - if was_cancelled.load(Ordering::Relaxed) || cancelled() { - return Err(EngineError::Other("file search cancelled".into())); - } - let mut entries = collected - .into_inner() - .map_err(|_| EngineError::Other("file index poisoned".into()))?; - entries.truncate(FILE_INDEX_MAX_ENTRIES); - Ok(entries) -} - -fn rank_file_matches( - entries: &[IndexedPath], - root: &Path, - query: &str, - featured_paths: &[String], -) -> Vec { - let featured: HashMap = featured_paths - .iter() - .filter_map(|path| { - let path = Path::new(path); - let full = if path.is_absolute() { - path.to_path_buf() - } else { - root.join(path) - }; - let canonical = std::fs::canonicalize(full).ok()?; - let relative = canonical.strip_prefix(root).ok()?; - Some(relative.to_string_lossy().replace('\\', "/")) - }) - .enumerate() - .fold(HashMap::new(), |mut paths, (rank, path)| { - paths.entry(path).or_insert(rank); - paths - }); - let mut matcher = nucleo_matcher::Matcher::new({ - let mut config = nucleo_matcher::Config::DEFAULT; - config.set_match_paths(); - config - }); - let pattern = nucleo_matcher::pattern::Pattern::parse( - query, - nucleo_matcher::pattern::CaseMatching::Smart, - nucleo_matcher::pattern::Normalization::Smart, - ); - let mut matches: Vec = Vec::new(); - for entry in entries { - let Some(score) = pattern.score(entry.haystack.slice(..), &mut matcher) else { - continue; - }; - matches.push(( - featured.get(&entry.path).copied(), - score, - entry.path.clone(), - entry.is_dir, - )); - if matches.len() >= RANK_BUFFER { - matches.sort_by(|a, b| compare_file_matches(query, a, b)); - matches.truncate(FILE_SEARCH_MAX_RESULTS); + return if tail.last() == segments.last() { 3 } else { 2 }; } - } - matches.sort_by(|a, b| compare_file_matches(query, a, b)); - matches.truncate(FILE_SEARCH_MAX_RESULTS); - matches + let name = path.last().copied().unwrap_or_default(); + if name == query { + 3 + } else if name.starts_with(&query) { + 2 + } else { + u8::from(name.contains(&query)) + } + }; + let mut ranked: Vec<(u8, usize, usize, (String, bool))> = candidates .into_iter() - .map(|(_, _, path, is_dir)| FileSearchMatch { path, is_dir }) - .collect() + .enumerate() + .map(|(order, (relative, is_repo))| { + let depth = relative.matches('/').count(); + (tier(&relative), depth, order, (relative, is_repo)) + }) + .collect(); + if ranked.iter().any(|(tier, ..)| *tier > 0) { + ranked.retain(|(tier, ..)| *tier > 0); + } + ranked.sort_by(|a, b| { + b.0.cmp(&a.0) + .then(b.3.1.cmp(&a.3.1)) + .then(a.1.cmp(&b.1)) + .then(a.2.cmp(&b.2)) + }); + ranked.into_iter().map(|(.., found)| found).collect() +} + +/// Repository metadata is never offered, even if an index surfaced it. +fn is_git_metadata(relative: &str) -> bool { + relative.split('/').any(|component| component == ".git") +} + +fn canonical_search_root(root: &Path) -> Result { + std::fs::canonicalize(root) + .map_err(|e| EngineError::Other(format!("could not search workspace: {e}"))) } /// Turn a generated chat title into the semantic portion of a Zeron branch @@ -2371,23 +2325,6 @@ mod tests { } } - fn score(query: &str, candidate: &str) -> Option { - let mut matcher = nucleo_matcher::Matcher::new({ - let mut config = nucleo_matcher::Config::DEFAULT; - config.set_match_paths(); - config - }); - nucleo_matcher::pattern::Pattern::parse( - query, - nucleo_matcher::pattern::CaseMatching::Smart, - nucleo_matcher::pattern::Normalization::Smart, - ) - .score( - nucleo_matcher::Utf32String::from(candidate).slice(..), - &mut matcher, - ) - } - #[test] fn expand_home_rewrites_tilde_and_leaves_other_paths() { let home = session_home_dir().unwrap(); @@ -2536,13 +2473,6 @@ tmpfs /run tmpfs rw 0 0 assert_eq!(paths.len(), drives.len()); } - #[test] - fn fuzzy_score_matches_a_path_subsequence() { - assert!(score("cmp rs", "crates/ui/src/composer.rs").is_some()); - assert!(score("composer crates", "crates/ui/src/composer.rs").is_some()); - assert!(score("xyzq", "crates/ui/src/composer.rs").is_none()); - } - #[test] fn git_history_matches_unicode_case_insensitively() { let mut candidate = history_commit("a1b2c3d4".into(), None); @@ -2576,10 +2506,213 @@ tmpfs /run tmpfs rw 0 0 assert!(compact[1].parent_shas.is_empty()); } + #[test] + fn home_folder_search_finds_nested_repositories() { + let home = tempfile::tempdir().unwrap(); + for dir in [ + "work/clients/comet/.git", + "work/clients/comet/src", + "notes/comet-ideas", + "notes/other", + ] { + std::fs::create_dir_all(home.path().join(dir)).unwrap(); + } + std::fs::write(home.path().join("notes/comet-ideas/todo.md"), b"").unwrap(); + let home = std::fs::canonicalize(home.path()).unwrap(); + let search = WorkspaceSearch::new(); + // The first call starts the index; wait out its scan. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let result = loop { + let result = search_folders_under(&search, &home, "comet", 20).unwrap(); + if !result.indexing && !result.matches.is_empty() { + break result; + } + assert!(std::time::Instant::now() < deadline, "index never finished"); + std::thread::sleep(Duration::from_millis(20)); + }; + let found: Vec<_> = result + .matches + .iter() + .map(|m| (m.relative.as_str(), m.is_repo)) + .collect(); + // The exact name outranks the shallower prefix match. + assert_eq!( + found, + [("work/clients/comet", true), ("notes/comet-ideas", false)] + ); + assert_eq!( + result.matches[0].path, + folder_browser_path(&home.join("work/clients/comet")) + ); + assert!( + search_folders_under(&search, &home, "", 20) + .unwrap() + .matches + .is_empty() + ); + } + + #[test] + #[cfg(windows)] + fn home_folder_search_accepts_both_windows_separators() { + let home = tempfile::tempdir().unwrap(); + for dir in ["aa/bb/cc/dd/src", "aa/bb/cc/dd-other", "elsewhere/dd"] { + std::fs::create_dir_all(home.path().join(dir)).unwrap(); + } + let home = std::fs::canonicalize(home.path()).unwrap(); + let search = WorkspaceSearch::new(); + search.warm(&home).unwrap(); + assert!(search.wait_until_indexed(&home, Duration::from_secs(20))); + + let forward = search_folders_under(&search, &home, "aa/bb/cc/dd", 20).unwrap(); + assert_eq!(forward.matches[0].relative, "aa/bb/cc/dd"); + assert!( + forward + .matches + .iter() + .any(|m| m.relative == "aa/bb/cc/dd-other") + ); + for query in [r"aa\bb\cc\dd", r"aa\bb/cc\dd", r"aa\bb\cc\dd\"] { + let found = search_folders_under(&search, &home, query, 20).unwrap(); + assert_eq!(found.matches, forward.matches); + } + } + + #[test] + #[cfg(not(windows))] + fn home_folder_search_preserves_literal_posix_backslashes() { + let home = tempfile::tempdir().unwrap(); + let literal = r"aa\bb\cc\dd"; + for dir in [literal, "aa/bb/cc/dd"] { + std::fs::create_dir_all(home.path().join(dir)).unwrap(); + } + let home = std::fs::canonicalize(home.path()).unwrap(); + let search = WorkspaceSearch::new(); + search.warm(&home).unwrap(); + assert!(search.wait_until_indexed(&home, Duration::from_secs(20))); + + let found = search_folders_under(&search, &home, literal, 20).unwrap(); + assert_eq!(found.matches.len(), 1); + assert_eq!(found.matches[0].relative, literal); + let forward = search_folders_under(&search, &home, "aa/bb/cc/dd", 20).unwrap(); + assert_eq!(forward.matches.len(), 1); + assert_eq!(forward.matches[0].relative, "aa/bb/cc/dd"); + } + + #[test] + #[cfg(not(windows))] + fn browser_paths_preserve_native_posix_spelling() { + for path in [ + "/home/ana/code", + "/Users/Ana/code", + r"/tmp/name\with\backslashes", + ] { + assert_eq!(folder_browser_path(Path::new(path)), path); + } + } + + #[test] + #[cfg(windows)] + fn browser_paths_simplify_only_safe_windows_drive_paths() { + assert_eq!( + folder_browser_path(Path::new(r"\\?\C:\Users\Ana\repo")), + r"C:\Users\Ana\repo" + ); + for path in [ + r"\\?\C:\repo.", + r"\\?\C:\NUL.txt", + r"\\?\UNC\server\share\repo", + ] { + assert_eq!(folder_browser_path(Path::new(path)), path); + } + let long = format!(r"\\?\C:\{}\{}", "a".repeat(150), "b".repeat(150)); + assert_eq!(folder_browser_path(Path::new(&long)), long); + } + + fn ranked(query: &str, candidates: &[(&str, bool)]) -> Vec { + let candidates = candidates + .iter() + .map(|(path, is_repo)| ((*path).to_owned(), *is_repo)) + .collect(); + rank_home_folders(query, candidates) + .into_iter() + .map(|(path, _)| path) + .collect() + } + + #[test] + fn home_folders_rank_by_their_own_name() { + let candidates = [ + ("Drive/notes about comet launch", false), + ("dev/comet/apps/ios", false), + ("old/comet-archive", false), + ("dev/comet", false), + ("dev/mycomet", false), + ]; + assert_eq!( + ranked("Comet", &candidates), + [ + "dev/comet", + "old/comet-archive", + // Same tier, repo flag and depth: the index's order decides. + "Drive/notes about comet launch", + "dev/mycomet", + ] + ); + } + + #[test] + fn home_folders_put_repositories_first_within_a_tier() { + let candidates = [("a/comet-docs", false), ("b/c/comet-app", true)]; + assert_eq!( + ranked("comet", &candidates), + ["b/c/comet-app", "a/comet-docs"] + ); + // Typo matches survive only when nothing contains the query, and + // order the same way. + let candidates = [ + ("Models/Comfy/comfy_types", false), + ("dev/comet", true), + ("dev/tools", false), + ]; + assert_eq!( + ranked("comt", &candidates), + ["dev/comet", "dev/tools", "Models/Comfy/comfy_types"] + ); + } + + #[test] + fn slash_queries_match_trailing_segment_prefixes() { + let candidates = [ + ("Documentos/development/comet/apps", false), + ("Documentos/development/comet", true), + ("Documentos/dev/tools", false), + ("dev/com", false), + ]; + assert_eq!( + ranked("dev/com", &candidates), + ["dev/com", "Documentos/development/comet"] + ); + } + + fn search_files_blocking( + root: &Path, + query: &str, + featured_paths: &[String], + ) -> Result, EngineError> { + super::search_files_blocking(&WorkspaceSearch::new(), root, query, featured_paths) + } + #[test] fn search_files_obeys_gitignore_and_returns_directories() { let root = tempfile::tempdir().unwrap(); - std::fs::create_dir(root.path().join(".git")).unwrap(); + // Dotfiles are indexed only inside a real repository. + let init = std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(root.path()) + .status() + .unwrap(); + assert!(init.success()); std::fs::create_dir(root.path().join("src")).unwrap(); std::fs::write(root.path().join("src/composer.rs"), "").unwrap(); std::fs::write(root.path().join(".secret"), "").unwrap(); @@ -2643,33 +2776,6 @@ tmpfs /run tmpfs rw 0 0 assert!(composer < path_only); } - #[test] - fn cached_search_reuses_one_walk_within_the_ttl() { - let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("alpha.rs"), "").unwrap(); - let cache: FileIndexCache = std::sync::Mutex::new(HashMap::new()); - - let first = search_files_cached(&cache, root.path(), "alpha", &[], || false).unwrap(); - assert_eq!(first.first().map(|m| m.path.as_str()), Some("alpha.rs")); - std::fs::write(root.path().join("beta.rs"), "").unwrap(); - let second = search_files_cached(&cache, root.path(), "beta", &[], || false).unwrap(); - assert!(second.is_empty(), "{second:?}"); - } - - #[test] - fn cancelled_search_stops_before_walking() { - let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("README.md"), "").unwrap(); - let cancelled = AtomicBool::new(true); - - let err = search_files_blocking_with_cancel(root.path(), "", &[], || { - cancelled.load(Ordering::Relaxed) - }) - .unwrap_err() - .to_string(); - assert!(err.contains("cancelled")); - } - #[tokio::test] async fn workspace_checkout_rejects_sibling_paths() { let data = tempfile::tempdir().unwrap(); diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 798b8e2243..0ebf024fd6 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1463,9 +1463,13 @@ fn forwardable(method: &str) -> bool { | methods::SWITCH_REF | methods::LIST_FOLDERS | methods::LIST_DRIVES + | methods::SEARCH_HOME_FOLDERS + | methods::RELEASE_HOME_FOLDER_SEARCH | methods::SEARCH_FILES | methods::LIST_WORKSPACE_DIRECTORY | methods::SEARCH_WORKSPACE_FILES + | methods::WARM_WORKSPACE_SEARCH + | methods::SEARCH_WORKSPACE_CONTENT | methods::READ_WORKSPACE_IMAGE | methods::READ_WORKSPACE_FILE | methods::DELETE_WORKSPACE_ENTRY @@ -3168,6 +3172,28 @@ impl RpcService for EngineRpc { .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&listing) } + methods::SEARCH_HOME_FOLDERS => { + let request: zeron_proto::SearchHomeFoldersRequest = parse_params(params)?; + if request.query.chars().count() > 256 { + return Err(RpcError::BadParams( + "SearchHomeFolders query must not exceed 256 characters".into(), + )); + } + let limit = request + .limit + .map_or(crate::repos::HOME_FOLDER_RESULTS, usize::from) + .min(crate::repos::HOME_FOLDER_MAX_RESULTS); + let result = self + .repos + .search_home_folders(request.query, limit) + .await + .map_err(|e| RpcError::Failed(e.to_string()))?; + RpcReply::value(&result) + } + methods::RELEASE_HOME_FOLDER_SEARCH => { + self.repos.release_home_folder_search(); + RpcReply::value(&serde_json::json!({})) + } methods::LIST_DRIVES => { let drives = self .repos @@ -3222,6 +3248,28 @@ impl RpcService for EngineRpc { .map_err(RpcError::from)?; RpcReply::value(&matches) } + methods::SEARCH_WORKSPACE_CONTENT => { + let request: zeron_proto::SearchWorkspaceContentRequest = parse_params(params)?; + let result = tokio::time::timeout( + crate::workspace_files::WORKSPACE_FILE_RPC_TIMEOUT, + self.workspace_files.search_content(request), + ) + .await + .map_err(|_| RpcError::Failed("workspace content search timed out".into()))? + .map_err(RpcError::from)?; + RpcReply::value(&result) + } + methods::WARM_WORKSPACE_SEARCH => { + let request: zeron_proto::WarmWorkspaceSearchRequest = parse_params(params)?; + let result = tokio::time::timeout( + crate::workspace_files::WORKSPACE_FILE_RPC_TIMEOUT, + self.workspace_files.warm_search(request), + ) + .await + .map_err(|_| RpcError::Failed("workspace search warm-up timed out".into()))? + .map_err(RpcError::from)?; + RpcReply::value(&result) + } methods::READ_WORKSPACE_IMAGE => { let request: zeron_proto::ReadWorkspaceImageRequest = parse_params(params)?; let chunk = tokio::time::timeout( @@ -4167,6 +4215,12 @@ mod tests { assert!(forwardable(methods::WATCH_WORKSPACE_GIT_STATUS)); assert!(!is_stream_method(methods::LIST_WORKSPACE_DIRECTORY)); assert!(!is_stream_method(methods::SEARCH_WORKSPACE_FILES)); + assert!(forwardable(methods::WARM_WORKSPACE_SEARCH)); + assert!(!is_stream_method(methods::WARM_WORKSPACE_SEARCH)); + assert!(forwardable(methods::SEARCH_WORKSPACE_CONTENT)); + assert!(!is_stream_method(methods::SEARCH_WORKSPACE_CONTENT)); + assert!(forwardable(methods::SEARCH_HOME_FOLDERS)); + assert!(forwardable(methods::RELEASE_HOME_FOLDER_SEARCH)); assert!(!is_stream_method(methods::READ_WORKSPACE_FILE)); assert!(!is_stream_method(methods::WRITE_WORKSPACE_FILE)); assert!(is_stream_method(methods::WATCH_WORKSPACE_FILES)); diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index fd89a7d8ac..82edac4b5a 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -14,17 +14,20 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Notify, broadcast, mpsc}; use tokio_util::sync::CancellationToken; use zeron_proto::{ - ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceFilesRequest, - WatchWorkspaceFilesRequest, WorkspaceDirectoryPage, WorkspaceEntry, WorkspaceEntryKind, - WorkspaceFileChange, WorkspaceFileChangeKind, WorkspaceFileChanges, - WorkspaceFileConflictReason, WorkspaceFileSearchMatch, WorkspaceFileText, - WorkspaceFileWriteResult, WorkspaceLineEnding, WorkspaceReadOnlyReason, WorkspaceTarget, - WorkspaceTextEncoding, WorkspaceWritableEncoding, WorkspaceWritableLineEnding, - WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, + ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceContentRequest, + SearchWorkspaceFilesRequest, WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, + WatchWorkspaceFilesRequest, WorkspaceContentMatch, WorkspaceContentSearchResult, + WorkspaceDirectoryPage, WorkspaceEntry, WorkspaceEntryKind, WorkspaceFileChange, + WorkspaceFileChangeKind, WorkspaceFileChanges, WorkspaceFileConflictReason, + WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceFileWriteResult, WorkspaceLineEnding, + WorkspaceReadOnlyReason, WorkspaceSearchIndexState, WorkspaceTarget, WorkspaceTextEncoding, + WorkspaceWritableEncoding, WorkspaceWritableLineEnding, WriteWorkspaceFileOutcome, + WriteWorkspaceFileRequest, }; use zeron_rpc::RpcError; -use crate::{Repos, WorkspaceHost}; +use crate::workspace_search::{IndexState, NameKinds, normalize_name_query}; +use crate::{Repos, WorkspaceHost, WorkspaceSearch}; mod mutations; @@ -35,6 +38,9 @@ pub const DIRECTORY_PAGE_SIZE: usize = 500; pub const MAX_DIRECTORY_ENTRIES: usize = 50_000; pub const MAX_SEARCH_QUERY_CHARS: usize = 256; pub const MAX_SEARCH_RESULTS: usize = 200; +pub const MAX_CONTENT_RESULTS: usize = 200; +pub const DEFAULT_CONTENT_RESULTS: usize = 100; +pub const DEFAULT_CONTENT_RESULTS_PER_FILE: usize = 3; pub const WORKSPACE_FILE_RPC_TIMEOUT: Duration = Duration::from_secs(6); pub const MAX_EDITABLE_FILE_BYTES: u64 = 1024 * 1024; pub const MAX_PREVIEW_FILE_BYTES: u64 = 8 * 1024 * 1024; @@ -410,14 +416,16 @@ impl WorkspaceFiles { &self, request: SearchWorkspaceFilesRequest, ) -> Result, WorkspaceFilesError> { - validate_workspace_search_query(&request.query)?; + validate_name_search_query(&request.query, request.include_ignored)?; let workspace = self.resolve_target(&request.target).await?; let limit = usize::from(request.limit.unwrap_or(MAX_SEARCH_RESULTS as u16)).min(MAX_SEARCH_RESULTS); let cancel = Arc::new(AtomicBool::new(false)); let cancel_on_drop = CancelOnDrop::new(cancel.clone()); + let search = self.inner.repos.workspace_search().clone(); let result = tokio::task::spawn_blocking(move || { search_workspace_blocking( + &search, &workspace.root, &request.query, request.include_ignored, @@ -431,6 +439,38 @@ impl WorkspaceFiles { result } + pub async fn search_content( + &self, + request: SearchWorkspaceContentRequest, + ) -> Result { + validate_workspace_search_query(&request.query)?; + let workspace = self.resolve_target(&request.target).await?; + let search = self.inner.repos.workspace_search().clone(); + tokio::task::spawn_blocking(move || { + search_content_blocking(&search, &workspace.root, &request) + }) + .await + .map_err(|error| WorkspaceFilesError::Io(format!("search worker failed: {error}")))? + } + + pub async fn warm_search( + &self, + request: WarmWorkspaceSearchRequest, + ) -> Result { + let workspace = self.resolve_target(&request.target).await?; + let search = self.inner.repos.workspace_search().clone(); + let state = tokio::task::spawn_blocking(move || search.warm(&workspace.root)) + .await + .map_err(|error| WorkspaceFilesError::Io(format!("search worker failed: {error}")))? + .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; + Ok(WarmWorkspaceSearchResult { + state: match state { + IndexState::Building => WorkspaceSearchIndexState::Building, + IndexState::Ready => WorkspaceSearchIndexState::Ready, + }, + }) + } + pub async fn read_file( &self, request: ReadWorkspaceFileRequest, @@ -1196,31 +1236,143 @@ fn filtered_directory_paths(root: &Path, target: &Path) -> HashSet { .collect() } +/// `SearchWorkspaceFiles` against an already-resolved root, for +/// `examples/workspace_search_bench.rs`. +#[doc(hidden)] +pub fn bench_search_root( + search: &WorkspaceSearch, + root: &Path, + query: &str, + include_ignored: bool, + limit: usize, +) -> Result, WorkspaceFilesError> { + search_workspace_blocking( + search, + root, + query, + include_ignored, + limit, + &AtomicBool::new(false), + ) +} + +/// Names come from the workspace search index (fff), which skips ignored +/// files. "Show ignored" has no index, so it walks the folder directly with a +/// plain substring match — see [`search_including_ignored`]. fn search_workspace_blocking( + search: &WorkspaceSearch, root: &Path, query: &str, include_ignored: bool, limit: usize, cancel: &AtomicBool, ) -> Result, WorkspaceFilesError> { - validate_workspace_search_query(query)?; + validate_name_search_query(query, include_ignored)?; if limit == 0 { return Ok(Vec::new()); } - let mut builder = ignore::WalkBuilder::new(root); - builder.follow_links(false).hidden(false); if include_ignored { - builder.standard_filters(false); + return search_including_ignored(root, query, limit, cancel); + } + let found = search + .search_names_within( + root, + query, + limit, + NameKinds::FilesAndDirectories, + crate::workspace_search::NAME_SCAN_WAIT, + ) + .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; + if cancel.load(Ordering::Relaxed) { + return Err(WorkspaceFilesError::Io("workspace search cancelled".into())); + } + Ok(found + .matches + .into_iter() + .filter(|found| { + !is_internal_temp_wire_path(&found.path) + && !contains_git_component(Path::new(&found.path)) + // A symlink entry itself is listed (the tree shows them); + // nothing reached through one is. + && !crate::workspace_search::escapes_through_symlink(root, &found.path, true) + }) + .map(|found| { + let is_symlink = std::fs::symlink_metadata(root.join(&found.path)) + .is_ok_and(|metadata| metadata.file_type().is_symlink()); + let kind = if is_symlink { + WorkspaceEntryKind::Symlink + } else if found.is_dir { + WorkspaceEntryKind::Directory + } else { + WorkspaceEntryKind::File + }; + WorkspaceFileSearchMatch { + name: found + .path + .rsplit('/') + .next() + .unwrap_or(&found.path) + .to_owned(), + path: found.path, + kind, + score: found.score, + } + }) + .collect()) +} + +/// Case-insensitive substring match over every entry, ignored ones included. +/// Names starting with the query rank first, then other name matches, then +/// path-only matches, shorter paths first; only the best `limit` are kept +/// while walking. +fn search_including_ignored( + root: &Path, + query: &str, + limit: usize, + cancel: &AtomicBool, +) -> Result, WorkspaceFilesError> { + struct Ranked(WorkspaceFileSearchMatch); + impl Ranked { + fn key(&self) -> (std::cmp::Reverse, usize, &str) { + ( + std::cmp::Reverse(self.0.score), + self.0.path.len(), + &self.0.path, + ) + } + } + impl PartialEq for Ranked { + fn eq(&self, other: &Self) -> bool { + self.key() == other.key() + } } - let query_lower = query.to_lowercase(); - let mut matches = Vec::new(); + impl Eq for Ranked {} + impl PartialOrd for Ranked { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + impl Ord for Ranked { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.key().cmp(&other.key()) + } + } + + let query = normalize_name_query(query).trim().to_lowercase(); + let mut builder = ignore::WalkBuilder::new(root); + builder + .follow_links(false) + .hidden(false) + .standard_filters(false); + // Max-heap on rank: the worst kept match sits on top, ready to go. + let mut best = std::collections::BinaryHeap::with_capacity(limit + 1); for result in builder.build() { if cancel.load(Ordering::Relaxed) { return Err(WorkspaceFilesError::Io("workspace search cancelled".into())); } let entry = match result { Ok(entry) => entry, - Err(error) if matches.is_empty() => { + Err(error) if best.is_empty() => { return Err(WorkspaceFilesError::Io(error.to_string())); } Err(_) => continue, @@ -1232,9 +1384,8 @@ fn search_workspace_blocking( Ok(relative) if !contains_git_component(relative) => relative, _ => continue, }; - let file_type = match entry.file_type() { - Some(file_type) => file_type, - None => continue, + let Some(file_type) = entry.file_type() else { + continue; }; let kind = if file_type.is_symlink() { WorkspaceEntryKind::Symlink @@ -1245,27 +1396,89 @@ fn search_workspace_blocking( } else { continue; }; + let name = entry.file_name().to_string_lossy(); + let lower_name = name.to_lowercase(); + let score = if lower_name.starts_with(&query) { + 2 + } else if lower_name.contains(&query) { + 1 + } else if normalize_name_query(&relative.to_string_lossy()) + .to_lowercase() + .contains(&query) + { + 0 + } else { + continue; + }; let path = path_to_wire(relative)?; if is_internal_temp_wire_path(&path) { continue; } - let name = entry.file_name().to_string_lossy().into_owned(); - let Some(score) = workspace_search_score(&name, &path, &query_lower) else { - continue; - }; - matches.push(WorkspaceFileSearchMatch { + best.push(Ranked(WorkspaceFileSearchMatch { + name: name.into_owned(), path, - name, kind, score, - }); - if matches.len() > limit { - matches.sort_by(compare_workspace_search_matches); - matches.truncate(limit); + })); + if best.len() > limit { + best.pop(); } } - matches.sort_by(compare_workspace_search_matches); - Ok(matches) + Ok(best + .into_sorted_vec() + .into_iter() + .map(|ranked| ranked.0) + .collect()) +} + +fn search_content_blocking( + search: &WorkspaceSearch, + root: &Path, + request: &SearchWorkspaceContentRequest, +) -> Result { + validate_workspace_search_query(&request.query)?; + let limit = request + .limit + .map_or(DEFAULT_CONTENT_RESULTS, usize::from) + .min(MAX_CONTENT_RESULTS); + let per_file = request + .per_file_limit + .map_or(DEFAULT_CONTENT_RESULTS_PER_FILE, usize::from) + .min(limit); + let found = search + .search_content(root, &request.query, limit, per_file) + .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; + Ok(WorkspaceContentSearchResult { + matches: found + .matches + .into_iter() + .filter(|found| { + !is_internal_temp_wire_path(&found.path) + && !contains_git_component(Path::new(&found.path)) + // Never preview contents reached through a symlink. + && !crate::workspace_search::escapes_through_symlink(root, &found.path, false) + }) + .map(|found| WorkspaceContentMatch { + path: found.path, + line: found.line, + column: found.column, + preview: found.preview, + ranges: found.ranges, + }) + .collect(), + truncated: found.truncated, + indexing: found.indexing, + }) +} + +/// File-name search also takes an empty query, listing the workspace's files +/// by recency from the index (the palette's default Files list). "Show +/// ignored" walks the disk with a substring match, so it still needs text. +fn validate_name_search_query(query: &str, include_ignored: bool) -> Result<(), WorkspaceFilesError> { + if query.trim().is_empty() && !include_ignored { + return Ok(()); + } + validate_workspace_search_query(query) } fn validate_workspace_search_query(query: &str) -> Result<(), WorkspaceFilesError> { @@ -1282,17 +1495,6 @@ fn validate_workspace_search_query(query: &str) -> Result<(), WorkspaceFilesErro Ok(()) } -fn compare_workspace_search_matches( - left: &WorkspaceFileSearchMatch, - right: &WorkspaceFileSearchMatch, -) -> std::cmp::Ordering { - right - .score - .cmp(&left.score) - .then_with(|| left.path.to_lowercase().cmp(&right.path.to_lowercase())) - .then_with(|| left.path.cmp(&right.path)) -} - fn read_image_blocking( root: &Path, relative: &WorkspaceRelativePath, @@ -2127,40 +2329,6 @@ fn decode_hex(value: &str) -> Option> { .collect() } -fn workspace_search_score(name: &str, path: &str, query: &str) -> Option { - if query.is_empty() { - return Some(0); - } - let name = name.to_lowercase(); - let path = path.to_lowercase(); - if name == query { - return Some(10_000); - } - if name.starts_with(query) { - return Some(8_000 - name.len() as i64); - } - if let Some(index) = name.find(query) { - return Some(6_000 - index as i64 - name.len() as i64); - } - if let Some(index) = path.find(query) { - return Some(4_000 - index as i64 - path.len() as i64); - } - let mut query_chars = query.chars(); - let mut wanted = query_chars.next()?; - let mut gaps = 0i64; - for character in path.chars() { - if character == wanted { - match query_chars.next() { - Some(next) => wanted = next, - None => return Some(2_000 - gaps - path.len() as i64), - } - } else { - gaps += 1; - } - } - None -} - fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mutex .lock() @@ -2340,6 +2508,23 @@ mod tests { ); } + fn search_workspace_blocking( + root: &Path, + query: &str, + include_ignored: bool, + limit: usize, + cancel: &AtomicBool, + ) -> Result, WorkspaceFilesError> { + super::search_workspace_blocking( + &WorkspaceSearch::new(), + root, + query, + include_ignored, + limit, + cancel, + ) + } + #[test] fn search_ranks_filename_and_nested_path_matches() { let root = tempfile::tempdir().unwrap(); @@ -2349,7 +2534,10 @@ mod tests { std::fs::write(root.path().join("README.md"), b"").unwrap(); let root = std::fs::canonicalize(root.path()).unwrap(); - let matches = search_workspace_blocking(&root, "config", false, 200, &no_cancel()).unwrap(); + // fff ranks the shallower `configuration.rs` first for "config"; an + // exact file name still beats depth. + let matches = + search_workspace_blocking(&root, "config.rs", false, 200, &no_cancel()).unwrap(); assert_eq!(matches[0].path, "src/deep/config.rs"); assert!( matches @@ -2360,13 +2548,19 @@ mod tests { } #[test] - fn search_rejects_empty_and_whitespace_queries() { + fn an_empty_query_lists_recent_names_but_show_ignored_needs_text() { let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("notes.txt"), "x").unwrap(); let root = std::fs::canonicalize(root.path()).unwrap(); for query in ["", " ", "\n\t"] { + // The index lists files by recency (the palette's default list). + let listed = search_workspace_blocking(&root, query, false, 200, &no_cancel()).unwrap(); + assert!(listed.len() <= 200); + // "Show ignored" walks the disk with a substring match: no text, + // no walk. let error = - search_workspace_blocking(&root, query, false, 200, &no_cancel()).unwrap_err(); + search_workspace_blocking(&root, query, true, 200, &no_cancel()).unwrap_err(); assert!(error.to_string().contains("query must not be empty")); } } @@ -2385,11 +2579,250 @@ mod tests { } let root = std::fs::canonicalize(root.path()).unwrap(); - let matches = search_workspace_blocking(&root, "query", false, 2, &no_cancel()).unwrap(); + let matches = search_workspace_blocking(&root, "query", true, 2, &no_cancel()).unwrap(); assert_eq!(matches.len(), 2); assert_eq!(matches[0].path, "query"); assert_eq!(matches[1].path, "query-reference.txt"); + assert_eq!( + search_workspace_blocking(&root, "query", false, 2, &no_cancel()) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn search_including_ignored_matches_nested_paths() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".ignore"), b"aa\n").unwrap(); + std::fs::create_dir_all(root.path().join("aa/bb/cc/dd")).unwrap(); + std::fs::write(root.path().join("aa/bb/cc/dd/file.rs"), b"").unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + let forward = + search_workspace_blocking(&root, "aa/bb/cc/dd", true, 20, &no_cancel()).unwrap(); + let paths: Vec<_> = forward.iter().map(|m| m.path.as_str()).collect(); + assert_eq!(paths, ["aa/bb/cc/dd", "aa/bb/cc/dd/file.rs"]); + #[cfg(windows)] + for query in [r"aa\bb\cc\dd", r"aa\bb/cc\dd"] { + let found = search_workspace_blocking(&root, query, true, 20, &no_cancel()).unwrap(); + assert_eq!(found, forward); + } + } + + #[test] + #[cfg(not(windows))] + fn search_including_ignored_preserves_literal_posix_backslashes() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join(r"aa\bb/cc\dd")).unwrap(); + std::fs::create_dir_all(root.path().join("aa/bb/cc/dd")).unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + let found = + search_workspace_blocking(&root, r"aa\bb/cc\dd", true, 20, &no_cancel()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].path, r"aa\bb/cc\dd"); + let forward = + search_workspace_blocking(&root, "aa/bb/cc/dd", true, 20, &no_cancel()).unwrap(); + assert_eq!(forward.len(), 1); + assert_eq!(forward[0].path, "aa/bb/cc/dd"); + } + + #[test] + fn search_finds_ignored_entries_only_when_asked() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".ignore"), b"target\n").unwrap(); + std::fs::create_dir_all(root.path().join("target/debug")).unwrap(); + std::fs::write(root.path().join("target/debug/build.log"), b"").unwrap(); + std::fs::write(root.path().join("lib.rs"), b"").unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + let indexed = search_workspace_blocking(&root, "build", false, 200, &no_cancel()).unwrap(); + assert!( + indexed + .iter() + .all(|entry| !entry.path.starts_with("target")) + ); + let walked = search_workspace_blocking(&root, "build", true, 200, &no_cancel()).unwrap(); + assert_eq!(walked[0].path, "target/debug/build.log"); + assert_eq!(walked[0].name, "build.log"); + assert_eq!(walked[0].kind, WorkspaceEntryKind::File); + } + + fn content( + root: &Path, + query: &str, + per_file: Option, + ) -> Result { + let search = WorkspaceSearch::new(); + search.warm(root).unwrap(); + let request = SearchWorkspaceContentRequest { + target: WorkspaceTarget { + chat_id: Some("chat".into()), + space_id: None, + checkout_path: None, + }, + query: query.into(), + limit: None, + per_file_limit: per_file, + }; + // Wait out the initial scan so the result is never `indexing`. + for _ in 0..200 { + let result = search_content_blocking(&search, root, &request)?; + if !result.indexing { + return Ok(result); + } + } + panic!("workspace search index never finished scanning"); + } + + fn content_fixture() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let files: [(&str, &[u8]); 4] = [ + ( + "src/lib.rs", + b"fn main() {}\n let needle = 1;\nneedle();\nneedle!();\nneedle?\n", + ), + ("README.md", b"A Needle in the docs\n"), + ("utf8.txt", "café — needle ñ\n".as_bytes()), + ("image.bin", b"needle\0\0\0\x01\x02 needle"), + ]; + for (path, bytes) in files { + let path = dir.path().join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + let root = std::fs::canonicalize(dir.path()).unwrap(); + (dir, root) + } + + #[test] + fn content_search_finds_literal_lines_with_their_location() { + let (_dir, root) = content_fixture(); + let result = content(&root, "let needle", None).unwrap(); + assert_eq!(result.matches.len(), 1); + let found = &result.matches[0]; + assert_eq!(found.path, "src/lib.rs"); + assert_eq!(found.line, 2); + assert_eq!(found.column, 4); + assert_eq!(found.preview, "let needle = 1;"); + assert_eq!(found.ranges, vec![(0, 10)]); + } + + #[test] + fn content_search_preserves_spaces_and_tabs_in_the_query() { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("literal.txt"), + "let needle = 1;\nlet\tneedle = 2;\nlet needle = 3;\n", + ) + .unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + for (query, line) in [("let needle", 1), ("let\tneedle", 2)] { + let result = content(&root, query, None).unwrap(); + assert_eq!(result.matches.len(), 1, "query: {query:?}"); + let found = &result.matches[0]; + assert_eq!(found.path, "literal.txt"); + assert_eq!(found.line, line, "query: {query:?}"); + assert_eq!(found.column, 0); + assert_eq!(found.ranges, vec![(0, query.len() as u32)]); + assert!(found.preview.starts_with(query)); + } + } + + #[test] + fn content_search_treats_exclamation_marks_as_literal_text() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("literal.txt"), "if !ready {\nif ready {\n").unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + let result = content(&root, "if !ready", None).unwrap(); + assert_eq!(result.matches.len(), 1); + let found = &result.matches[0]; + assert_eq!(found.path, "literal.txt"); + assert_eq!(found.line, 1); + assert_eq!(found.column, 0); + assert_eq!(found.preview, "if !ready {"); + assert_eq!(found.ranges, vec![(0, 9)]); + } + + #[test] + fn content_search_preserves_literal_backslashes() { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("literal.txt"), + "\\n\nn\n\\!ready\n!ready\n", + ) + .unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + + for (query, line) in [(r"\n", 1), (r"\!ready", 3)] { + let result = content(&root, query, None).unwrap(); + assert_eq!(result.matches.len(), 1, "query: {query:?}"); + let found = &result.matches[0]; + assert_eq!(found.path, "literal.txt"); + assert_eq!(found.line, line); + assert_eq!(found.column, 0); + assert_eq!(found.preview, query); + assert_eq!(found.ranges, vec![(0, query.len() as u32)]); + } + } + + #[test] + fn content_search_uses_smart_case() { + let (_dir, root) = content_fixture(); + let insensitive = content(&root, "needle", Some(10)).unwrap(); + assert!(insensitive.matches.iter().any(|m| m.path == "README.md")); + let sensitive = content(&root, "Needle", Some(10)).unwrap(); + assert!(sensitive.matches.iter().all(|m| m.path == "README.md")); + assert_eq!(sensitive.matches.len(), 1); + } + + #[test] + fn content_search_skips_binary_files_and_caps_matches_per_file() { + let (_dir, root) = content_fixture(); + let result = content(&root, "needle", None).unwrap(); + assert!(!result.matches.iter().any(|m| m.path == "image.bin")); + assert_eq!( + result + .matches + .iter() + .filter(|m| m.path == "src/lib.rs") + .count(), + DEFAULT_CONTENT_RESULTS_PER_FILE + ); + let one = content(&root, "needle", Some(1)).unwrap(); + assert_eq!( + one.matches + .iter() + .filter(|m| m.path == "src/lib.rs") + .count(), + 1 + ); + } + + #[test] + fn content_search_ranges_are_utf8_byte_offsets_into_the_preview() { + let (_dir, root) = content_fixture(); + let result = content(&root, "needle ñ", None).unwrap(); + let found = &result.matches[0]; + assert_eq!(found.path, "utf8.txt"); + let (start, end) = found.ranges[0]; + assert_eq!(&found.preview[start as usize..end as usize], "needle ñ"); + assert_eq!(start as usize, "café — ".len()); + } + + #[test] + fn content_search_rejects_empty_and_oversized_queries() { + let (_dir, root) = content_fixture(); + for query in ["", " "] { + let error = content(&root, query, None).unwrap_err(); + assert!(error.to_string().contains("query must not be empty")); + } + let long = "x".repeat(MAX_SEARCH_QUERY_CHARS + 1); + assert!(content(&root, &long, None).is_err()); } fn read_fixture(bytes: &[u8]) -> WorkspaceFileText { diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs new file mode 100644 index 0000000000..368f09c158 --- /dev/null +++ b/crates/engine/src/workspace_search.rs @@ -0,0 +1,865 @@ +//! Workspace search index: one `fff-search` file picker per working folder +//! (repo, worktree or plain folder), shared by `SearchFiles` (`@`), +//! `SearchWorkspaceFiles` (file tree) and `SearchWorkspaceContent` (cmd+K). +//! +//! An index is created by the first warm or search of its root, scans in the +//! background and keeps itself current with its own watcher. Memory stays +//! bounded: an index idle for [`IDLE_EVICT`] is dropped, at most [`MAX_LIVE`] +//! are alive at once (least recently used goes first), and the content cache +//! of each one is capped at [`CACHE_BUDGET_BYTES`]. Indexes hold paths only: +//! content search greps the indexed files without a bigram prefilter. The UI +//! warms the focused chat's index once, so its first search finds it built. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, Instant}; + +use fff_search::file_picker::FilePicker; +use fff_search::{ + Casing, ContentCacheBudget, DirSearchConfig, FFFMode, FilePickerOptions, FuzzySearchOptions, + GrepMode, GrepSearchOptions, MixedItemRef, MixedSearchConfig, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, +}; + +/// An index nobody searched or warmed for this long is dropped. +pub const IDLE_EVICT: Duration = Duration::from_secs(15 * 60); +/// Live indexes at once; creating one more evicts the least recently used. +/// Each runs its own file watcher, so the cap also bounds inotify use. +pub const MAX_LIVE: usize = 7; +/// Per-index cap on cached file contents (fff defaults to up to 512 MB). +pub const CACHE_BUDGET_BYTES: u64 = 16 * 1024 * 1024; +/// A search waits this long for the initial scan before answering from +/// whatever is indexed so far (flagged `indexing`). +pub const SCAN_WAIT: Duration = Duration::from_millis(300); +/// The first-scan wait for name searches whose answer can't report that the +/// index is still building (`SearchFiles`, `SearchWorkspaceFiles`): long +/// enough for most checkouts, well inside the 6 s RPC timeout. +pub const NAME_SCAN_WAIT: Duration = Duration::from_millis(2000); +/// Interactive content searches return partial results past this budget. +pub const CONTENT_TIME_BUDGET: Duration = Duration::from_secs(2); +const REAPER_INTERVAL: Duration = Duration::from_secs(60); +/// Ceiling for logging a finished scan; a scan still running then is not logged. +const SCAN_LOG_WAIT: Duration = Duration::from_secs(10 * 60); + +/// Name queries follow the engine host's path syntax. fff indexes `/` paths, +/// while a backslash is a literal filename character on POSIX hosts. +pub(crate) fn normalize_name_query(query: &str) -> Cow<'_, str> { + #[cfg(windows)] + if query.contains('\\') { + return Cow::Owned(query.replace('\\', "/")); + } + Cow::Borrowed(query) +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceSearchError { + #[error("workspace search is unavailable: {0}")] + Unavailable(String), +} + +/// Whether a root's index has finished its initial scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexState { + Building, + Ready, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameKinds { + FilesAndDirectories, + Files, + Directories, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameMatch { + /// Root-relative, `/`-separated. + pub path: String, + pub is_dir: bool, + pub score: i64, +} + +#[derive(Debug, Clone, Default)] +pub struct NameSearch { + pub matches: Vec, + /// The initial scan had not finished; results may be incomplete. + pub indexing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentMatch { + /// Root-relative, `/`-separated. + pub path: String, + /// 1-based. + pub line: u64, + /// 0-based byte column of the first match in the original line. + pub column: u64, + /// The matched line, leading whitespace trimmed and capped at 512 bytes. + pub preview: String, + /// Byte ranges of the matches within `preview`. + pub ranges: Vec<(u32, u32)>, +} + +#[derive(Debug, Clone, Default)] +pub struct ContentSearch { + pub matches: Vec, + /// More matches exist past `limit`, or the time budget ran out. + pub truncated: bool, + pub indexing: bool, +} + +type Clock = Arc Instant + Send + Sync>; + +#[derive(Clone)] +pub struct WorkspaceSearch { + inner: Arc, +} + +struct Inner { + entries: Mutex>, + clock: Clock, + /// Whether indexes run a file watcher; off only for eviction tests, which + /// need many indexes but no updates (each watcher is an inotify instance). + watch: bool, + reaper_started: std::sync::Once, +} + +struct Entry { + picker: SharedFilePicker, + last_used: Instant, + /// A chat warmed it: it serves a conversation, not only a transient + /// caller such as the new-project folder picker. + warmed: bool, +} + +impl Default for WorkspaceSearch { + fn default() -> Self { + Self::new() + } +} + +impl WorkspaceSearch { + pub fn new() -> Self { + Self::with_clock(Arc::new(Instant::now), true) + } + + fn with_clock(clock: Clock, watch: bool) -> Self { + Self { + inner: Arc::new(Inner { + entries: Mutex::new(HashMap::new()), + clock, + watch, + reaper_started: std::sync::Once::new(), + }), + } + } + + /// Start (or keep alive) the index for a chat's `root`. + pub fn warm(&self, root: &Path) -> Result { + let picker = self.acquire(root, true)?; + Ok(if picker.wait_for_scan(Duration::ZERO) { + IndexState::Ready + } else { + IndexState::Building + }) + } + + /// Fuzzy file (and directory) names. An empty query lists files by + /// recency (git and modification time), or no directories at all. + pub fn search_names( + &self, + root: &Path, + query: &str, + limit: usize, + kinds: NameKinds, + ) -> Result { + self.search_names_within(root, query, limit, kinds, SCAN_WAIT) + } + + /// [`Self::search_names`], waiting up to `wait` for a first scan. Callers + /// whose wire answer cannot say "still indexing" (`@`, the file tree) + /// wait longer so a fresh index isn't served half-built. + pub fn search_names_within( + &self, + root: &Path, + query: &str, + limit: usize, + kinds: NameKinds, + wait: Duration, + ) -> Result { + let query = normalize_name_query(query); + let query = query.as_ref(); + let picker = self.acquire(root, false)?; + let indexing = !picker.wait_for_scan(wait); + if limit == 0 { + return Ok(NameSearch { + matches: Vec::new(), + indexing, + }); + } + let guard = picker.read().map_err(unavailable)?; + let Some(fff) = guard.as_ref() else { + // Released under us (capacity eviction): not a final empty answer. + return Ok(NameSearch { + matches: Vec::new(), + indexing: true, + }); + }; + let options = FuzzySearchOptions { + pagination: PaginationArgs { offset: 0, limit }, + ..Default::default() + }; + let mut matches = Vec::with_capacity(limit); + if kinds == NameKinds::Directories { + if query.trim().is_empty() { + return Ok(NameSearch { matches, indexing }); + } + let parsed = QueryParser::new(DirSearchConfig).parse(query); + let result = fff.fuzzy_search_directories(&parsed, options); + for (dir, score) in result.items.into_iter().zip(result.scores) { + let path = dir.relative_path(fff).trim_end_matches('/').to_owned(); + if path.is_empty() { + continue; + } + matches.push(NameMatch { + path, + is_dir: true, + score: i64::from(score.total), + }); + } + } else if kinds == NameKinds::Files || query.trim().is_empty() { + let parsed = QueryParser::default().parse(query); + let result = fff.fuzzy_search(&parsed, None, options); + for (item, score) in result.items.into_iter().zip(result.scores) { + matches.push(NameMatch { + path: item.relative_path(fff), + is_dir: false, + score: i64::from(score.total), + }); + } + } else { + let parsed = QueryParser::new(MixedSearchConfig).parse(query); + let result = fff.fuzzy_search_mixed(&parsed, None, options); + for (item, score) in result.items.into_iter().zip(result.scores) { + let (path, is_dir) = match item { + MixedItemRef::File(file) => (file.relative_path(fff), false), + MixedItemRef::Dir(dir) => { + let path = dir.relative_path(fff); + (path.trim_end_matches('/').to_owned(), true) + } + }; + if path.is_empty() { + continue; + } + matches.push(NameMatch { + path, + is_dir, + score: i64::from(score.total), + }); + } + } + matches.truncate(limit); + Ok(NameSearch { matches, indexing }) + } + + /// Plain-text content search with smart case. Binary and oversized files + /// are skipped; at most `per_file` matches come from any one file. + pub fn search_content( + &self, + root: &Path, + query: &str, + limit: usize, + per_file: usize, + ) -> Result { + let picker = self.acquire(root, false)?; + let indexing = !picker.wait_for_scan(SCAN_WAIT); + if limit == 0 || per_file == 0 || query.trim().is_empty() { + return Ok(ContentSearch { + indexing, + ..Default::default() + }); + } + let guard = picker.read().map_err(unavailable)?; + let Some(fff) = guard.as_ref() else { + // Released under us (capacity eviction): not a final empty answer. + return Ok(ContentSearch { + indexing: true, + ..Default::default() + }); + }; + let options = GrepSearchOptions { + max_matches_per_file: per_file, + casing: Some(Casing::Smart), + page_limit: limit, + mode: GrepMode::PlainText, + time_budget_ms: CONTENT_TIME_BUDGET.as_millis() as u64, + enforce_time_budget: true, + ..Default::default() + }; + // The raw-pattern API preserves whitespace, constraint-like tokens + // and backslashes. `grep` parses filters and interprets escapes even + // in PlainText mode, so use one literal pattern with no constraints. + let result = fff.multi_grep(&[query], &[], &options); + let mut truncated = result.next_file_offset != 0 || result.matches.len() > limit; + let mut matches = Vec::with_capacity(result.matches.len().min(limit)); + for found in result.matches { + if matches.len() == limit { + truncated = true; + break; + } + let Some(file) = result.files.get(found.file_index) else { + continue; + }; + let indent = found.line_content.len() - found.line_content.trim_start().len(); + let shift = indent as u32; + matches.push(ContentMatch { + path: file.relative_path(fff), + line: found.line_number, + column: found.col as u64, + preview: found.line_content[indent..].to_owned(), + ranges: found + .match_byte_offsets + .iter() + .map(|(start, end)| (start.saturating_sub(shift), end.saturating_sub(shift))) + .collect(), + }); + } + Ok(ContentSearch { + matches, + truncated, + indexing, + }) + } + + /// Block until `root`'s index finished its scan and content indexing + /// (benchmarks); `false` on timeout or when `root` has no index. + pub fn wait_until_indexed(&self, root: &Path, timeout: Duration) -> bool { + let picker = lock(&self.inner.entries) + .get(root) + .map(|entry| entry.picker.clone()); + picker.is_some_and(|picker| picker.wait_for_indexing_complete(timeout)) + } + + /// Drop every index now (benchmarks measuring memory after eviction). + #[doc(hidden)] + pub fn release_all(&self) { + let entries: Vec<_> = lock(&self.inner.entries).drain().collect(); + for (root, entry) in entries { + release(&root, entry, "explicit"); + } + } + + /// Drop `root`'s index now unless a chat warmed it, for a caller done + /// with an index it may have started. `true` if dropped. + pub fn release_unless_warmed(&self, root: &Path) -> bool { + let removed = { + let mut entries = lock(&self.inner.entries); + let warmed = entries.get(root).is_some_and(|entry| entry.warmed); + if warmed { None } else { entries.remove(root) } + }; + let Some(entry) = removed else { + return false; + }; + release(root, entry, "released"); + true + } + + /// Number of live indexes. + pub fn live(&self) -> usize { + lock(&self.inner.entries).len() + } + + /// Drop every index idle past [`IDLE_EVICT`] (the reaper runs this every + /// [`REAPER_INTERVAL`]). + pub fn evict_idle(&self) { + let now = (self.inner.clock)(); + let expired: Vec<(PathBuf, Entry)> = { + let mut entries = lock(&self.inner.entries); + let roots: Vec = entries + .iter() + .filter(|(_, entry)| now.saturating_duration_since(entry.last_used) >= IDLE_EVICT) + .map(|(root, _)| root.clone()) + .collect(); + roots + .into_iter() + .filter_map(|root| entries.remove(&root).map(|entry| (root, entry))) + .collect() + }; + for (root, entry) in expired { + release(&root, entry, "idle"); + } + } + + /// `warm`: a chat is asking (see [`Entry::warmed`]). + fn acquire(&self, root: &Path, warm: bool) -> Result { + self.start_reaper(); + let now = (self.inner.clock)(); + let mut evicted = None; + let picker = { + let mut entries = lock(&self.inner.entries); + if let Some(entry) = entries.get_mut(root) { + entry.last_used = now; + entry.warmed |= warm; + entry.picker.clone() + } else { + // Create first: a root fff refuses (a filesystem root, a + // vanished folder) must not cost a healthy index its place. + let picker = create_picker(root, self.inner.watch)?; + if entries.len() >= MAX_LIVE { + evicted = lru_victim(&entries) + .and_then(|victim| entries.remove(&victim).map(|entry| (victim, entry))); + } + entries.insert( + root.to_path_buf(), + Entry { + picker: picker.clone(), + last_used: now, + warmed: warm, + }, + ); + picker + } + }; + if let Some((victim, entry)) = evicted { + release(&victim, entry, "capacity"); + } + Ok(picker) + } + + fn start_reaper(&self) { + let weak = Arc::downgrade(&self.inner); + self.inner.reaper_started.call_once(move || { + let spawned = std::thread::Builder::new() + .name("workspace-search-reaper".into()) + .spawn(move || reap(weak)); + if let Err(error) = spawned { + tracing::warn!(%error, "workspace search reaper did not start"); + } + }); + } +} + +fn reap(inner: Weak) { + loop { + std::thread::sleep(REAPER_INTERVAL); + let Some(inner) = inner.upgrade() else { + return; + }; + WorkspaceSearch { inner }.evict_idle(); + } +} + +fn lru_victim(entries: &HashMap) -> Option { + entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(root, _)| root.clone()) +} + +fn create_picker(root: &Path, watch: bool) -> Result { + let picker = SharedFilePicker::default(); + let started = Instant::now(); + FilePicker::new_with_shared_state( + picker.clone(), + SharedFrecency::noop(), + FilePickerOptions { + base_path: root.to_string_lossy().into_owned(), + enable_mmap_cache: false, + // No bigram index: it costs a third of a large repo's index memory + // and scan CPU on every focused chat, while grep without it still + // answers in ~20 ms on a 28k-file repo (see + // docs/performance-workspace-search.md). + enable_content_indexing: false, + mode: FFFMode::Neovim, + cache_budget: ContentCacheBudget::from_overrides(0, CACHE_BUDGET_BYTES, 0), + watch, + follow_symlinks: false, + enable_fs_root_scanning: false, + // Projectless chats live in the home folder; fff skips its dotfiles + // and machine-state folders. + enable_home_dir_scanning: true, + ..Default::default() + }, + ) + .map_err(unavailable)?; + tracing::info!(root = %root.display(), "workspace search index created"); + let logged = picker.clone(); + let root_for_log = root.to_path_buf(); + let _ = std::thread::Builder::new() + .name("workspace-search-scan-log".into()) + .spawn(move || { + if !logged.wait_for_scan(SCAN_LOG_WAIT) { + return; + } + let Ok(guard) = logged.read() else { + return; + }; + if let Some(fff) = guard.as_ref() { + tracing::info!( + root = %root_for_log.display(), + files = fff.live_file_count(), + scan_ms = started.elapsed().as_millis() as u64, + arena_bytes = fff.arena_bytes().0, + "workspace search index scanned" + ); + } + }); + Ok(picker) +} + +/// Stop the index's scan and watcher and free it. The write lock waits for +/// in-flight searches, so this runs off the caller's thread. +fn release(root: &Path, entry: Entry, reason: &'static str) { + let root = root.to_path_buf(); + let picker = entry.picker; + picker.cancel(); + let _ = std::thread::Builder::new() + .name("workspace-search-release".into()) + .spawn(move || { + let Ok(mut guard) = picker.write() else { + return; + }; + if let Some(mut fff) = guard.take() { + let files = fff.live_file_count(); + let arena_bytes = fff.arena_bytes().0; + fff.stop_background_monitor(); + drop(fff); + drop(guard); + // An index frees tens of MB in small blocks; glibc keeps them + // mapped unless asked to give them back. + #[cfg(all(target_os = "linux", target_env = "gnu"))] + // SAFETY: malloc_trim only walks the allocator's own arenas. + unsafe { + libc::malloc_trim(0); + } + tracing::info!( + root = %root.display(), + reason, + files, + arena_bytes, + "workspace search index evicted" + ); + } + }); +} + +fn unavailable(error: impl std::fmt::Display) -> WorkspaceSearchError { + WorkspaceSearchError::Unavailable(error.to_string()) +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + + #[cfg(unix)] + #[test] + fn results_reached_through_a_symlink_are_outside_the_root() { + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret"), "token").unwrap(); + let root = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(root.path()).unwrap(); + std::fs::create_dir(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("leak")).unwrap(); + std::os::unix::fs::symlink(root.join("src/main.rs"), root.join("alias.rs")).unwrap(); + + assert!(!escapes_through_symlink(&root, "src/main.rs", false)); + // Through a symlinked folder: never, names or contents. + assert!(escapes_through_symlink(&root, "leak/secret", true)); + assert!(escapes_through_symlink(&root, "leak/secret", false)); + // A symlink entry itself may be listed but not read. + assert!(!escapes_through_symlink(&root, "alias.rs", true)); + assert!(escapes_through_symlink(&root, "alias.rs", false)); + // Gone, or not a plain relative path: not served. + assert!(escapes_through_symlink(&root, "src/missing.rs", false)); + assert!(escapes_through_symlink(&root, "../outside", false)); + } + + use super::*; + + struct ManualClock(Mutex); + + impl ManualClock { + fn advance(&self, by: Duration) { + *lock(&self.0) += by; + } + } + + fn manual() -> (WorkspaceSearch, Arc) { + let clock = Arc::new(ManualClock(Mutex::new(Instant::now()))); + let reader = clock.clone(); + ( + WorkspaceSearch::with_clock(Arc::new(move || *lock(&reader.0)), false), + clock, + ) + } + + fn folder(files: &[(&str, &[u8])]) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + for (path, contents) in files { + let path = dir.path().join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + let root = std::fs::canonicalize(dir.path()).unwrap(); + (dir, root) + } + + fn ready(search: &WorkspaceSearch, root: &Path) -> SharedFilePicker { + search.warm(root).unwrap(); + let picker = lock(&search.inner.entries)[root].picker.clone(); + assert!(picker.wait_for_indexing_complete(Duration::from_secs(20))); + picker + } + + fn names(search: &WorkspaceSearch, root: &Path, query: &str) -> Vec { + search + .search_names(root, query, 20, NameKinds::FilesAndDirectories) + .unwrap() + .matches + .into_iter() + .map(|found| found.path) + .collect() + } + + #[test] + fn creates_an_index_and_finds_files_and_directories() { + let (_dir, root) = folder(&[("src/composer.rs", b""), ("README.md", b"")]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + + let found = search + .search_names(&root, "composer", 20, NameKinds::FilesAndDirectories) + .unwrap(); + assert!(!found.indexing); + assert_eq!(found.matches[0].path, "src/composer.rs"); + assert!(!found.matches[0].is_dir); + let dirs = search + .search_names(&root, "src", 20, NameKinds::FilesAndDirectories) + .unwrap(); + assert!(dirs.matches.iter().any(|m| m.path == "src" && m.is_dir)); + let files = search + .search_names(&root, "src", 20, NameKinds::Files) + .unwrap(); + assert!(files.matches.iter().all(|m| !m.is_dir)); + assert_eq!(search.live(), 1); + } + + #[test] + fn directory_search_returns_only_directories() { + let (_dir, root) = folder(&[ + ("projects/comet/src/main.rs", b""), + ("projects/comet.md", b""), + ]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + let found = search + .search_names(&root, "comet", 20, NameKinds::Directories) + .unwrap(); + assert_eq!(found.matches[0].path, "projects/comet"); + assert!(found.matches.iter().all(|m| m.is_dir)); + assert!( + search + .search_names(&root, "", 20, NameKinds::Directories) + .unwrap() + .matches + .is_empty() + ); + } + + #[test] + #[cfg(windows)] + fn name_search_accepts_both_windows_separators() { + let (_dir, root) = folder(&[("aa/bb/cc/dd/file.rs", b"")]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + + for (kinds, query, expected) in [ + ( + NameKinds::Files, + "aa/bb/cc/dd/file.rs", + "aa/bb/cc/dd/file.rs", + ), + (NameKinds::Directories, "aa/bb/cc/dd", "aa/bb/cc/dd"), + (NameKinds::FilesAndDirectories, "aa/bb/cc/dd", "aa/bb/cc/dd"), + ] { + let forward = search.search_names(&root, query, 20, kinds).unwrap(); + assert!(forward.matches.iter().any(|m| m.path == expected)); + for alternative in [query.replace('/', "\\"), query.replacen('/', "\\", 2)] { + let found = search.search_names(&root, &alternative, 20, kinds).unwrap(); + assert_eq!(found.matches, forward.matches); + } + } + } + + #[test] + #[cfg(not(windows))] + fn name_search_preserves_literal_posix_backslashes() { + let literal = r"aa\bb\cc\dd"; + let (_dir, root) = folder(&[(r"aa\bb\cc\dd/file.rs", b""), ("aa/bb/cc/dd/file.rs", b"")]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + + for kinds in [NameKinds::Directories, NameKinds::FilesAndDirectories] { + let found = search.search_names(&root, literal, 20, kinds).unwrap(); + assert_eq!(found.matches[0].path, literal); + } + let found = search + .search_names(&root, &format!("{literal}/file.rs"), 20, NameKinds::Files) + .unwrap(); + assert_eq!(found.matches[0].path, format!("{literal}/file.rs")); + let forward = search + .search_names(&root, "aa/bb/cc/dd", 20, NameKinds::Directories) + .unwrap(); + assert_eq!(forward.matches[0].path, "aa/bb/cc/dd"); + } + + #[test] + fn releasing_spares_an_index_a_chat_warmed() { + let (_a, a) = folder(&[("a.rs", b"")]); + let (_b, b) = folder(&[("b.rs", b"")]); + let search = WorkspaceSearch::new(); + search.warm(&a).unwrap(); + search + .search_names(&b, "b", 5, NameKinds::Directories) + .unwrap(); + + assert!(!search.release_unless_warmed(&a)); + assert!(search.release_unless_warmed(&b)); + assert!(!search.release_unless_warmed(&b)); + let entries = lock(&search.inner.entries); + assert!(entries.contains_key(&a)); + assert!(!entries.contains_key(&b)); + } + + #[test] + fn the_watcher_picks_up_new_files() { + let (_dir, root) = folder(&[("alpha.rs", b"")]); + let search = WorkspaceSearch::new(); + let picker = ready(&search, &root); + assert!(picker.wait_for_watcher(Duration::from_secs(20))); + assert!(!names(&search, &root, "beta").contains(&"beta.rs".to_owned())); + + std::fs::write(root.join("beta.rs"), b"").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !names(&search, &root, "beta").contains(&"beta.rs".to_owned()) { + assert!(Instant::now() < deadline, "watcher never indexed beta.rs"); + std::thread::sleep(Duration::from_millis(50)); + } + } + + #[test] + fn idle_indexes_are_evicted() { + let (_a, a) = folder(&[("a.rs", b"")]); + let (_b, b) = folder(&[("b.rs", b"")]); + let (search, clock) = manual(); + search.warm(&a).unwrap(); + clock.advance(IDLE_EVICT / 2); + search.warm(&b).unwrap(); + clock.advance(IDLE_EVICT / 2); + + search.evict_idle(); + + let entries = lock(&search.inner.entries); + assert!(!entries.contains_key(&a)); + assert!(entries.contains_key(&b)); + } + + #[test] + fn creating_past_the_cap_evicts_the_least_recently_used() { + let folders: Vec<_> = (0..=MAX_LIVE).map(|_| folder(&[("x.rs", b"")])).collect(); + let (search, clock) = manual(); + for (_, root) in &folders[..MAX_LIVE] { + search.warm(root).unwrap(); + clock.advance(Duration::from_secs(1)); + } + // Touching the oldest makes the second the least recently used. + search.warm(&folders[0].1).unwrap(); + clock.advance(Duration::from_secs(1)); + + search.warm(&folders[MAX_LIVE].1).unwrap(); + + let entries = lock(&search.inner.entries); + assert_eq!(entries.len(), MAX_LIVE); + assert!(!entries.contains_key(&folders[1].1)); + } + + #[test] + fn content_search_skips_binaries_and_caps_matches_per_file() { + let (_dir, root) = folder(&[ + ( + "notes.md", + b"needle one\nneedle two\nneedle three\nneedle four\n", + ), + ("other.txt", b" indented needle\n"), + ("blob.bin", b"needle\0\0\0binary"), + ]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + + let found = search.search_content(&root, "needle", 50, 3).unwrap(); + assert!(!found.matches.iter().any(|m| m.path == "blob.bin")); + assert_eq!( + found + .matches + .iter() + .filter(|m| m.path == "notes.md") + .count(), + 3 + ); + let other = found + .matches + .iter() + .find(|m| m.path == "other.txt") + .unwrap(); + assert_eq!(other.line, 1); + assert_eq!(other.preview, "indented needle"); + assert_eq!(other.ranges, vec![(9, 15)]); + assert_eq!(other.column, 11); + } + + #[test] + fn content_search_rejects_blank_queries_without_searching() { + let (_dir, root) = folder(&[("notes.md", b" \n")]); + let search = WorkspaceSearch::new(); + ready(&search, &root); + assert!( + search + .search_content(&root, " ", 50, 3) + .unwrap() + .matches + .is_empty() + ); + } +} + +/// Whether `relative` (an index result under `root`) reaches outside the +/// root through a symlink. fff's watcher follows links created after the +/// first scan, so results are re-checked with ReadWorkspaceFile's rule: no +/// symlinked folder on the way, and for contents, no symlinked file either. +/// A path that no longer exists is treated as escaping (it can't be served). +pub fn escapes_through_symlink(root: &Path, relative: &str, allow_final_link: bool) -> bool { + let components: Vec<_> = Path::new(relative).components().collect(); + let mut current = root.to_path_buf(); + for (index, component) in components.iter().enumerate() { + let std::path::Component::Normal(component) = component else { + return true; + }; + current.push(component); + let Ok(metadata) = std::fs::symlink_metadata(¤t) else { + return true; + }; + let last = index + 1 == components.len(); + if metadata.file_type().is_symlink() && !(last && allow_final_link) { + return true; + } + } + false +} diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index 142f71c4c1..8602fe4da2 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -1442,6 +1442,36 @@ async fn workspace_file_surface_proxies_over_the_relay() { .expect("remote search"); assert_eq!(matches[0]["path"], "src/remote.rs"); + let warm = client + .call( + methods::WARM_WORKSPACE_SEARCH, + serde_json::json!({ + "chatId": "chat-files", + "targetDeviceId": "device-b", + }), + ) + .await + .expect("remote search warm-up"); + assert!(matches!(warm["state"].as_str(), Some("building" | "ready"))); + + let content = client + .call( + methods::SEARCH_WORKSPACE_CONTENT, + serde_json::json!({ + "chatId": "chat-files", + "query": "REMOTE", + "targetDeviceId": "device-b", + }), + ) + .await + .expect("remote content search"); + assert!( + content["matches"] + .as_array() + .is_some_and(|matches| matches.iter().any(|m| m["path"] == "src/remote.rs")) + || content["indexing"] == true + ); + let read = client .call( methods::READ_WORKSPACE_FILE, diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 45665386f5..333338eb3f 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -444,6 +444,35 @@ pub struct FolderListing { pub truncated: bool, } +/// `SearchHomeFolders`: folders anywhere under the host's home whose names +/// match `query`, for the new-project picker. An empty query only starts the +/// host's home search index. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchHomeFoldersRequest { + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeFolderMatch { + /// Absolute path on the host. + pub path: String, + /// Home-relative, `/`-separated (what the picker shows). + pub relative: String, + pub is_repo: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeFolderSearchResult { + pub matches: Vec, + /// The host's home index was still scanning; results may be incomplete. + pub indexing: bool, +} + /// A browse root beyond home: a mounted drive/volume (or the system root). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -555,6 +584,68 @@ pub struct WorkspaceFileSearchMatch { pub score: i64, } +/// Plain-text search over a workspace's file contents (smart case). Binary +/// and oversized files are skipped. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchWorkspaceContentRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub per_file_limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceContentMatch { + /// Workspace-relative, `/`-separated. + pub path: String, + /// 1-based. + pub line: u64, + /// 0-based byte column of the first match in the original line. + pub column: u64, + /// The matched line, leading whitespace trimmed, at most 512 bytes. + pub preview: String, + /// `[start, end)` byte ranges of the matches within `preview`. + pub ranges: Vec<(u32, u32)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceContentSearchResult { + pub matches: Vec, + /// More matches exist, or the host's time budget ran out. + pub truncated: bool, + /// The host's index was still scanning; results may be incomplete. + pub indexing: bool, +} + +/// Start (or keep alive) the host's search index for a workspace, so the +/// chat's first search finds it built. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WarmWorkspaceSearchRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceSearchIndexState { + /// The initial scan is still running; searches may be incomplete. + Building, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WarmWorkspaceSearchResult { + pub state: WorkspaceSearchIndexState, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReadWorkspaceFileRequest { @@ -1682,6 +1773,21 @@ mod tests { assert!(requests.iter().all(|value| value["chatId"] == "chat-1")); } + #[test] + fn warm_workspace_search_flattens_its_target() { + // Early clients also sent a `pin` flag; it is ignored now. + let request: WarmWorkspaceSearchRequest = + serde_json::from_value(serde_json::json!({ "chatId": "chat-1", "pin": true })).unwrap(); + assert_eq!(request.target.chat_id.as_deref(), Some("chat-1")); + assert_eq!( + serde_json::to_value(WarmWorkspaceSearchResult { + state: WorkspaceSearchIndexState::Building, + }) + .unwrap(), + serde_json::json!({ "state": "building" }) + ); + } + #[test] fn workspace_write_outcomes_have_stable_tags() { let written = WriteWorkspaceFileOutcome::Written { diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index b18ba7384e..6822af5f7f 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -163,12 +163,20 @@ pub mod methods { pub const LIST_FOLDERS: &str = "ListFolders"; /// The device's browse roots: home plus mounted drives/volumes. pub const LIST_DRIVES: &str = "ListDrives"; + /// Folders under the host's home by name (new-project picker). + pub const SEARCH_HOME_FOLDERS: &str = "SearchHomeFolders"; + /// The new-project picker closed: drop the host's home index if unpinned. + pub const RELEASE_HOME_FOLDER_SEARCH: &str = "ReleaseHomeFolderSearch"; /// Fuzzy relative-path search rooted in a known chat or space checkout. pub const SEARCH_FILES: &str = "SearchFiles"; // Device-local workspace filesystem operations. All are relay-forwardable; // WatchWorkspaceFiles is the only streaming method in this group. pub const LIST_WORKSPACE_DIRECTORY: &str = "ListWorkspaceDirectory"; pub const SEARCH_WORKSPACE_FILES: &str = "SearchWorkspaceFiles"; + /// Start or keep alive a workspace's search index (fire-and-forget warm). + pub const WARM_WORKSPACE_SEARCH: &str = "WarmWorkspaceSearch"; + /// Plain-text search over a workspace's file contents. + pub const SEARCH_WORKSPACE_CONTENT: &str = "SearchWorkspaceContent"; pub const READ_WORKSPACE_IMAGE: &str = "ReadWorkspaceImage"; pub const READ_WORKSPACE_FILE: &str = "ReadWorkspaceFile"; pub const MOVE_WORKSPACE_ENTRY: &str = "MoveWorkspaceEntry"; diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index 8b100ccba2..8d70505ad9 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -5,9 +5,11 @@ use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio::sync::mpsc; use zeron_proto::{ - ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceFilesRequest, - WatchWorkspaceFilesRequest, WorkspaceDirectoryPage, WorkspaceFileSearchMatch, - WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, + ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceContentRequest, + SearchWorkspaceFilesRequest, WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, + WatchWorkspaceFilesRequest, WorkspaceContentSearchResult, WorkspaceDirectoryPage, + WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, + WriteWorkspaceFileRequest, }; use zeron_rpc::{RpcError, methods}; @@ -55,6 +57,9 @@ pub enum FilesClientError { Transport(String), #[error("workspace request failed: {0}")] Request(String), + /// The host predates this method. + #[error("workspace host does not support {0}")] + Unsupported(String), } impl FilesClientError { @@ -68,6 +73,7 @@ impl From for FilesClientError { match error { RpcError::Transport(message) => Self::Transport(message), RpcError::Closed => Self::Transport("connection closed".into()), + RpcError::UnknownMethod(method) => Self::Unsupported(method), other => Self::Request(other.to_string()), } } @@ -122,6 +128,11 @@ impl WorkspaceFilesClient { Self { transport, context } } + /// The workspace every request of this client addresses. + pub fn target(&self) -> &WorkspaceTarget { + &self.context.target + } + pub async fn list_directory( &self, request: ListWorkspaceDirectoryRequest, @@ -179,6 +190,30 @@ impl WorkspaceFilesClient { self.call(methods::SEARCH_WORKSPACE_FILES, &request).await } + pub async fn search_content( + &self, + request: SearchWorkspaceContentRequest, + ) -> Result { + self.call(methods::SEARCH_WORKSPACE_CONTENT, &request).await + } + + /// Start or keep alive the host's search index for this workspace. + pub async fn warm_search(&self) -> Result { + let request = WarmWorkspaceSearchRequest { + target: self.context.target.clone(), + }; + self.call(methods::WARM_WORKSPACE_SEARCH, &request).await + } + + /// [`Self::warm_search`] as a hint: failures are logged, never surfaced + /// (hosts that predate it are skipped silently). + pub async fn hint_search_warm(&self) { + match self.warm_search().await { + Ok(_) | Err(FilesClientError::Unsupported(_)) => {} + Err(error) => tracing::debug!(%error, "workspace search warm-up failed"), + } + } + pub async fn read_file( &self, request: ReadWorkspaceFileRequest, diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 0a69668a12..d9ad54fac9 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -44,6 +44,15 @@ use model::{DirectoryLoadState, FileTreeModel}; use preview::FilePreviewState; use search::FileSearchState; +/// File links use character columns; content search reports UTF-8 bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileColumn { + /// One-based character column from a file link. + Character(u32), + /// Zero-based UTF-8 byte column from a content search match. + Byte(u64), +} + static NEXT_REVIEW_COMMENT_FLUSH_SOURCE: AtomicU64 = AtomicU64::new(1); use crate::surface_chrome::{ CONTROL_RADIUS as TOOLBAR_BUTTON_RADIUS, CONTROL_SIZE as TOOLBAR_BUTTON_SIZE, toolbar, @@ -291,7 +300,7 @@ pub struct FilesSurface { watch_error: Option, preview: FilePreviewState, tree_context_menu: crate::popover::Popup, - pending_line_navigation: Option<(u32, Option)>, + pending_line_navigation: Option<(u32, Option)>, line_navigation_generation: u64, editor_context_menu: crate::popover::Popup, loads: HashMap<(String, Option), Task<()>>, @@ -881,6 +890,18 @@ impl FilesSurface { } self.started = true; self.load_directory(String::new(), None, cx); + // Opening the tree is a strong hint a search follows; start the + // host's index now. + if let (Some(engine), Some(context)) = ( + self.state.read(cx).engine().cloned(), + self.request_context.clone(), + ) { + let client = WorkspaceFilesClient::new(engine, context); + cx.spawn(async move |_, _| { + client.hint_search_warm().await; + }) + .detach(); + } } pub fn retry_root(&mut self, cx: &mut Context) { diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index 6336cd54ac..76d53a0382 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -17,7 +17,7 @@ use zeron_proto::{ }; use super::{ - FilesCloseDisposition, FilesEvent, FilesSurface, + FileColumn, FilesCloseDisposition, FilesEvent, FilesSurface, client::{FilesRequestContext, WorkspaceFilesClient}, document::{DocumentKey, DocumentPhase, FileDocument}, toolbar, toolbar_button, @@ -577,7 +577,7 @@ impl FilesSurface { pub(crate) fn navigate_to_line( &mut self, line: u32, - column: Option, + column: Option, cx: &mut Context, ) { if line == 0 { @@ -628,8 +628,19 @@ impl FilesSurface { if let Some(editor) = editor { editor.update(cx, |state, cx| { let row = (line - 1).min(state.text().lines_len().saturating_sub(1) as u32); + let character = match column { + Some(FileColumn::Character(column)) => column.saturating_sub(1), + Some(FileColumn::Byte(column)) => { + // Convert against the loaded line, not the trimmed and + // truncated search preview (which may omit indentation). + let text = state.text().slice_line(row as usize); + let byte = column.min(text.len() as u64) as usize; + text.byte_to_char_idx(byte) as u32 + } + None => 0, + }; state.set_cursor_position( - gpui_base::input::Position::new(row, column.unwrap_or(1).saturating_sub(1)), + gpui_base::input::Position::new(row, character), window, cx, ); @@ -4001,6 +4012,96 @@ mod markdown_buffer_tests { use super::*; use gpui::{AppContext, TestAppContext}; + #[gpui::test] + fn content_search_byte_columns_place_cursor_at_match_after_unicode(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + let path = "search.txt"; + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| crate::state::AppState::new()); + FilesSurface::new_editor( + state, + "chat".into(), + path.into(), + false, + 1000, + 13.0, + false, + false, + cx, + ) + }); + for prefix in [ + "plain ", + "café ", + "🦀 ", + "café 🦀 ", + "\u{2003}\tcafé 🦀 ", + "cafe\u{301} ", + ] { + let source = format!("first line\n{prefix}needle suffix\nlast line"); + window + .update(cx, |surface, window, cx| { + surface.preview.documents.insert( + path.into(), + FileDocument::loading(DocumentKey { + chat_id: "chat".into(), + checkout_id: Some("checkout".into()), + path: path.into(), + }), + ); + surface.navigate_to_line(2, Some(FileColumn::Byte(prefix.len() as u64)), cx); + // Navigation can arrive before the file contents do. + surface.apply_line_navigation(path, None, window, cx); + assert!(surface.pending_line_navigation.is_some()); + surface.preview.documents.get_mut(path).unwrap().set_loaded( + zeron_proto::WorkspaceFileText { + checkout_id: "checkout".into(), + path: path.into(), + text: Some(source.clone()), + content_hash: Some("hash".into()), + size: source.len() as u64, + modified_at: None, + encoding: zeron_proto::WorkspaceTextEncoding::Utf8, + line_ending: Some(zeron_proto::WorkspaceLineEnding::Lf), + read_only_reason: None, + truncated: false, + }, + ); + let editor = cx.new(|cx| { + gpui_base::input::EditorState::new(window, cx).default_value(source.clone()) + }); + surface.apply_line_navigation(path, Some(&editor), window, cx); + assert!(surface.pending_line_navigation.is_none()); + assert_eq!( + editor.read(cx).cursor(), + source.find("needle").unwrap(), + "{prefix:?}" + ); + assert_eq!( + editor.read(cx).cursor_position(), + gpui_base::input::Position::new(1, prefix.chars().count() as u32) + ); + // Opening a link to the same character must keep its existing + // one-based character semantics, even on a multibyte line. + surface.navigate_to_line( + 2, + Some(FileColumn::Character(prefix.chars().count() as u32 + 1)), + cx, + ); + surface.apply_line_navigation(path, Some(&editor), window, cx); + assert_eq!( + editor.read(cx).cursor(), + source.find("needle").unwrap(), + "file link: {prefix:?}" + ); + }) + .unwrap(); + } + } + #[gpui::test] fn chat_line_link_opens_markdown_source_and_centers_the_requested_line( cx: &mut TestAppContext, @@ -4036,8 +4137,8 @@ mod markdown_buffer_tests { path: path.into(), }); assert!(document.show_markdown); - surface.navigate_to_line(20, Some(3), cx); - surface.navigate_to_line(90, Some(6), cx); + surface.navigate_to_line(20, Some(FileColumn::Character(3)), cx); + surface.navigate_to_line(90, Some(FileColumn::Character(6)), cx); surface.preview.active = Some(path.into()); surface.preview.documents.insert(path.into(), document); }) @@ -4050,7 +4151,10 @@ mod markdown_buffer_tests { .unwrap(); window .update(cx, |surface, _, cx| { - assert_eq!(surface.pending_line_navigation, Some((90, Some(6)))); + assert_eq!( + surface.pending_line_navigation, + Some((90, Some(FileColumn::Character(6)))) + ); surface.preview.documents.get_mut(path).unwrap().set_loaded( zeron_proto::WorkspaceFileText { checkout_id: "checkout".into(), diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index ee8d4f8028..e7fcedbab8 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -244,27 +244,171 @@ pub fn offered_options( // Pure: folder-browser navigation (used by the shell's add-space flow) // --------------------------------------------------------------------------- -/// Whether `path` is drive-rooted (`C:`, `C:\…`, `C:/…`). Judged by shape, -/// not `cfg`: the device being browsed may be a Windows machine reached from -/// any platform. -fn is_windows_path(path: &str) -> bool { - let bytes = path.as_bytes(); - bytes.len() >= 2 +/// Windows browser paths are parsed by shape even on a POSIX client: the +/// device being browsed can be remote. A UNC root includes BOTH server and +/// share; extended paths keep their prefix and use only backslash separators. +struct WindowsBrowserPath<'a> { + root: &'a str, + rest: &'a str, + extended: bool, +} + +impl WindowsBrowserPath<'_> { + fn is_separator(&self, ch: char) -> bool { + ch == '\\' || (!self.extended && ch == '/') + } + + fn root_path(&self) -> String { + let root = if self.extended { + self.root.to_owned() + } else { + self.root.replace('/', "\\") + }; + format!("{root}\\") + } + + fn normalized_rest(&self) -> String { + let rest = self.rest.trim_matches(|ch| self.is_separator(ch)); + if self.extended { + rest.to_owned() + } else { + rest.replace('/', "\\") + } + } +} + +fn windows_browser_path(path: &str) -> Option> { + let extended = path.starts_with(r"\\?\"); + let start = if extended { 4 } else { 0 }; + let tail = &path[start..]; + let bytes = tail.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' - && bytes.get(2).is_none_or(|b| matches!(b, b'/' | b'\\')) + && bytes + .get(2) + .is_none_or(|b| *b == b'\\' || (!extended && *b == b'/')) + { + return Some(WindowsBrowserPath { + root: &path[..start + 2], + rest: &path[start + 2..], + extended, + }); + } + let unc_start = if extended { + tail.get(..4) + .filter(|prefix| prefix.eq_ignore_ascii_case("UNC\\"))?; + 8 + } else if path.starts_with(r"\\") && !path.starts_with(r"\\.\") { + 2 + } else { + return None; + }; + let is_separator = |ch| ch == '\\' || (!extended && ch == '/'); + let server_end = unc_start + path[unc_start..].find(is_separator)?; + if server_end == unc_start { + return None; + } + let share_start = server_end + 1; + let share_end = path[share_start..] + .find(is_separator) + .map_or(path.len(), |at| share_start + at); + if share_end == share_start { + return None; + } + Some(WindowsBrowserPath { + root: &path[..share_end], + rest: &path[share_end..], + extended, + }) +} + +fn is_windows_path(path: &str) -> bool { + windows_browser_path(path).is_some() +} + +/// Lexical identity for browser rows, crumbs and existing spaces. Preserve +/// POSIX spelling and directory casing (Windows directories can be case +/// sensitive). Prefix aliases are equivalent only for ordinary Windows names. +pub fn folder_path_key(path: &str) -> String { + let Some(windows) = windows_browser_path(path) else { + return path.to_owned(); + }; + let rest = windows.normalized_rest(); + let ordinary_name = |name: &str| { + let stem = name.split('.').next().unwrap_or_default(); + let reserved = matches!( + stem.to_ascii_uppercase().as_str(), + "CON" | "PRN" | "AUX" | "NUL" + ) || stem.get(..3).is_some_and(|prefix| { + (prefix.eq_ignore_ascii_case("COM") || prefix.eq_ignore_ascii_case("LPT")) + && matches!( + &stem[3..], + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }); + !name.ends_with(['.', ' ']) + && !name.contains(['/', ':', '*', '?', '"', '<', '>', '|']) + && !name.chars().any(char::is_control) + && !reserved + }; + let unc_root = windows.extended + && windows + .root + .get(4..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("UNC\\")); + let ordinary_names = rest.split('\\').all(ordinary_name) + && (!unc_root || windows.root[8..].split('\\').all(ordinary_name)); + let root = if windows.extended && ordinary_names { + let tail = &windows.root[4..]; + if tail + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("UNC\\")) + { + format!(r"\\{}", &tail[4..]) + } else { + tail.to_owned() + } + } else if windows.extended { + windows.root.to_owned() + } else { + windows.root.replace('/', "\\") + }; + // Drive letters never distinguish case (`c:` is `C:`). Folder names keep + // theirs: a directory can be made case-sensitive (`fsutil + // setCaseSensitiveInfo`), so `Repo` and `repo` may be different folders. + let root = uppercase_drive_letter(root); + if rest.is_empty() { + format!("{root}\\") + } else { + format!("{root}\\{rest}") + } +} + +/// `c:` → `C:`, also behind an extended `\\?\` prefix; other roots unchanged. +fn uppercase_drive_letter(mut root: String) -> String { + let drive = root.strip_prefix(r"\\?\").unwrap_or(&root); + let bytes = drive.as_bytes(); + if bytes.len() == 2 && bytes[1] == b':' && bytes[0].is_ascii_lowercase() { + let at = root.len() - 2; + root[at..at + 1].make_ascii_uppercase(); + } + root +} + +pub fn same_folder_path(left: &str, right: &str) -> bool { + folder_path_key(left) == folder_path_key(right) } /// Parent of an absolute path; `None` at the filesystem root. pub fn parent_path(path: &str) -> Option { - if is_windows_path(path) { - let (drive, rest) = path.split_at(2); - let rest = rest.trim_matches(['/', '\\']); + if let Some(windows) = windows_browser_path(path) { + let rest = windows.normalized_rest(); if rest.is_empty() { - return None; // drive root + return None; } - let parent = rest.rfind(['/', '\\']).map_or("", |at| &rest[..at]); - return Some(format!("{drive}\\{parent}")); + let parent = rest.rfind('\\').map_or("", |at| &rest[..at]); + return Some(format!("{}{parent}", windows.root_path())); } let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { @@ -281,7 +425,7 @@ pub fn parent_path(path: &str) -> Option { pub fn child_path(base: &str, name: &str) -> String { if base.ends_with(['/', '\\']) { format!("{base}{name}") - } else if is_windows_path(base) { + } else if windows_browser_path(base).is_some() { format!("{base}\\{name}") } else { format!("{base}/{name}") @@ -340,17 +484,29 @@ pub fn is_typed_path(query: &str) -> bool { /// yet. A query like `~foo` is a folder name, not a path. pub fn typed_path_target(query: &str, home: Option<&str>) -> Option { let query = query.trim(); - if is_windows_path(query) { - let path = query.replace('/', "\\"); - let trimmed = path.trim_end_matches('\\'); - // `D:` and `D:\` both mean the drive root. - return Some(if trimmed.len() == 2 { - format!("{trimmed}\\") + if let Some(windows) = windows_browser_path(query) { + let rest = windows.normalized_rest(); + return Some(if rest.is_empty() { + windows.root_path() } else { - trimmed.to_string() + format!("{}{rest}", windows.root_path()) }); } if let Some(rest) = query.strip_prefix('~') { + if is_windows_path(home?) { + let home = typed_path_target(home?, None)?; + if rest.is_empty() { + return Some(home); + } + let rest = rest + .strip_prefix(['/', '\\'])? + .trim_end_matches(['/', '\\']); + return Some(if rest.is_empty() { + home + } else { + child_path(&home, &rest.replace('/', "\\")) + }); + } let home = home?.trim_end_matches('/'); if rest.is_empty() { return Some(home.to_string()); @@ -375,17 +531,25 @@ pub fn typed_path_target(query: &str, home: Option<&str>) -> Option { /// Breadcrumb segments for a path: `(label, full path)`, root first. pub fn breadcrumbs(path: &str) -> Vec<(String, String)> { - let (drive, sep, rest) = if is_windows_path(path) { - let (drive, rest) = path.split_at(2); - (drive, '\\', rest) - } else { - ("", '/', path) - }; - let root = format!("{drive}{sep}"); + if let Some(windows) = windows_browser_path(path) { + let root = windows.root_path(); + let mut out = vec![(root.clone(), root.clone())]; + let mut acc = root; + for segment in windows + .rest + .split(|ch| windows.is_separator(ch)) + .filter(|s| !s.is_empty()) + { + acc = child_path(&acc, segment); + out.push((segment.to_owned(), acc.clone())); + } + return out; + } + let root = "/".to_string(); let mut out = vec![(root.clone(), root)]; - let mut acc = drive.to_string(); - for segment in rest.split(['/', sep]).filter(|s| !s.is_empty()) { - acc.push(sep); + let mut acc = String::new(); + for segment in path.split('/').filter(|s| !s.is_empty()) { + acc.push('/'); acc.push_str(segment); out.push((segment.to_string(), acc.clone())); } @@ -9354,6 +9518,111 @@ mod tests { assert!(!is_windows_path("ab:/x")); } + #[test] + fn extended_and_unc_folder_navigation_preserves_roots() { + for root in [r"\\?\D:\", r"\\server\share\", r"\\?\UNC\server\share\"] { + let folder = format!("{root}projects\\comet"); + let parent = format!("{root}projects"); + assert_eq!(parent_path(&folder), Some(parent.clone())); + assert_eq!(parent_path(&parent), Some(root.to_owned())); + assert_eq!(parent_path(root), None); + assert_eq!(child_path(&parent, "comet"), folder); + let crumbs = breadcrumbs(&folder); + assert_eq!( + crumbs, + vec![ + (root.to_owned(), root.to_owned()), + ("projects".into(), parent), + ("comet".into(), folder.clone()), + ] + ); + assert!(is_typed_path(&folder)); + assert_eq!( + typed_path_target(&format!("{folder}\\"), None), + Some(folder) + ); + assert_eq!(typed_path_target(root, None), Some(root.to_owned())); + } + // The server alone isn't a browsable share, and device namespaces + // must not be mistaken for a server/share pair. + assert!(!is_windows_path(r"\\server")); + assert!(!is_windows_path(r"\\server\\repo")); + assert!(!is_windows_path(r"\\.\C:\repo")); + assert!(!is_windows_path(r"\\?\Volume{123}\repo")); + } + + #[test] + fn windows_folder_identity_handles_prefixes_without_changing_posix_names() { + assert!(same_folder_path( + r"C:\Users\Ana\repo", + r"\\?\C:\Users\Ana\repo" + )); + assert!(same_folder_path( + r"C:/Users/Ana/repo/", + r"\\?\C:\Users\Ana\repo" + )); + assert!(same_folder_path( + r"\\server\share\repo", + r"\\?\UNC\server\share\repo" + )); + assert!(!same_folder_path( + r"\\server\share\repo", + r"\\server\other\repo" + )); + assert!(!same_folder_path(r"C:\repo", r"D:\repo")); + assert!(!same_folder_path(r"C:\Repo", r"C:\repo")); + // Only the drive letter is case-insensitive. + assert!(same_folder_path(r"c:\Users\Ana", r"C:\Users\Ana")); + assert!(same_folder_path(r"c:/Users/Ana/", r"\\?\C:\Users\Ana")); + assert!(same_folder_path(r"\\?\c:\repo.", r"\\?\C:\repo.")); + assert!(same_folder_path(r"d:\", r"D:\")); + assert!(!same_folder_path(r"c:\users\ana", r"C:\Users\Ana")); + // Extended paths can refer to names with different Win32 semantics. + for name in ["repo.", "repo ", "..", "NUL.txt", "COM¹"] { + assert!(!same_folder_path( + &format!(r"C:\{name}"), + &format!(r"\\?\C:\{name}") + )); + } + for path in [ + "/home/ana/repo", + "/Users/Ana/repo", + r"/tmp/name\with\backslashes", + "//server/share", + ] { + assert_eq!(folder_path_key(path), path); + } + assert!(!same_folder_path("/home/Ana", "/home/ana")); + } + + #[test] + fn home_relative_jumps_use_the_remote_windows_separator() { + for home in [ + r"C:\Users\Ana", + r"\\?\C:\Users\Ana", + r"\\server\share\Ana", + r"\\?\UNC\server\share\Ana", + ] { + assert_eq!( + typed_path_target("~/code/comet/", Some(home)), + Some(format!("{home}\\code\\comet")) + ); + assert_eq!( + typed_path_target(r"~\code\comet\", Some(home)), + Some(format!("{home}\\code\\comet")) + ); + } + assert_eq!( + typed_path_target("~/code", Some("/home/ana")), + Some("/home/ana/code".into()) + ); + assert_eq!( + typed_path_target("~/code", Some("/Users/Ana")), + Some("/Users/Ana/code".into()) + ); + assert_eq!(typed_path_target(r"~\code", Some("/home/ana")), None); + } + #[test] fn completion_prefix_lengths() { // Case-insensitive; the length indexes into the NAME's bytes. diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index ca4c86e461..3352cc8208 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1142,18 +1142,20 @@ pub fn key_hint_pair( .child(key_hint_label(theme, label)) } -/// A muted kbd hint chip inside menu rows (`⌘↵`-style accelerators). +/// The face shortcut glyphs are set in. The UI font has no ⇧ ⌥ ↵ (they fell +/// back to a mismatched face); the system font draws the platform's own. +pub const KEY_GLYPH_FONT: &str = ".SystemUIFont"; + +/// A shortcut legend (`⌘N`, `⇧⌘N`, `⌘1`): the sidebar's jump-hint voice +/// everywhere — plain muted text at the row's subline size, no chip. pub fn kbd_hint(theme: &Theme, label: &str) -> gpui::Div { let theme = &theme.for_popup(); div() .flex_none() - .px(px(5.0)) - .py(px(1.0)) - .rounded(px(5.0)) - .bg(ink(0.05)) - .text_size(crate::typography::ui_rems(10.0)) - .font_family(theme.font_mono.clone()) - .text_color(theme.text_muted) + .whitespace_nowrap() + .font_family(KEY_GLYPH_FONT) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted.opacity(0.5)) .child(SharedString::from(label.to_string())) } @@ -2471,10 +2473,21 @@ pub(crate) fn search_highlight( return text.into_any_element(); }; let ranges = search_match_ranges(&text, query); - if ranges.is_empty() { + search_highlight_ranges(text, ranges, theme) +} + +/// [`search_highlight`] over byte ranges the caller already knows (a +/// content search's matches). Ranges that are empty, out of bounds or off +/// a character boundary are skipped. +pub(crate) fn search_highlight_ranges( + text: SharedString, + ranges: Vec>, + theme: &Theme, +) -> AnyElement { + let badges = valid_highlight_ranges(&text, ranges); + if badges.is_empty() { return text.into_any_element(); } - let badges = ranges; let styled = gpui::StyledText::new(text).with_highlights(badges.iter().cloned().map(|range| { ( range, @@ -2517,6 +2530,21 @@ pub(crate) fn search_highlight( .into_any_element() } +fn valid_highlight_ranges( + text: &str, + ranges: Vec>, +) -> Vec> { + ranges + .into_iter() + .filter(|range| { + range.start < range.end + && range.end <= text.len() + && text.is_char_boundary(range.start) + && text.is_char_boundary(range.end) + }) + .collect() +} + fn search_match_ranges(text: &str, query: &str) -> Vec> { let folded = text.to_lowercase(); let mut original = Vec::with_capacity(folded.len()); @@ -2548,7 +2576,16 @@ fn search_match_ranges(text: &str, query: &str) -> Vec> { #[cfg(test)] mod search_highlight_tests { - use super::search_match_ranges; + use super::{search_match_ranges, valid_highlight_ranges}; + + #[test] + fn explicit_ranges_skip_empty_out_of_bounds_and_split_characters() { + let text = "café needle"; + assert_eq!( + valid_highlight_ranges(text, vec![6..12, 4..4, 3..4, 6..40, 0..3]), + vec![6..12, 0..3] + ); + } #[test] fn inline_matches_keep_adjacent_word_boundaries() { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index a643447a6a..a8e072c6be 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -29,7 +29,9 @@ use zeron_rpc::methods; use crate::changes::{Changes, ChangesEvent, DiscardWorkingTreeRequest}; use crate::composer::{Composer, ComposerEvent, ComposerInput, ComposerInputEvent}; -use crate::files::{FilesCloseDisposition, FilesEvent, FilesSurface, WorkspacePathDrag}; +use crate::files::{ + FileColumn, FilesCloseDisposition, FilesEvent, FilesSurface, WorkspacePathDrag, +}; use crate::icons::{self, icon}; use crate::loaders; use crate::motion::{ @@ -83,7 +85,9 @@ mod session_info; mod side_chats; mod sidebar_pins; mod sidebar_sections; +mod search_warm; pub(crate) mod spaces; +mod thread_search; use side_chats::SideChatTab; mod tabs; mod voice_stage; @@ -301,6 +305,13 @@ fn titlebar_new_session_alpha(route: &Route, has_selected_chat: bool) -> f32 { #[action(namespace = shell, no_json)] pub struct JumpSession(pub usize); +/// Show the command palette's tab at this (zero-based) position. Bound to +/// mod-1…4 inside the palette's key context only, where it outranks the +/// session jumps on the same keys while the palette is open. +#[derive(Clone, PartialEq, Action)] +#[action(namespace = shell, no_json)] +pub struct SelectPaletteTab(pub usize); + // --------------------------------------------------------------------------- // Traffic-light-aware titlebar layout (feature-inventory §1.1) // --------------------------------------------------------------------------- @@ -504,6 +515,21 @@ pub fn apply_keymap( None, )) })); + // Context-free bindings (the jumps above) match at the full focus depth, + // so a palette binding must too: one for the palette's focused search + // input, one for the card itself. Bound after the jumps, they win the tie. + let palette_input = format!("{} > PaletteSearch", command_palette::KEY_CONTEXT); + cx.bind_keys((0..command_palette::Tab::ALL.len()).flat_map(|ix| { + let combo = platform_combo(&format!("mod-{}", ix + 1)); + [ + KeyBinding::new(&combo, SelectPaletteTab(ix), Some(&palette_input)), + KeyBinding::new( + &combo, + SelectPaletteTab(ix), + Some(command_palette::KEY_CONTEXT), + ), + ] + })); } /// The settings sections (feature-inventory §1.5 routes). @@ -2074,6 +2100,10 @@ pub struct Shell { /// The New project palette's collapsed-breadcrumbs (`…`) menu. project_crumb_menu: popover::Popup<()>, command_palette: Option, + /// The focus whose workspace search index was last warmed (see `search_warm`). + search_warm: Option, + /// Per chat, the files opened in editor tabs, most recent first. + recent_files: std::collections::HashMap>, pending_workspace_command: Option, /// The sidebar's space-filter dropdown. spaces_menu: popover::Popup, @@ -2560,6 +2590,8 @@ impl Shell { add_space: None, project_crumb_menu: popover::Popup::default(), command_palette: None, + search_warm: None, + recent_files: std::collections::HashMap::new(), pending_workspace_command: None, spaces_menu: popover::Popup::default(), spaces_menu_bar: popover::MenuScrollbarState::default(), @@ -2737,6 +2769,7 @@ impl Shell { } self.prune_file_explorers(cx); self.refresh_harness_update_watch(cx); + self.sync_search_warm(cx); if state.read(cx).engine().is_none() { self.side_chats.clear(); self.side_chat_creating = false; @@ -3569,6 +3602,10 @@ impl Shell { RightSurface::File(id) => { if let Some(file) = self.file_surfaces.get(&id).cloned() { file.update(cx, |file, cx| file.ensure_loaded(cx)); + if let Some(path) = self.file_surface_paths.get(&id).cloned() { + let chat_id = file.read(cx).chat_id().to_owned(); + self.note_recent_file(&chat_id, &path); + } } } RightSurface::Terminal(tab) => { @@ -3858,7 +3895,7 @@ impl Shell { &mut self, owner: (String, Entity), path: String, - location: Option<(u32, Option)>, + location: Option<(u32, Option)>, window: &mut Window, cx: &mut Context, ) { @@ -4054,7 +4091,8 @@ impl Shell { self.add_file_surface_at( owner, link.path, - link.line.map(|line| (line, link.column)), + link.line + .map(|line| (line, link.column.map(FileColumn::Character))), window, cx, ); @@ -5290,7 +5328,8 @@ impl Shell { self.set_route(Route::PullRequests, cx); self.close_user_menu(cx); self.close_chat_menu(cx); - self.add_space = None; + // Through `close_add_space`, so the host's home index is released. + self.close_add_space(cx); self.close_right_plus(cx); if self.spaces_menu.begin_close() { popover::reap_popup(cx, |shell: &mut Self| &mut shell.spaces_menu); @@ -8067,30 +8106,16 @@ impl Shell { let compact_jump_label = compact.then(|| jump_label.clone()).flatten(); let corner_body: AnyElement = if let Some(label) = jump_label.filter(|_| !compact) { // The jump hint replaces the status/time corner while the modifier - // is held, cut to the sidebar PR badge's exact cloth - // (`pull_request_badge`, Sidebar surface): pinned 16px, px 4, - // rounded 4, borderless 0.08-fill with 0.85 text of one tone — - // neutral here — and the label in the badge's mono at 10 MEDIUM. - // Any other geometry reads as a second badge system on the row. - { - let tone = theme.text_muted; - div() - .h(px(16.0)) - .flex_none() - .flex() - .flex_row() - .items_center() - .px(px(4.0)) - .rounded(px(4.0)) - .bg(tone.opacity(0.08)) - .whitespace_nowrap() - .text_size(crate::typography::ui_rems(10.0)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(tone.opacity(0.85)) - .font_family(theme.font_mono.clone()) - .child(label) - .into_any_element() - } + // is held, in the shared legend voice (the compact rows' plain + // muted text, `popover::kbd_hint`). + div() + .flex_none() + .whitespace_nowrap() + .font_family(crate::popover::KEY_GLYPH_FONT) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted.opacity(0.5)) + .child(label) + .into_any_element() } else if corner_hovered { div() .flex() @@ -10389,8 +10414,7 @@ impl Shell { return true; } if self.add_space.is_some() { - self.add_space = None; - cx.notify(); + self.close_add_space(cx); return true; } if self.spaces_menu.is_open() { @@ -13746,8 +13770,7 @@ impl Render for Shell { return; } if this.add_space.is_some() { - this.add_space = None; - cx.notify(); + this.close_add_space(cx); } else { this.open_add_space(cx); } @@ -14078,8 +14101,7 @@ impl Render for Shell { // scheduling `with_animation` would have requested). Hover color fades // ride the same clock; their once-per-frame tick lives here (this is // the window's root render — it runs exactly once per frame). - if self.motion_active.get() | motion::hover_fades_active() | motion::state_morphs_active() - { + if self.motion_active.get() | motion::hover_fades_active() | motion::state_morphs_active() { window.request_animation_frame(); } diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 6798adfe2e..74a89412ee 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -1,23 +1,172 @@ -//! Global action and conversation search, using the sidebar's conversation rows. +//! Global search over actions, conversations and the focused chat's +//! workspace, using the sidebar's conversation rows. Results are grouped in +//! sections; tabs narrow them to one kind. use super::*; use crate::appearance::AppearanceMode; +use crate::files::client::{FilesClientError, FilesRequestContext, WorkspaceFilesClient}; +use zeron_proto::{ + SearchWorkspaceContentRequest, SearchWorkspaceFilesRequest, WorkspaceContentMatch, + WorkspaceEntryKind, WorkspaceFileSearchMatch, WorkspaceSearchIndexState, WorkspaceTarget, +}; -const HISTORY_RESULT_LIMIT: usize = 30; +/// Key context of the palette card: its mod-1…4 tab bindings live here. +pub(super) const KEY_CONTEXT: &str = "CommandPalette"; +/// Rows a section shows under All; its own tab shows the long limit. +const ALL_TAB_LIMIT: usize = 5; +const THREADS_TAB_LIMIT: usize = 50; +const FILES_TAB_LIMIT: usize = 50; +const CONTENT_TAB_LIMIT: usize = 100; +const CONTENT_MATCHES_PER_FILE: usize = 3; +/// Shorter workspace queries match nearly everything; they are not sent. +const MIN_WORKSPACE_QUERY_CHARS: usize = 2; +/// Content search waits for typing to pause; name search does not. +const CONTENT_DEBOUNCE: Duration = Duration::from_millis(80); +/// Refresh partial results until the host finishes its initial scan, +/// doubling the wait up to [`CONTENT_INDEX_RETRY_MAX`]: without a content +/// index every refresh is a full grep, and a large folder can scan for long. +const CONTENT_INDEX_RETRY: Duration = Duration::from_millis(500); +const CONTENT_INDEX_RETRY_MAX: Duration = Duration::from_secs(2); +const FILE_INDEX_RETRY: Duration = Duration::from_millis(500); const RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { search: Entity, focus: FocusHandle, previous_focus: Option, - active: usize, + tab: Tab, + /// The highlighted row, kept by identity so results arriving around it + /// never move it; `active_index` is where it was, for when it vanishes. + active: Option, + active_index: usize, enter_press: EnterPress, // Claim focus during mount so the shell does not restore the composer // while this input is still absent from the dispatch tree. focus_pending: bool, scroll: gpui::ScrollHandle, + /// File-name matches in the focused chat's workspace. + files: WorkspaceQuery>, + /// The workspace whose index a probe found ready during this palette + /// session: name searches there skip the readiness probe, saving a round + /// trip per keystroke on remote hosts. + files_ready: Option, + /// Content matches in the focused chat's workspace. + content: WorkspaceQuery>, _search_events: Subscription, } +/// An asynchronous workspace section's request state. Rows from the last +/// answer stay up while a newer query runs. +#[derive(Default)] +struct WorkspaceQuery { + /// Bumped per request; an answer to an older one is dropped. + generation: u64, + loading: bool, + /// The host's index was still scanning at the last answer. + indexing: bool, + error: Option, + results: T, + task: Option>, +} + +impl WorkspaceQuery { + /// Forget everything, cancelling any request in flight. + fn clear(&mut self) { + self.generation += 1; + *self = Self { + generation: self.generation, + ..Self::default() + }; + } + + /// Start a request; the returned generation identifies its answer. + fn begin(&mut self) -> u64 { + self.generation += 1; + self.loading = true; + self.generation + } + + /// [`Self::status`] without failures: a default list that a host can't + /// serve (one that predates empty-query search) quietly shows nothing. + fn progress(&self) -> Option { + if self.indexing { + Some("Indexing…".into()) + } else if self.loading { + Some("Searching…".into()) + } else { + None + } + } + + /// Header status while loading, while the host indexes, or after a + /// failure. + fn status(&self) -> Option { + if self.indexing { + Some("Indexing…".into()) + } else if self.loading { + Some("Searching…".into()) + } else { + self.error.clone() + } + } + + /// Settle the request `generation` with its answer; `false` when a newer + /// request superseded it. + fn finish( + &mut self, + generation: u64, + result: Result<(T, bool), FilesClientError>, + what: &str, + ) -> bool { + if self.generation != generation { + return false; + } + self.loading = false; + match result { + Ok((results, indexing)) => { + self.error = None; + self.indexing = indexing; + self.results = results; + } + Err(error) => { + tracing::warn!(%error, "palette {what} search failed"); + self.error = Some(search_error_message(&error, what)); + self.indexing = false; + self.results = T::default(); + } + } + true + } +} + +/// The focused chat's workspace connection, when it has one. +fn palette_workspace(shell: &Shell, cx: &App) -> Option { + if shell.active_chat.is_empty() { + return None; + } + let state = shell.state.read(cx); + let context = FilesRequestContext::for_chat(state, &shell.active_chat)?; + Some(WorkspaceFilesClient::new(state.engine()?.clone(), context)) +} + +/// `what` ("file" or "content") names the search in the message. +fn search_error_message(error: &FilesClientError, what: &str) -> SharedString { + match error { + FilesClientError::Unsupported(_) => { + let subject = if what == "content" { + "file contents" + } else { + "files" + }; + format!("The session's device runs an older zeron — update it to search its {subject}") + .into() + } + FilesClientError::Transport(_) => "The session's device is unreachable".into(), + FilesClientError::Encode(_) + | FilesClientError::Decode(_) + | FilesClientError::Request(_) => "Search failed".into(), + } +} + // X11 suppresses synthetic repeat releases but sends repeated keydowns with // is_held=false. Keep our own latch until the physical key is released. #[derive(Default)] @@ -36,32 +185,171 @@ impl EnterPress { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Tab { + #[default] + All, + Threads, + Commands, + Files, +} + +impl Tab { + pub(super) const ALL: [Tab; 4] = [Tab::All, Tab::Threads, Tab::Commands, Tab::Files]; + + fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Threads => "Threads", + Self::Commands => "Commands", + Self::Files => "Files", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|tab| *tab == self).unwrap_or(0) + } + + /// The neighbouring tab, wrapping (tab / shift-tab). + fn step(self, forward: bool) -> Self { + let count = Self::ALL.len(); + let next = if forward { + self.index() + 1 + } else { + self.index() + count - 1 + }; + Self::ALL[next % count] + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Section { + RecentFiles, + QuickActions, + RecentThreads, + Threads, + Commands, + Files, + InFiles, +} + +impl Section { + fn title(self) -> &'static str { + match self { + Self::RecentFiles => "Recent Files", + Self::QuickActions => "Quick Actions", + Self::RecentThreads => "Recent Threads", + Self::Threads => "Threads", + Self::Commands => "Commands", + Self::Files => "Files", + Self::InFiles => "In Files", + } + } +} + #[derive(Clone, Debug, PartialEq)] -enum Entry { +enum Command { NewChat, NewProject, Settings, Theme(AppearanceMode), - Chat(String), + ArchiveThread, } -impl Entry { - fn action(&self) -> Option<(&'static str, &'static str)> { +impl Command { + fn label(&self) -> &'static str { match self { - Self::NewChat => Some(("New chat", icons::PEN_NEW_SQUARE)), - Self::NewProject => Some(("New project", icons::FOLDER)), - Self::Settings => Some(("Open settings", icons::SETTINGS)), - Self::Theme(mode) => Some(( - match mode { - AppearanceMode::System => "Switch to system theme", - AppearanceMode::Light => "Switch to light theme", - AppearanceMode::Dark => "Switch to dark theme", - }, - mode.icon(), - )), - Self::Chat(_) => None, + Self::NewChat => "New chat", + Self::NewProject => "New project", + Self::Settings => "Open settings", + Self::Theme(AppearanceMode::System) => "Switch to system theme", + Self::Theme(AppearanceMode::Light) => "Switch to light theme", + Self::Theme(AppearanceMode::Dark) => "Switch to dark theme", + Self::ArchiveThread => "Archive thread", + } + } + + fn icon(&self) -> &'static str { + match self { + Self::NewChat => icons::PEN_NEW_SQUARE, + Self::NewProject => icons::FOLDER, + Self::Settings => icons::SETTINGS, + Self::Theme(mode) => mode.icon(), + Self::ArchiveThread => icons::ARCHIVE_MINIMALISTIC, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum RowKind { + Command(Command), + Chat(String), + /// A path in the focused chat's workspace. + File { + path: String, + is_dir: bool, + }, + /// A line of a file in the focused chat's workspace. + Content(WorkspaceContentMatch), +} + +#[derive(Clone, Debug, PartialEq)] +struct PaletteRow { + section: Section, + kind: RowKind, +} + +/// A row's identity across result refreshes. +#[derive(Clone, Debug, PartialEq, Eq)] +struct RowKey(Section, String); + +impl PaletteRow { + fn new(section: Section, kind: RowKind) -> Self { + Self { section, kind } + } + + fn key(&self) -> RowKey { + let id = match &self.kind { + RowKind::Command(command) => format!("command:{}", command.label()), + RowKind::Chat(id) => format!("chat:{id}"), + RowKind::File { path, .. } => format!("file:{path}"), + RowKind::Content(found) => { + format!("content:{}:{}:{}", found.path, found.line, found.column) + } + }; + RowKey(self.section, id) + } +} + +/// One titled group of rows, already limited for the tab. +#[derive(Clone, Debug, PartialEq)] +struct PaletteSection { + section: Section, + rows: Vec, + /// Shown beside the title: "Searching…", or why the search failed. + status: Option, +} + +impl PaletteSection { + fn new(section: Section, kinds: impl IntoIterator) -> Self { + Self { + section, + rows: kinds + .into_iter() + .map(|kind| PaletteRow::new(section, kind)) + .collect(), + status: None, } } + + fn with_status(mut self, status: Option) -> Self { + self.status = status; + self + } + + fn is_empty(&self) -> bool { + self.rows.is_empty() && self.status.is_none() + } } fn matches_query(query: &str, text: &str) -> bool { @@ -69,22 +357,89 @@ fn matches_query(query: &str, text: &str) -> bool { query.split_whitespace().all(|word| text.contains(word)) } -fn actions_for(query: &str, is_dark: bool) -> Vec { +/// `can_archive`: a focused, unarchived chat exists for Archive thread. +fn commands_for(query: &str, is_dark: bool, can_archive: bool) -> Vec { [ - Entry::NewChat, - Entry::NewProject, - Entry::Settings, - Entry::Theme(if is_dark { + Command::NewChat, + Command::NewProject, + Command::Settings, + Command::Theme(if is_dark { AppearanceMode::Light } else { AppearanceMode::Dark }), ] .into_iter() - .filter(|entry| matches_query(query, entry.action().unwrap().0)) + .chain(can_archive.then_some(Command::ArchiveThread)) + .filter(|command| matches_query(query, command.label())) .collect() } +/// A path's last segment and the folder holding it (empty at the root). +fn split_path(path: &str) -> (&str, &str) { + let trimmed = path.trim_end_matches('/'); + match trimmed.rsplit_once('/') { + Some((parent, name)) => (name, parent), + None => (trimmed, ""), + } +} + +/// The scroll container's child index of flat row `row_ix`: each section +/// mounts a header before its rows. +fn child_index(sections: &[PaletteSection], row_ix: usize) -> usize { + let mut rows_before = 0; + for (section_ix, section) in sections.iter().enumerate() { + if row_ix < rows_before + section.rows.len() { + return row_ix + section_ix + 1; + } + rows_before += section.rows.len(); + } + row_ix + sections.len() +} + +/// The child to scroll into view for row `row_ix`: its section header when +/// it is the section's first row, so the header stays visible with it. +fn scroll_child_index(sections: &[PaletteSection], row_ix: usize) -> usize { + let mut rows_before = 0; + for (section_ix, section) in sections.iter().enumerate() { + if row_ix < rows_before + section.rows.len() { + let child = row_ix + section_ix + 1; + return if row_ix == rows_before { child - 1 } else { child }; + } + rows_before += section.rows.len(); + } + child_index(sections, row_ix) +} + +/// Navigation order: every row of every section, headers skipped. +fn flat_rows(sections: &[PaletteSection]) -> Vec<&PaletteRow> { + sections.iter().flat_map(|section| §ion.rows).collect() +} + +/// Where the row `active` sits in `rows`: found by identity when still +/// listed, else `fallback` clamped. +fn row_position(active: Option<&RowKey>, fallback: usize, rows: &[&PaletteRow]) -> usize { + active + .and_then(|key| rows.iter().position(|row| &row.key() == key)) + .unwrap_or_else(|| fallback.min(rows.len().saturating_sub(1))) +} + +impl CommandPalette { + fn active_position(&self, rows: &[&PaletteRow]) -> usize { + row_position(self.active.as_ref(), self.active_index, rows) + } + + fn set_active(&mut self, rows: &[&PaletteRow], index: usize) { + self.active_index = index; + self.active = rows.get(index).map(|row| row.key()); + } + + fn reset_active(&mut self) { + self.active = None; + self.active_index = 0; + } +} + impl Shell { pub(super) fn reset_command_palette_key_state(&mut self) { if let Some(palette) = self.command_palette.as_mut() { @@ -97,16 +452,17 @@ impl Shell { self.close_command_palette(window, cx); return; } - self.add_space = None; + self.close_add_space(cx); let search = cx.new(|cx| { - ComposerInput::with_context("Search commands and chats…", "PaletteSearch", cx) + ComposerInput::with_context("Search threads, commands and files…", "PaletteSearch", cx) }); let events = cx.subscribe(&search, |this, _, event, cx| { if matches!(event, ComposerInputEvent::Edited) { if let Some(palette) = this.command_palette.as_mut() { - palette.active = 0; + palette.reset_active(); palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); } + this.search_palette_workspace(cx); cx.notify(); } }); @@ -115,15 +471,34 @@ impl Shell { search, focus: cx.focus_handle(), previous_focus, - active: 0, + tab: Tab::All, + active: None, + active_index: 0, enter_press: EnterPress::default(), focus_pending: true, scroll: gpui::ScrollHandle::new(), + files: WorkspaceQuery::default(), + files_ready: None, + content: WorkspaceQuery::default(), _search_events: events, }); + // Load the default Files list (recently changed files) right away. + self.search_palette_workspace(cx); cx.notify(); } + pub(super) fn select_palette_tab(&mut self, tab: Tab, cx: &mut Context) { + let Some(palette) = self.command_palette.as_mut() else { + return; + }; + if palette.tab != tab { + palette.tab = tab; + palette.reset_active(); + palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); + cx.notify(); + } + } + pub(super) fn close_command_palette(&mut self, window: &mut Window, cx: &mut Context) { if let Some(palette) = self.command_palette.take() { if let Some(focus) = palette.previous_focus { @@ -133,29 +508,267 @@ impl Shell { } } - fn command_entries(&self, cx: &App) -> Vec { + /// Look the query up in the focused workspace: file names at once, + /// contents once typing pauses. + fn search_palette_workspace(&mut self, cx: &mut Context) { + let workspace = palette_workspace(self, cx); + let Some(palette) = self.command_palette.as_mut() else { + return; + }; + let query = palette.search.read(cx).text().trim().to_owned(); + // An empty query lists the workspace's recent files (the default + // Files list); a single character is too broad to search. + let Some(client) = + workspace.filter(|_| query.is_empty() || query.chars().count() >= MIN_WORKSPACE_QUERY_CHARS) + else { + palette.files.clear(); + palette.content.clear(); + return; + }; + + let generation = palette.files.begin(); + let request = SearchWorkspaceFilesRequest { + target: client.target().clone(), + query: query.clone(), + include_ignored: false, + limit: Some(FILES_TAB_LIMIT as u16), + }; + let names = client.clone(); + let target = client.target().clone(); + // The default list is a plain request: readiness is probed for typed + // queries, whose partial answers are worth retrying. + let mut probe = !request.query.is_empty() && palette.files_ready.as_ref() != Some(&target); + palette.files.task = Some(cx.spawn(async move |this, cx| { + loop { + let mut ready = false; + let result = async { + // SearchWorkspaceFiles keeps its list-shaped wire response. + // Check readiness BEFORE searching: a scan finishing after + // a partial answer must still trigger one final search. + // Once ready, the index stays built while in use, so later + // keystrokes go straight to the search. + let indexing = if probe { + match names.warm_search().await { + Ok(warm) => warm.state == WorkspaceSearchIndexState::Building, + // Older hosts search without the new index/warm method. + Err(FilesClientError::Unsupported(_)) => false, + Err(error) => return Err(error), + } + } else { + false + }; + ready = probe && !indexing; + names + .search(request.clone()) + .await + .map(|found| (found, indexing)) + } + .await; + probe &= !ready; + let target = target.clone(); + let retry = this + .update(cx, |shell, cx| { + let Some(palette) = shell.command_palette.as_mut() else { + return false; + }; + if ready { + palette.files_ready = Some(target); + } + if !palette.files.finish(generation, result, "file") { + return false; + } + cx.notify(); + palette.files.indexing + }) + .unwrap_or(false); + if !retry { + break; + } + // Keep partial rows and selection; replacing the query or + // closing the palette drops this task and cancels retries. + cx.background_executor().timer(FILE_INDEX_RETRY).await; + } + })); + + if query.is_empty() { + palette.content.clear(); + return; + } + let generation = palette.content.begin(); + let request = SearchWorkspaceContentRequest { + target: client.target().clone(), + query, + limit: Some(CONTENT_TAB_LIMIT as u16), + per_file_limit: Some(CONTENT_MATCHES_PER_FILE as u16), + }; + palette.content.task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CONTENT_DEBOUNCE).await; + let mut delay = CONTENT_INDEX_RETRY; + loop { + let result = client + .search_content(request.clone()) + .await + .map(|found| (found.matches, found.indexing)); + let retry = this + .update(cx, |shell, cx| { + let Some(palette) = shell.command_palette.as_mut() else { + return false; + }; + if !palette.content.finish(generation, result, "content") { + return false; + } + cx.notify(); + palette.content.indexing + }) + .unwrap_or(false); + if !retry { + break; + } + // Keep partial rows and their selection visible. Replacing the + // query or closing the palette drops this task, cancelling the + // wait as well as any in-flight request. + cx.background_executor().timer(delay).await; + delay = (delay * 2).min(CONTENT_INDEX_RETRY_MAX); + } + })); + } + + /// The open palette's sections for its tab and query. + fn palette_sections(&self, cx: &App) -> Vec { let Some(palette) = &self.command_palette else { return Vec::new(); }; let query = palette.search.read(cx).text().trim().to_lowercase(); - let mut entries = actions_for(&query, Theme::of(cx).appearance.is_dark()); + let can_archive = self.state.read(cx).archivable_selected_chat().is_some(); + let commands = |section| { + PaletteSection::new( + section, + commands_for(&query, Theme::of(cx).appearance.is_dark(), can_archive) + .into_iter() + .map(RowKind::Command), + ) + }; + let threads = |limit| { + PaletteSection::new( + Section::Threads, + self.thread_matches(&query, limit, cx) + .into_iter() + .map(RowKind::Chat), + ) + }; + let recent_threads = |limit| { + PaletteSection::new( + Section::RecentThreads, + self.recent_threads(limit, cx) + .into_iter() + .map(RowKind::Chat), + ) + }; + // Files opened here first, then the workspace's recently changed + // files (the host's empty-query list), each path once. + let recent_files = |limit| { + let mut seen = std::collections::HashSet::new(); + let opened = self.recent_files(&self.active_chat).map(|path| (path.clone(), false)); + let changed = palette + .files + .results + .iter() + .filter(|found| found.kind != WorkspaceEntryKind::Directory) + .map(|found| (found.path.clone(), false)); + PaletteSection::new( + Section::RecentFiles, + opened + .chain(changed) + .filter(|(path, _)| seen.insert(path.clone())) + .take(limit) + .map(|(path, is_dir)| RowKind::File { path, is_dir }), + ) + .with_status(palette.files.progress()) + }; + let files = |limit| { + PaletteSection::new( + Section::Files, + palette + .files + .results + .iter() + .take(limit) + .map(|found| RowKind::File { + path: found.path.clone(), + is_dir: found.kind == WorkspaceEntryKind::Directory, + }), + ) + .with_status(palette.files.status()) + }; + let in_files = |limit| { + PaletteSection::new( + Section::InFiles, + palette + .content + .results + .iter() + .take(limit) + .cloned() + .map(RowKind::Content), + ) + .with_status(palette.content.status()) + }; + let sections = match (palette.tab, query.is_empty()) { + (Tab::All, true) => vec![ + recent_files(ALL_TAB_LIMIT), + commands(Section::QuickActions), + recent_threads(ALL_TAB_LIMIT), + ], + (Tab::All, false) => vec![ + threads(ALL_TAB_LIMIT), + commands(Section::Commands), + files(ALL_TAB_LIMIT), + in_files(ALL_TAB_LIMIT), + ], + (Tab::Threads, true) => vec![recent_threads(THREADS_TAB_LIMIT)], + (Tab::Threads, false) => vec![threads(THREADS_TAB_LIMIT)], + (Tab::Commands, true) => vec![commands(Section::QuickActions)], + (Tab::Commands, false) => vec![commands(Section::Commands)], + (Tab::Files, true) => vec![recent_files(FILES_TAB_LIMIT)], + (Tab::Files, false) => vec![files(FILES_TAB_LIMIT), in_files(CONTENT_TAB_LIMIT)], + }; + sections + .into_iter() + .filter(|section| !section.is_empty()) + .collect() + } + + /// Unarchived top-level chats by latest activity, the focused one aside. + fn recent_threads(&self, limit: usize, cx: &App) -> Vec { + let state = self.state.read(cx); + let mut chats: Vec<_> = state + .chats + .iter() + .filter(|chat| chat.is_top_level() && !chat.archived && chat.id != self.active_chat) + .collect(); + chats + .sort_by_key(|chat| std::cmp::Reverse(chat.last_message_at.unwrap_or(chat.created_at))); + chats + .into_iter() + .take(limit) + .map(|chat| chat.id.clone()) + .collect() + } + + /// Top-level chats matching every word of `query`, best match first + /// (see `thread_search`). + fn thread_matches(&self, query: &str, limit: usize, cx: &App) -> Vec { let state = self.state.read(cx); + let terms = thread_search::search_terms(query); // Global history deliberately ignores the sidebar's project filter and // collapsed groups. Archived conversations remain searchable too. // Like the sidebar, only list top-level sessions, not side chats or MCP workers. - let mut chats: Vec<_> = state + let scored = state .chats .iter() .filter(|chat| chat.is_top_level()) - .filter(|chat| { - let project = state - .space_for_chat(chat) - .map(|s| s.display_name()) - .unwrap_or("~"); - let device = state.device_name(&chat.device_id).unwrap_or(""); - let branch = - crate::change_requests::conversation_branch(chat, &state.spaces).unwrap_or(""); - let pr = state + .filter_map(|chat| { + let pull_request = state .change_request_for_chat(chat) .map(|pr| { format!( @@ -164,110 +777,176 @@ impl Shell { ) }) .unwrap_or_default(); - matches_query( - &query, - &format!( - "{} {project} {device} {branch} {pr}", - chat.title.as_deref().unwrap_or("New session") - ), - ) + let fields = thread_search::ThreadFields { + title: chat.title.as_deref().unwrap_or("New session"), + project: state + .space_for_chat(chat) + .map(|s| s.display_name()) + .unwrap_or("~"), + branch: crate::change_requests::conversation_branch(chat, &state.spaces) + .unwrap_or(""), + device: state.device_name(&chat.device_id).unwrap_or(""), + pull_request: &pull_request, + archived: chat.archived, + }; + let score = thread_search::score_thread(&fields, &terms)?; + let active = chat.last_message_at.unwrap_or(chat.created_at); + Some((chat.id.clone(), score, active.timestamp_millis())) }) .collect(); - chats.sort_by(|a, b| spaces::compare_sidebar_chats(self.settings.sidebar_sort, a, b)); - // Limit after filtering and sorting so every chat remains searchable. - entries.extend( - chats - .into_iter() - .take(HISTORY_RESULT_LIMIT) - .map(|chat| Entry::Chat(chat.id.clone())), - ); - entries + thread_search::rank(scored, limit) } /// Pointer motion moves the highlight, so hover and keyboard never light /// two rows. Motion only: rows scrolling under a resting pointer must not /// steal the keyboard's place. - fn hover_command(&mut self, ix: usize, cx: &mut Context) { + fn hover_command(&mut self, key: RowKey, ix: usize, cx: &mut Context) { if let Some(palette) = self.command_palette.as_mut() - && palette.active != ix + && palette.active.as_ref() != Some(&key) { - palette.active = ix; + palette.active = Some(key); + palette.active_index = ix; cx.notify(); } } - fn activate_command(&mut self, entry: Entry, window: &mut Window, cx: &mut Context) { - if let Entry::Theme(mode) = entry { + fn move_palette_selection(&mut self, down: bool, cx: &mut Context) { + let sections = self.palette_sections(cx); + let rows = flat_rows(§ions); + let count = rows.len(); + let Some(palette) = self.command_palette.as_mut() else { + return; + }; + if count == 0 { + return; + } + let current = palette.active_position(&rows); + let next = if down { + (current + 1) % count + } else { + (current + count - 1) % count + }; + palette.set_active(&rows, next); + // A section's first row brings its header along, and the very first + // row returns to the top, so a header never hides above the edge. + if next == 0 { + palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); + } else { + palette + .scroll + .scroll_to_item(scroll_child_index(§ions, next)); + } + cx.notify(); + } + + fn activate_palette_selection(&mut self, window: &mut Window, cx: &mut Context) { + let sections = self.palette_sections(cx); + let rows = flat_rows(§ions); + let Some(row) = self + .command_palette + .as_ref() + .and_then(|palette| rows.get(palette.active_position(&rows))) + .map(|row| (*row).clone()) + else { + return; + }; + self.activate_palette_row(row, window, cx); + } + + fn activate_palette_row( + &mut self, + row: PaletteRow, + window: &mut Window, + cx: &mut Context, + ) { + if let RowKind::Command(Command::Theme(mode)) = row.kind { // Keep the palette open so this ordinary action updates to its next state. crate::appearance::set_mode(mode, cx); cx.notify(); return; } self.close_command_palette(window, cx); - match entry { - Entry::NewChat => self.open_new_session(None, cx), - Entry::NewProject => self.open_add_space(cx), - Entry::Settings => self.open_last_settings(cx), - Entry::Theme(_) => unreachable!(), - Entry::Chat(id) => self.open_chat(id, cx), + match row.kind { + RowKind::Command(Command::NewChat) => self.open_new_session(None, cx), + RowKind::Command(Command::NewProject) => self.open_add_space(cx), + RowKind::Command(Command::Settings) => self.open_last_settings(cx), + RowKind::Command(Command::Theme(_)) => unreachable!(), + RowKind::Command(Command::ArchiveThread) => self.archive_selected_chat(cx), + RowKind::Chat(id) => self.open_chat(id, cx), + RowKind::File { path, is_dir } => { + self.open_palette_path(path, is_dir, None, window, cx) + } + RowKind::Content(found) => { + let line = u32::try_from(found.line).unwrap_or(u32::MAX); + let column = Some(FileColumn::Byte(found.column)); + self.open_palette_path(found.path, false, Some((line, column)), window, cx) + } } } - pub(super) fn render_command_palette( + /// Open a workspace path of the focused chat: a file in an editor tab + /// (at `location`, when given), a folder revealed in the files tree. + fn open_palette_path( &mut self, - viewport: gpui::Size, + path: String, + is_dir: bool, + location: Option<(u32, Option)>, window: &mut Window, cx: &mut Context, - ) -> Option { - let entries = self.command_entries(cx); - let palette = self.command_palette.as_mut()?; - if std::mem::take(&mut palette.focus_pending) { - window.focus(&palette.search.focus_handle(cx), cx); + ) { + if self.active_chat.is_empty() { + return; } - palette.active = palette.active.min(entries.len().saturating_sub(1)); - let active = palette.active; - let search = palette.search.clone(); - let query = search.read(cx).text().to_string(); - let focus = palette.focus.clone(); - let scroll = palette.scroll.clone(); - let theme = Theme::of(cx).for_popup(); - let action_count = entries.iter().take_while(|e| e.action().is_some()).count(); - let mut rows = Vec::new(); - for (ix, entry) in entries.iter().enumerate() { - // End spacing belongs to the content, so it scrolls out of the - // fade instead of leaving a permanent gutter beside the chrome. - let mut row = div() - .id(("command-result", ix)) - .flex_none() - .on_mouse_move(cx.listener(move |this, _: &gpui::MouseMoveEvent, _, cx| { - this.hover_command(ix, cx) - })) - .when(ix == 0, |row| row.pt(px(8.0))) - .when(ix + 1 == entries.len(), |row| row.pb(px(8.0))); - if ix == action_count && action_count > 0 { - row = row.child(spaces::sidebar_separator(&theme).w_full().my(px(8.0))); + if is_dir { + self.add_files_surface(window, cx); + if let Some(files) = self.files.get(&self.panel_key(cx)).cloned() { + files.update(cx, |files, cx| files.reveal_file_explicit(path, cx)); } - let content = if let Some((label, glyph)) = entry.action() { - let shortcut = match entry { - Entry::NewChat | Entry::NewProject => { - let id = if *entry == Entry::NewChat { - ShortcutId::NewSession - } else { - ShortcutId::NewProject - }; - let combo = self.settings.keymap.get(id); - let valid = Keystroke::parse(&platform_combo(combo)).is_ok(); - Some(crate::settings::badge_combo(if valid { - combo - } else { - id.default_combo() - })) - } - Entry::Settings => Some(crate::settings::badge_combo("mod-,")), - _ => None, - }; - let entry = entry.clone(); - popover::menu_row(&theme, ix == active, format!("command-action-{ix}")) + return; + } + let key = self.panel_key(cx); + let was_open = self.panels.get(&key).changes_open; + let from = self.right_target(cx); + self.panels.update(&key, |panel| panel.changes_open = true); + if !was_open { + self.right_tween = Some(WidthTween::new(from, self.right_target(cx))); + } + let owner = (self.active_chat.clone(), self.state.clone()); + self.add_file_surface_at(owner, path, location, window, cx); + } + + fn command_shortcut(&self, command: &Command) -> Option { + let id = match command { + Command::NewChat => ShortcutId::NewSession, + Command::NewProject => ShortcutId::NewProject, + Command::Settings => return Some(crate::settings::badge_combo("mod-,")), + Command::ArchiveThread => ShortcutId::ArchiveSession, + Command::Theme(_) => return None, + }; + let combo = self.settings.keymap.get(id); + let valid = Keystroke::parse(&platform_combo(combo)).is_ok(); + Some(crate::settings::badge_combo(if valid { + combo + } else { + id.default_combo() + })) + } + + fn render_palette_row( + &mut self, + row: &PaletteRow, + ix: usize, + active: bool, + query: &str, + theme: &Theme, + cx: &mut Context, + ) -> Option { + Some(match &row.kind { + RowKind::Command(command) => { + let label = command.label(); + let shortcut = self.command_shortcut(command); + let target = row.clone(); + popover::menu_row(theme, active, format!("command-action-{ix}")) .id(("command-action", ix)) .rounded(px(popover::PALETTE_ITEM_RADIUS)) .role(gpui::Role::Button) @@ -275,24 +954,119 @@ impl Shell { .min_h(px(30.0)) .py(px(4.0)) .on_click(cx.listener(move |this, _, window, cx| { - this.activate_command(entry.clone(), window, cx) + this.activate_palette_row(target.clone(), window, cx) })) .child( - icon(glyph) + icon(command.icon()) .size(px(16.0)) .flex_none() .text_color(theme.text_muted), ) .child(div().flex_1().min_w_0().child(popover::search_highlight( label.into(), - Some(&query), - &theme, + Some(query), + theme, ))) .when_some(shortcut, |row, shortcut| { - row.child(popover::kbd_hint(&theme, &shortcut)) + row.child(popover::kbd_hint(theme, &shortcut)) }) .into_any_element() - } else if let Entry::Chat(id) = entry { + } + RowKind::File { path, is_dir } => { + let (name, parent) = split_path(path); + let identity = if *is_dir { + crate::file_icons::FileIconIdentity::directory(path, false) + } else { + crate::file_icons::FileIconIdentity::file(path) + }; + let target = row.clone(); + popover::menu_row(theme, active, format!("command-file-{ix}")) + .id(("command-file", ix)) + .rounded(px(popover::PALETTE_ITEM_RADIUS)) + .role(gpui::Role::Button) + .aria_label(SharedString::from(path.clone())) + .min_h(px(30.0)) + .py(px(4.0)) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_palette_row(target.clone(), window, cx) + })) + .child(crate::file_icons::icon(identity, theme.appearance).size(px(16.0))) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap(px(8.0)) + .overflow_hidden() + .child(div().flex_none().child(popover::search_highlight( + SharedString::from(name.to_owned()), + Some(query), + theme, + ))) + .when(!parent.is_empty(), |row| { + row.child( + div() + .min_w_0() + .truncate() + .text_color(theme.text_muted) + .child(SharedString::from(parent.to_owned())), + ) + }), + ) + .into_any_element() + } + RowKind::Content(found) => { + let target = row.clone(); + let ranges = found + .ranges + .iter() + .map(|(start, end)| *start as usize..*end as usize) + .collect(); + popover::menu_row(theme, active, format!("command-content-{ix}")) + .id(("command-content", ix)) + .rounded(px(popover::PALETTE_ITEM_RADIUS)) + .role(gpui::Role::Button) + .aria_label(SharedString::from(format!( + "{}:{} {}", + found.path, found.line, found.preview + ))) + .min_h(px(30.0)) + .py(px(4.0)) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_palette_row(target.clone(), window, cx) + })) + .child( + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file(&found.path), + theme.appearance, + ) + .size(px(16.0)), + ) + .child( + div() + .flex_none() + .max_w(px(200.0)) + .truncate() + .text_color(theme.text_muted) + .child(SharedString::from(format!("{}:{}", found.path, found.line))), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .font_family(theme.font_mono.clone()) + .child(popover::search_highlight_ranges( + SharedString::from(found.preview.clone()), + ranges, + theme, + )), + ) + .into_any_element() + } + RowKind::Chat(id) => { let state = self.state.read(cx); let chat = state.chats.iter().find(|chat| &chat.id == id)?; let project = match (state.space_for_chat(chat), chat.space_id.as_deref()) { @@ -332,20 +1106,82 @@ impl Shell { pr, harness, state.display_status_for(chat, Utc::now()), - ix == active, + active, chat.archived, false, None, None, false, - Some(&query), - &theme, + Some(query), + theme, cx, ) - } else { - unreachable!() - }; - rows.push(row.child(div().px(px(8.0)).child(content))); + } + }) + } + + pub(super) fn render_command_palette( + &mut self, + viewport: gpui::Size, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let sections = self.palette_sections(cx); + let palette = self.command_palette.as_mut()?; + if std::mem::take(&mut palette.focus_pending) { + window.focus(&palette.search.focus_handle(cx), cx); + } + let row_count = sections + .iter() + .map(|section| section.rows.len()) + .sum::(); + let active = palette.active_position(&flat_rows(§ions)); + let search = palette.search.clone(); + let query = search.read(cx).text().to_string(); + let focus = palette.focus.clone(); + let scroll = palette.scroll.clone(); + let current_tab = palette.tab; + let theme = Theme::of(cx).for_popup(); + let mut children = Vec::new(); + let mut ix = 0; + for (section_ix, section) in sections.iter().enumerate() { + // End spacing belongs to the content, so it scrolls out of the + // fade instead of leaving a permanent gutter beside the chrome. + let mut header = div() + .flex_none() + .when(section_ix == 0, |header| header.pt(px(8.0))); + if section_ix > 0 { + header = header.child(spaces::sidebar_separator(&theme).w_full().my(px(8.0))); + } + children.push( + header + .child(palette_section_header( + &theme, + section.section.title(), + section.status.clone(), + )) + .into_any_element(), + ); + for row in §ion.rows { + let key = row.key(); + let content = self.render_palette_row(row, ix, ix == active, &query, &theme, cx); + let Some(content) = content else { + ix += 1; + continue; + }; + children.push( + div() + .id(("command-result", ix)) + .flex_none() + .on_mouse_move(cx.listener(move |this, _: &gpui::MouseMoveEvent, _, cx| { + this.hover_command(key.clone(), ix, cx) + })) + .when(ix + 1 == row_count, |row| row.pb(px(8.0))) + .child(div().px(px(8.0)).child(content)) + .into_any_element(), + ); + ix += 1; + } } let body = div() .id("command-results") @@ -356,8 +1192,8 @@ impl Shell { .flex() .flex_col() .gap(px(SIDEBAR_LIST_GAP)) - .children(rows) - .when(entries.is_empty(), |el| { + .children(children) + .when(sections.is_empty(), |el| { el.child(palette_empty( &theme, "No results", @@ -365,23 +1201,40 @@ impl Shell { )) }); let body = palette_results_fade(body, &scroll); + let tabs = div() + .flex_none() + .px(px(12.0)) + .py(px(6.0)) + .flex() + .items_center() + .gap(px(4.0)) + .border_b_1() + .border_color(crate::theme::hairline(0.06)) + .children(Tab::ALL.into_iter().map(|tab| { + let combo = crate::settings::badge_combo(&format!("mod-{}", tab.index() + 1)); + // The app's tab chip (right-pane surfaces, PR board), not a + // menu row: its height, type and washes. + crate::surface_chrome::tab(("command-tab", tab.index()), tab == current_tab, &theme) + .px(px(8.0)) + .role(gpui::Role::Tab) + .aria_label(tab.label()) + .gap(px(6.0)) + .on_click(cx.listener(move |this, _, _, cx| this.select_palette_tab(tab, cx))) + .child(tab.label()) + .child(popover::kbd_hint(&theme, &combo)) + })); let card = palette_card("command-palette", &focus, viewport, &theme) + .key_context(KEY_CONTEXT) + .on_action(cx.listener(|this, action: &SelectPaletteTab, _, cx| { + if let Some(tab) = Tab::ALL.get(action.0) { + this.select_palette_tab(*tab, cx); + } + })) .on_key_down( cx.listener(move |this, event: &gpui::KeyDownEvent, window, cx| { match event.keystroke.key.as_str() { "up" | "down" => { - let count = this.command_entries(cx).len(); - if count > 0 - && let Some(palette) = this.command_palette.as_mut() - { - palette.active = if event.keystroke.key == "down" { - (palette.active + 1) % count - } else { - (palette.active + count - 1) % count - }; - palette.scroll.scroll_to_item(palette.active); - cx.notify(); - } + this.move_palette_selection(event.keystroke.key == "down", cx) } "enter" => { let activate = this @@ -392,14 +1245,16 @@ impl Shell { cx.stop_propagation(); return; } - let entries = this.command_entries(cx); - if let Some(entry) = this + this.activate_palette_selection(window, cx); + } + "tab" => { + let forward = !event.keystroke.modifiers.shift; + if let Some(tab) = this .command_palette .as_ref() - .and_then(|p| entries.get(p.active)) - .cloned() + .map(|palette| palette.tab.step(forward)) { - this.activate_command(entry, window, cx); + this.select_palette_tab(tab, cx); } } "escape" => this.close_command_palette(window, cx), @@ -424,17 +1279,40 @@ impl Shell { search.into_any_element(), popover::kbd_hint(&theme, &crate::settings::badge_combo("mod-k")), )) + .child(tabs) .child(body) .child( palette_footer() .child(command_key_hint(&theme, "↑ ↓", "Navigate")) - .child(command_key_hint(&theme, "↵", "Select")) + .child(command_key_hint(&theme, "↵", "Open")) .child(command_key_hint(&theme, "Esc", "Close")), ); Some(palette_overlay(viewport, card)) } } +/// A section's title, in the sidebar's secondary text style, with its +/// search status trailing. +fn palette_section_header( + theme: &Theme, + title: &'static str, + status: Option, +) -> gpui::Div { + div() + .px(px(16.0)) + .pt(px(2.0)) + .pb(px(4.0)) + .flex() + .items_center() + .gap(px(8.0)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(title) + .when_some(status, |header, status| { + header.child(div().min_w_0().truncate().opacity(0.7).child(status)) + }) +} + /// The results list's max height; shared so every palette sits at one size. pub(super) fn palette_results_height(viewport: gpui::Size) -> f32 { (f32::from(viewport.height) - 180.0).clamp(100.0, 360.0) @@ -550,12 +1428,34 @@ pub(super) fn palette_overlay( .into_any_element() } +/// A footer legend: the keys on a key cap (footers keep their caps, unlike +/// the plain inline legends) and the verb after it. Arrow and return keys +/// are icons, which sit optically centered on the cap where font glyphs +/// don't; other keys ("Esc") are text. pub(super) fn command_key_hint(theme: &Theme, keys: &str, label: &'static str) -> gpui::Div { + let glyph = |path: &'static str| { + crate::icons::icon(path) + .size(px(12.5)) + .text_color(theme.text_muted) + }; + let cap = popover::key_cap(theme); + let cap = match keys { + "↵" => cap.child(glyph(crate::icons::RETURN)), + "←" => cap.child(glyph(crate::icons::ARROW_LEFT)), + "↑ ↓" => cap + .child(glyph(crate::icons::ARROW_UP)) + .child(glyph(crate::icons::ARROW_DOWN)), + _ => cap + .font_family(popover::KEY_GLYPH_FONT) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(SharedString::from(keys.to_string())), + }; div() .flex() .items_center() .gap(px(5.0)) - .child(popover::kbd_hint(theme, keys)) + .child(cap) .child( div() .text_size(crate::typography::ui_rems(10.0)) @@ -616,10 +1516,11 @@ mod tests { input.set_text(query, cx); }); shell - .command_entries(cx) + .palette_sections(cx) .into_iter() - .filter_map(|entry| match entry { - Entry::Chat(id) => Some(id), + .flat_map(|section| section.rows) + .filter_map(|row| match row.kind { + RowKind::Chat(id) => Some(id), _ => None, }) .collect() @@ -641,10 +1542,8 @@ mod tests { ]); }); shell.toggle_command_palette(window, cx); - assert_eq!( - search_chats(shell, "", cx), - ["main-session", "archived-session"] - ); + // Recent Threads leaves archived threads to search. + assert_eq!(search_chats(shell, "", cx), ["main-session"]); for query in [ "manual-sidechat", "mcp-worker", @@ -671,30 +1570,1163 @@ mod tests { window .update(cx, |shell, window, cx| { shell.state.update(cx, |state, _| { - let children = (0..HISTORY_RESULT_LIMIT).map(|ix| { + let children = (0..THREADS_TAB_LIMIT).map(|ix| { chat(&format!("child-{ix}"), Some("session-0"), false, ix as i64) }); - let sessions = (0..=HISTORY_RESULT_LIMIT).map(|ix| { + let sessions = (0..=THREADS_TAB_LIMIT).map(|ix| { chat( &format!("session-{ix}"), None, false, - (HISTORY_RESULT_LIMIT + ix) as i64, + (THREADS_TAB_LIMIT + ix) as i64, ) }); state.apply_chats(children.chain(sessions).collect()); }); shell.toggle_command_palette(window, cx); - let expected: Vec<_> = (0..HISTORY_RESULT_LIMIT) + shell.select_palette_tab(Tab::Threads, cx); + let expected: Vec<_> = (0..THREADS_TAB_LIMIT) .map(|ix| format!("session-{ix}")) .collect(); assert_eq!(search_chats(shell, "", cx), expected); - let oldest = format!("session-{HISTORY_RESULT_LIMIT}"); + let oldest = format!("session-{THREADS_TAB_LIMIT}"); assert_eq!(search_chats(shell, &oldest, cx), [oldest]); }) .unwrap(); } + fn sections(shell: &Shell, cx: &App) -> Vec<(Section, Vec)> { + shell + .palette_sections(cx) + .into_iter() + .map(|section| { + ( + section.section, + section.rows.into_iter().map(|row| row.kind).collect(), + ) + }) + .collect() + } + + fn titled(id: &str, title: &str, archived: bool, age: i64) -> zeron_proto::Chat { + let mut chat = chat(id, None, archived, age); + chat.title = Some(title.into()); + chat + } + + #[gpui::test] + fn thread_search_ranks_by_score_with_archived_below(cx: &mut TestAppContext) { + let (window, _dir) = palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, _| { + state.apply_chats(vec![ + titled("mid-word", "Redeploy the edge", false, 0), + titled("archived", "Deploy notes", true, 1), + titled("older", "Deploy staging", false, 3), + titled("newer", "Deploy production", false, 2), + titled("unrelated", "Fix the composer", false, 0), + ]); + }); + shell.toggle_command_palette(window, cx); + assert_eq!( + search_chats(shell, "deploy", cx), + ["newer", "older", "mid-word", "archived"] + ); + shell.select_palette_tab(Tab::Threads, cx); + assert_eq!(search_chats(shell, "deploy staging", cx), ["older"]); + }) + .unwrap(); + } + + #[gpui::test] + fn all_tab_lists_threads_before_commands_and_caps_them(cx: &mut TestAppContext) { + let (window, _dir) = palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, _| { + state.apply_chats( + (0..8) + .map(|ix| titled(&format!("t{ix}"), "New project idea", false, ix)) + .collect(), + ); + }); + shell.toggle_command_palette(window, cx); + shell + .command_palette + .as_ref() + .unwrap() + .search + .update(cx, |input, cx| input.set_text("new", cx)); + let sections = sections(shell, cx); + assert_eq!(sections[0].0, Section::Threads); + assert_eq!(sections[0].1.len(), ALL_TAB_LIMIT); + assert_eq!(sections[1].0, Section::Commands); + shell.select_palette_tab(Tab::Threads, cx); + assert_eq!(search_chats(shell, "new", cx).len(), 8); + }) + .unwrap(); + } + + #[gpui::test] + fn empty_state_without_a_focused_chat_has_no_files_or_archive(cx: &mut TestAppContext) { + let (window, _dir) = palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, _| { + state.apply_chats(vec![chat("only", None, false, 0)]); + }); + shell.toggle_command_palette(window, cx); + let sections = sections(shell, cx); + assert_eq!( + sections + .iter() + .map(|(section, _)| *section) + .collect::>(), + [Section::QuickActions, Section::RecentThreads] + ); + assert!( + !sections[0] + .1 + .contains(&RowKind::Command(Command::ArchiveThread)) + ); + }) + .unwrap(); + } + + #[gpui::test] + fn empty_state_lists_recent_files_actions_and_threads(cx: &mut TestAppContext) { + let (window, _dir) = palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, cx| { + state.apply_chats(vec![ + chat("focused", None, false, 0), + chat("side", Some("focused"), false, 1), + chat("older", None, false, 3), + chat("newer", None, false, 2), + chat("archived", None, true, 1), + ]); + state.select_chat(Some("focused".into()), cx); + }); + shell.active_chat = "focused".into(); + for path in ["src/a.rs", "README.md", "src/a.rs", "docs/b.md"] { + shell.note_recent_file("focused", path); + } + shell.note_recent_file("newer", "elsewhere.rs"); + shell.toggle_command_palette(window, cx); + let sections = sections(shell, cx); + let file = |path: &str| RowKind::File { + path: path.into(), + is_dir: false, + }; + assert_eq!(sections[0].0, Section::RecentFiles); + assert_eq!( + sections[0].1, + [file("docs/b.md"), file("src/a.rs"), file("README.md")] + ); + assert_eq!(sections[1].0, Section::QuickActions); + assert!( + sections[1] + .1 + .contains(&RowKind::Command(Command::ArchiveThread)) + ); + assert_eq!(sections[2].0, Section::RecentThreads); + assert_eq!( + sections[2].1, + [RowKind::Chat("newer".into()), RowKind::Chat("older".into())] + ); + }) + .unwrap(); + } + + #[test] + fn archive_is_offered_only_with_a_focused_chat() { + assert!(!commands_for("archive", true, false).contains(&Command::ArchiveThread)); + assert_eq!( + commands_for("archive", true, true), + vec![Command::ArchiveThread] + ); + } + + #[test] + fn file_rows_split_name_and_parent_folder() { + assert_eq!( + split_path("src/shell/palette.rs"), + ("palette.rs", "src/shell") + ); + assert_eq!(split_path("README.md"), ("README.md", "")); + assert_eq!(split_path("crates/ui/"), ("ui", "crates")); + } + + /// The engine side of a shell under test: requests out, replies in. + struct FakeEngine { + runtime: tokio::runtime::Runtime, + requests: tokio::sync::mpsc::Receiver, + replies: tokio::sync::mpsc::Sender, + } + + impl FakeEngine { + /// Every request sent so far for `method`; other methods are + /// answered with an empty object (or a ready search index). + fn take(&mut self, method: &str, cx: &mut TestAppContext) -> Vec { + let mut found = Vec::new(); + loop { + cx.executor().advance_clock(CONTENT_DEBOUNCE); + cx.run_until_parked(); + let Ok(request) = self.requests.try_recv() else { + return found; + }; + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + if request["method"] == method { + found.push(request); + } else if request["method"] == methods::WARM_WORKSPACE_SEARCH { + self.reply(&request, serde_json::json!({ "state": "ready" })); + } else { + self.reply(&request, serde_json::json!({})); + } + } + } + + fn reply(&self, request: &serde_json::Value, ok: serde_json::Value) { + self.send(serde_json::json!({ "id": request["id"], "ok": ok })); + } + + fn fail(&self, request: &serde_json::Value, err: &str) { + self.send(serde_json::json!({ "id": request["id"], "err": err })); + } + + fn send(&self, reply: serde_json::Value) { + // The client's reader task runs on this runtime: let it drain. + self.runtime.block_on(async { + self.replies.send(reply.to_string()).await.unwrap(); + while self.replies.capacity() < self.replies.max_capacity() { + tokio::task::yield_now().await; + } + }); + } + } + + /// A palette over chat "focused" (plus "deploy-thread"), wired to a fake + /// engine. `focused: false` leaves no chat in focus. + fn engine_palette( + cx: &mut TestAppContext, + focused: bool, + ) -> (gpui::WindowHandle, tempfile::TempDir, FakeEngine) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (out, requests) = tokio::sync::mpsc::channel(64); + let (replies, inbound) = tokio::sync::mpsc::channel(64); + let engine = { + let _guard = runtime.enter(); + crate::state::EngineHandle::from_test_client(zeron_rpc::RpcClient::new(out, inbound)) + }; + let (window, dir) = palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.state.update(cx, |state, cx| { + state.local_device_id = Some("local".into()); + state.apply_chats(vec![ + chat("focused", None, false, 0), + titled("deploy-thread", "Deploy the docs", false, 1), + ]); + state.set_test_engine(engine); + if focused { + state.select_chat(Some("focused".into()), cx); + } + }); + if focused { + shell.active_chat = "focused".into(); + } + shell.toggle_command_palette(window, cx); + }) + .unwrap(); + let fake = FakeEngine { + runtime, + requests, + replies, + }; + (window, dir, fake) + } + + fn type_query(window: gpui::WindowHandle, query: &str, cx: &mut TestAppContext) { + window + .update(cx, |shell, _, cx| { + shell + .command_palette + .as_ref() + .unwrap() + .search + .update(cx, |input, cx| input.set_text(query, cx)); + }) + .unwrap(); + } + + fn palette_section( + window: gpui::WindowHandle, + section: Section, + cx: &mut TestAppContext, + ) -> Option { + window + .read_with(cx, |shell, cx| { + shell + .palette_sections(cx) + .into_iter() + .find(|found| found.section == section) + }) + .unwrap() + } + + fn name_match(path: &str, kind: &str) -> serde_json::Value { + serde_json::json!({ "path": path, "name": path, "kind": kind, "score": 1 }) + } + + /// Settle the independent focus warm-up before controlling search probes. + fn file_search_palette( + cx: &mut TestAppContext, + ) -> (gpui::WindowHandle, tempfile::TempDir, FakeEngine) { + let (window, dir, mut engine) = engine_palette(cx, true); + for warm in engine.take(methods::WARM_WORKSPACE_SEARCH, cx) { + engine.reply(&warm, serde_json::json!({ "state": "ready" })); + } + cx.run_until_parked(); + (window, dir, engine) + } + + fn answer_file_index( + engine: &mut FakeEngine, + state: &str, + cx: &mut TestAppContext, + ) -> serde_json::Value { + let warm = engine.take(methods::WARM_WORKSPACE_SEARCH, cx); + assert_eq!(warm.len(), 1); + assert_eq!(warm[0]["params"]["chatId"], "focused"); + engine.reply(&warm[0], serde_json::json!({ "state": state })); + let search = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + assert_eq!(search.len(), 1); + search.into_iter().next().unwrap() + } + + /// Every request sent so far, each answered: warm-ups as `warm`, name + /// searches with no matches. Returns the methods in order. + fn answer_all(engine: &mut FakeEngine, warm: &str, cx: &mut TestAppContext) -> Vec { + let mut methods_seen = Vec::new(); + loop { + cx.executor().advance_clock(CONTENT_DEBOUNCE); + cx.run_until_parked(); + let Ok(request) = engine.requests.try_recv() else { + return methods_seen; + }; + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + let method = request["method"].as_str().unwrap().to_owned(); + match method.as_str() { + methods::WARM_WORKSPACE_SEARCH if warm == "unsupported" => engine.fail( + &request, + &format!("unknown method: {}", methods::WARM_WORKSPACE_SEARCH), + ), + methods::WARM_WORKSPACE_SEARCH => { + engine.reply(&request, serde_json::json!({ "state": warm })) + } + methods::SEARCH_WORKSPACE_FILES => engine.reply(&request, serde_json::json!([])), + _ => engine.reply(&request, content_answer(Vec::new(), false)), + } + if method != methods::SEARCH_WORKSPACE_CONTENT { + methods_seen.push(method); + } + } + } + + #[gpui::test] + fn the_files_tab_lists_recently_changed_files_with_no_query(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + let listed = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + assert_eq!(listed.len(), 1, "opening asks for the default list once"); + assert_eq!(listed[0]["params"]["query"], ""); + engine.reply( + &listed[0], + serde_json::json!([ + name_match("src/recent.rs", "file"), + name_match("src", "directory"), + name_match("README.md", "file"), + ]), + ); + cx.run_until_parked(); + window + .update(cx, |shell, _, cx| shell.select_palette_tab(Tab::Files, cx)) + .unwrap(); + let recent = palette_section(window, Section::RecentFiles, cx).expect("a default list"); + let paths: Vec<_> = recent + .rows + .iter() + .filter_map(|row| match &row.kind { + RowKind::File { path, .. } => Some(path.as_str()), + _ => None, + }) + .collect(); + // Files only; no content search runs without a query. + assert_eq!(paths, ["src/recent.rs", "README.md"]); + assert!(engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx).is_empty()); + } + + #[gpui::test] + fn a_ready_index_skips_the_probe_until_the_palette_reopens(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + let probed = [ + methods::WARM_WORKSPACE_SEARCH, + methods::SEARCH_WORKSPACE_FILES, + ]; + type_query(window, "ne", cx); + assert_eq!(answer_all(&mut engine, "ready", cx), probed); + for query in ["nee", "need"] { + type_query(window, query, cx); + assert_eq!( + answer_all(&mut engine, "ready", cx), + [methods::SEARCH_WORKSPACE_FILES], + "{query}" + ); + } + + window + .update(cx, |shell, window, cx| { + shell.close_command_palette(window, cx); + shell.toggle_command_palette(window, cx); + }) + .unwrap(); + type_query(window, "ne", cx); + assert_eq!(answer_all(&mut engine, "ready", cx), probed); + } + + #[gpui::test] + fn a_building_index_keeps_probing(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "ne", cx); + answer_all(&mut engine, "building", cx); + type_query(window, "nee", cx); + assert_eq!( + answer_all(&mut engine, "ready", cx), + [ + methods::WARM_WORKSPACE_SEARCH, + methods::SEARCH_WORKSPACE_FILES + ] + ); + } + + #[gpui::test] + fn an_older_host_is_probed_once(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "ne", cx); + assert_eq!( + answer_all(&mut engine, "unsupported", cx), + [ + methods::WARM_WORKSPACE_SEARCH, + methods::SEARCH_WORKSPACE_FILES + ] + ); + type_query(window, "nee", cx); + assert_eq!( + answer_all(&mut engine, "unsupported", cx), + [methods::SEARCH_WORKSPACE_FILES] + ); + } + + #[gpui::test] + fn file_search_refreshes_partial_results_until_indexed(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "needle", cx); + let first = answer_file_index(&mut engine, "building", cx); + engine.reply(&first, serde_json::json!([])); + cx.run_until_parked(); + let empty = palette_section(window, Section::Files, cx).unwrap(); + assert!(empty.rows.is_empty()); + assert_eq!(empty.status.as_deref(), Some("Indexing…")); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + + cx.executor().advance_clock(FILE_INDEX_RETRY); + let second = answer_file_index(&mut engine, "building", cx); + assert_eq!(second["params"], first["params"]); + engine.reply( + &second, + serde_json::json!([name_match("needle.rs", "file")]), + ); + cx.run_until_parked(); + let partial = palette_section(window, Section::Files, cx).unwrap(); + assert_eq!(partial.rows.len(), 1); + assert_eq!(partial.status.as_deref(), Some("Indexing…")); + let selected = partial.rows[0].key(); + window + .update(cx, |shell, _, cx| { + shell.select_palette_tab(Tab::Files, cx); + shell.command_palette.as_mut().unwrap().active = Some(selected.clone()); + }) + .unwrap(); + + cx.executor().advance_clock(FILE_INDEX_RETRY); + // Even though the index is ready now, search again: the previous + // answer was obtained while it was still building. + let third = answer_file_index(&mut engine, "ready", cx); + assert_eq!(third["params"], first["params"]); + assert_eq!(palette_section(window, Section::Files, cx), Some(partial)); + engine.reply( + &third, + serde_json::json!([ + name_match("another-needle.rs", "file"), + name_match("needle.rs", "file") + ]), + ); + cx.run_until_parked(); + let complete = palette_section(window, Section::Files, cx).unwrap(); + assert_eq!(complete.rows.len(), 2); + assert_eq!(complete.status, None); + window + .read_with(cx, |shell, cx| { + let sections = shell.palette_sections(cx); + let rows = flat_rows(§ions); + let palette = shell.command_palette.as_ref().unwrap(); + assert_eq!(rows[palette.active_position(&rows)].key(), selected); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + } + + #[gpui::test] + fn changing_query_cancels_file_index_retries(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "needle", cx); + let first = answer_file_index(&mut engine, "building", cx); + engine.reply(&first, serde_json::json!([])); + cx.run_until_parked(); + type_query(window, "updated", cx); + cx.executor().advance_clock(FILE_INDEX_RETRY); + let updated = answer_file_index(&mut engine, "ready", cx); + assert_eq!(updated["params"]["query"], "updated"); + engine.reply( + &updated, + serde_json::json!([name_match("updated.rs", "file")]), + ); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + } + + #[gpui::test] + fn clearing_or_closing_cancels_file_index_retries(cx: &mut TestAppContext) { + for close in [false, true] { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "needle", cx); + let first = answer_file_index(&mut engine, "building", cx); + engine.reply(&first, serde_json::json!([])); + cx.run_until_parked(); + if close { + window + .update(cx, |shell, window, cx| { + shell.close_command_palette(window, cx); + }) + .unwrap(); + } else { + type_query(window, "", cx); + } + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + } + } + + #[gpui::test] + fn file_search_on_an_older_host_keeps_the_legacy_search(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "needle", cx); + let warm = engine.take(methods::WARM_WORKSPACE_SEARCH, cx); + assert_eq!(warm.len(), 1); + engine.fail(&warm[0], "unknown method: WarmWorkspaceSearch"); + let search = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + assert_eq!(search.len(), 1); + engine.reply( + &search[0], + serde_json::json!([name_match("needle.rs", "file")]), + ); + cx.run_until_parked(); + let files = palette_section(window, Section::Files, cx).unwrap(); + assert_eq!(files.rows.len(), 1); + assert_eq!(files.status, None); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + } + + #[gpui::test] + fn file_index_retries_stop_on_warm_or_search_error(cx: &mut TestAppContext) { + for fail_warm in [false, true] { + let (window, _dir, mut engine) = file_search_palette(cx); + type_query(window, "needle", cx); + let first = answer_file_index(&mut engine, "building", cx); + engine.reply(&first, serde_json::json!([name_match("needle.rs", "file")])); + cx.run_until_parked(); + cx.executor().advance_clock(FILE_INDEX_RETRY); + let request = if fail_warm { + let warm = engine.take(methods::WARM_WORKSPACE_SEARCH, cx); + assert_eq!(warm.len(), 1); + warm.into_iter().next().unwrap() + } else { + answer_file_index(&mut engine, "building", cx) + }; + engine.fail(&request, "search failed"); + cx.run_until_parked(); + let files = palette_section(window, Section::Files, cx).unwrap(); + assert!(files.rows.is_empty()); + assert_eq!(files.status.as_deref(), Some("Search failed")); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::WARM_WORKSPACE_SEARCH, cx).is_empty()); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + } + } + + #[gpui::test] + fn file_names_need_a_focused_chat_and_two_characters(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, false); + type_query(window, "co", cx); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + assert!(palette_section(window, Section::Files, cx).is_none()); + + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "c", cx); + assert!(engine.take(methods::SEARCH_WORKSPACE_FILES, cx).is_empty()); + type_query(window, "co", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["params"]["chatId"], "focused"); + assert_eq!(requests[0]["params"]["query"], "co"); + let searching = palette_section(window, Section::Files, cx).unwrap(); + assert!(searching.rows.is_empty()); + assert_eq!(searching.status.as_deref(), Some("Searching…")); + + engine.reply( + &requests[0], + serde_json::json!([ + name_match("src/composer.rs", "file"), + name_match("src", "directory") + ]), + ); + cx.run_until_parked(); + let files = palette_section(window, Section::Files, cx).unwrap(); + assert_eq!(files.status, None); + assert_eq!( + files + .rows + .into_iter() + .map(|row| row.kind) + .collect::>(), + [ + RowKind::File { + path: "src/composer.rs".into(), + is_dir: false + }, + RowKind::File { + path: "src".into(), + is_dir: true + }, + ] + ); + } + + #[gpui::test] + fn stale_file_answers_are_dropped(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "ab", cx); + let first = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + type_query(window, "abc", cx); + let second = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + engine.reply( + &second[0], + serde_json::json!([name_match("abc.rs", "file")]), + ); + cx.run_until_parked(); + engine.reply(&first[0], serde_json::json!([name_match("ab.rs", "file")])); + cx.run_until_parked(); + let files = palette_section(window, Section::Files, cx).unwrap(); + assert_eq!( + files + .rows + .into_iter() + .map(|row| row.kind) + .collect::>(), + [RowKind::File { + path: "abc.rs".into(), + is_dir: false + }] + ); + } + + #[gpui::test] + fn arriving_files_keep_the_selected_row(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "deploy", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_FILES, cx); + // Select the thread; the Files section then lands below it. + window + .update(cx, |shell, _, cx| shell.move_palette_selection(false, cx)) + .unwrap(); + let selected = |cx: &mut TestAppContext| { + window + .read_with(cx, |shell, _| { + shell.command_palette.as_ref().unwrap().active.clone() + }) + .unwrap() + }; + let before = selected(cx); + engine.reply( + &requests[0], + serde_json::json!([name_match("deploy.md", "file")]), + ); + cx.run_until_parked(); + assert_eq!(selected(cx), before); + assert!(before.is_some()); + } + + #[gpui::test] + fn activating_a_file_row_opens_it_for_the_focused_chat(cx: &mut TestAppContext) { + let (window, _dir, _engine) = engine_palette(cx, true); + window + .update(cx, |shell, window, cx| { + let row = PaletteRow::new( + Section::Files, + RowKind::File { + path: "src/composer.rs".into(), + is_dir: false, + }, + ); + shell.activate_palette_row(row, window, cx); + assert!(shell.command_palette.is_none()); + assert!( + shell + .file_surface_paths + .values() + .any(|path| path == "src/composer.rs") + ); + assert_eq!( + shell.recent_files("focused").next().map(String::as_str), + Some("src/composer.rs") + ); + }) + .unwrap(); + } + + fn content_match(path: &str, line: u64) -> serde_json::Value { + serde_json::json!({ + "path": path, "line": line, "column": 4, + "preview": "let needle = 1;", "ranges": [[4, 10]], + }) + } + + fn content_answer(matches: Vec, indexing: bool) -> serde_json::Value { + serde_json::json!({ "matches": matches, "truncated": false, "indexing": indexing }) + } + + #[gpui::test] + fn content_matches_arrive_after_the_debounce(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(requests.len(), 1); + let params = &requests[0]["params"]; + assert_eq!(params["chatId"], "focused"); + assert_eq!(params["query"], "needle"); + assert_eq!(params["limit"], CONTENT_TAB_LIMIT); + assert_eq!(params["perFileLimit"], CONTENT_MATCHES_PER_FILE); + + let matches = (1..=8) + .map(|line| content_match("src/lib.rs", line)) + .collect(); + engine.reply(&requests[0], content_answer(matches, false)); + cx.run_until_parked(); + let in_files = palette_section(window, Section::InFiles, cx).unwrap(); + assert_eq!(in_files.status, None); + assert_eq!(in_files.rows.len(), ALL_TAB_LIMIT); + let RowKind::Content(first) = &in_files.rows[0].kind else { + panic!("content row expected"); + }; + assert_eq!((first.line, first.ranges.as_slice()), (1, &[(4, 10)][..])); + window + .update(cx, |shell, _, cx| shell.select_palette_tab(Tab::Files, cx)) + .unwrap(); + assert_eq!( + palette_section(window, Section::InFiles, cx) + .unwrap() + .rows + .len(), + 8 + ); + } + + #[gpui::test] + fn content_search_reports_indexing_and_hides_when_empty(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&requests[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + assert_eq!( + palette_section(window, Section::InFiles, cx) + .unwrap() + .status + .as_deref(), + Some("Indexing…") + ); + + type_query(window, "needles", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&requests[0], content_answer(Vec::new(), false)); + cx.run_until_parked(); + assert!(palette_section(window, Section::InFiles, cx).is_none()); + } + + #[gpui::test] + fn content_search_refreshes_partial_results_until_indexed(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let first = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&first[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + + cx.executor().advance_clock(CONTENT_INDEX_RETRY); + let second = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(second.len(), 1); + assert_eq!(second[0]["params"], first[0]["params"]); + engine.reply( + &second[0], + content_answer(vec![content_match("src/lib.rs", 2)], true), + ); + cx.run_until_parked(); + let partial = palette_section(window, Section::InFiles, cx).unwrap(); + assert_eq!(partial.rows.len(), 1); + assert_eq!(partial.status.as_deref(), Some("Indexing…")); + let selected = partial.rows[0].key(); + window + .update(cx, |shell, _, cx| { + shell.select_palette_tab(Tab::Files, cx); + shell.command_palette.as_mut().unwrap().active = Some(selected.clone()); + }) + .unwrap(); + + cx.executor().advance_clock(CONTENT_INDEX_RETRY * 2); + let third = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(third.len(), 1); + assert_eq!(third[0]["params"], first[0]["params"]); + assert_eq!(palette_section(window, Section::InFiles, cx), Some(partial)); + engine.reply( + &third[0], + content_answer( + vec![ + content_match("src/lib.rs", 1), + content_match("src/lib.rs", 2), + ], + false, + ), + ); + cx.run_until_parked(); + let complete = palette_section(window, Section::InFiles, cx).unwrap(); + assert_eq!(complete.rows.len(), 2); + assert_eq!(complete.status, None); + window + .read_with(cx, |shell, cx| { + let sections = shell.palette_sections(cx); + let rows = flat_rows(§ions); + let palette = shell.command_palette.as_ref().unwrap(); + assert_eq!(rows[palette.active_position(&rows)].key(), selected); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + } + + #[gpui::test] + fn content_index_retries_back_off_to_two_seconds(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let mut pending = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + let early = Duration::from_millis(100); + for wait in [500, 1000, 2000, 2000].map(Duration::from_millis) { + engine.reply(&pending[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + // `take` itself advances the clock by the debounce; stay short of + // the wait, then cross it. + cx.executor().advance_clock(wait - early); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty(), + "refreshed before {wait:?}" + ); + cx.executor().advance_clock(early); + pending = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(pending.len(), 1, "no refresh after {wait:?}"); + } + engine.reply(&pending[0], content_answer(Vec::new(), false)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + } + + #[gpui::test] + fn changing_the_query_cancels_content_index_retries(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let first = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&first[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + + type_query(window, "updated", cx); + cx.run_until_parked(); + cx.executor().advance_clock(CONTENT_INDEX_RETRY); + let updated = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0]["params"]["query"], "updated"); + engine.reply(&updated[0], content_answer(Vec::new(), false)); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + } + + #[gpui::test] + fn closing_the_palette_cancels_content_index_retries(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let first = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&first[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + window + .update(cx, |shell, window, cx| { + shell.close_command_palette(window, cx) + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + } + + #[gpui::test] + fn content_index_retries_stop_on_error(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let first = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.reply(&first[0], content_answer(Vec::new(), true)); + cx.run_until_parked(); + cx.executor().advance_clock(CONTENT_INDEX_RETRY); + let retry = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + assert_eq!(retry.len(), 1); + engine.fail(&retry[0], "search failed"); + cx.run_until_parked(); + let section = palette_section(window, Section::InFiles, cx).unwrap(); + assert_eq!(section.status.as_deref(), Some("Search failed")); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine + .take(methods::SEARCH_WORKSPACE_CONTENT, cx) + .is_empty() + ); + } + + #[gpui::test] + fn content_search_on_an_older_host_explains_itself(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = engine_palette(cx, true); + type_query(window, "needle", cx); + let requests = engine.take(methods::SEARCH_WORKSPACE_CONTENT, cx); + engine.fail(&requests[0], "unknown method: SearchWorkspaceContent"); + cx.run_until_parked(); + let in_files = palette_section(window, Section::InFiles, cx).unwrap(); + assert!(in_files.rows.is_empty()); + assert!( + in_files + .status + .is_some_and(|status| status.contains("update it to search its file contents")) + ); + } + + #[gpui::test] + fn activating_a_content_row_opens_its_file(cx: &mut TestAppContext) { + let (window, _dir, _engine) = engine_palette(cx, true); + window + .update(cx, |shell, window, cx| { + let found: WorkspaceContentMatch = + serde_json::from_value(content_match("src/lib.rs", 42)).unwrap(); + shell.activate_palette_row( + PaletteRow::new(Section::InFiles, RowKind::Content(found)), + window, + cx, + ); + assert!( + shell + .file_surface_paths + .values() + .any(|path| path == "src/lib.rs") + ); + }) + .unwrap(); + } + + /// A shell with the real app keymap and two sidebar sessions. + fn keyed_palette_window( + cx: &mut TestAppContext, + ) -> (gpui::WindowHandle, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir.path(), cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + crate::composer::init(cx, ComposerSendBehavior::default()); + apply_keymap( + cx, + &KeymapConfig::default(), + ComposerSendBehavior::default(), + ); + }); + let (window, _) = palette_window(cx); + window + .update(cx, |shell, _, cx| { + shell.settings.sidebar_organization = SidebarOrganization::InOneList; + shell.state.update(cx, |state, _| { + state.connection = zeron_proto::view::ConnectionStatus::Ready; + state.workspace_scope = Some(WorkspaceScope::Local); + state.local_device_id = Some("local".into()); + state.apply_chats(vec![ + chat("first", None, false, 0), + chat("second", None, false, 1), + ]); + state.selected_chat = Some("first".into()); + }); + }) + .unwrap(); + (window, dir) + } + + fn palette_tab(window: gpui::WindowHandle, cx: &mut TestAppContext) -> Option { + window + .read_with(cx, |shell, _| shell.command_palette.as_ref().map(|p| p.tab)) + .unwrap() + } + + fn selected_chat(window: gpui::WindowHandle, cx: &mut TestAppContext) -> Option { + window + .read_with(cx, |shell, cx| shell.state.read(cx).selected_chat.clone()) + .unwrap() + } + + #[gpui::test] + fn digit_shortcuts_switch_tabs_while_the_palette_is_open(cx: &mut TestAppContext) { + let (window, _dir) = keyed_palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.toggle_command_palette(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + + cx.simulate_keystrokes(window.into(), &platform_combo("mod-2")); + assert_eq!(palette_tab(window, cx), Some(Tab::Threads)); + assert_eq!(selected_chat(window, cx).as_deref(), Some("first")); + cx.simulate_keystrokes(window.into(), &platform_combo("mod-4")); + assert_eq!(palette_tab(window, cx), Some(Tab::Files)); + cx.simulate_keystrokes(window.into(), &platform_combo("mod-1")); + assert_eq!(palette_tab(window, cx), Some(Tab::All)); + } + + #[gpui::test] + fn digit_shortcuts_jump_to_sessions_once_the_palette_closes(cx: &mut TestAppContext) { + let (window, _dir) = keyed_palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.toggle_command_palette(window, cx); + shell.close_command_palette(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + + cx.simulate_keystrokes(window.into(), &platform_combo("mod-2")); + assert_eq!(palette_tab(window, cx), None); + assert_eq!(selected_chat(window, cx).as_deref(), Some("second")); + } + + #[gpui::test] + fn tab_rotates_the_palette_tabs(cx: &mut TestAppContext) { + let (window, _dir) = keyed_palette_window(cx); + window + .update(cx, |shell, window, cx| { + shell.toggle_command_palette(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + + for expected in [Tab::Threads, Tab::Commands, Tab::Files, Tab::All] { + cx.simulate_keystrokes(window.into(), "tab"); + assert_eq!(palette_tab(window, cx), Some(expected)); + } + cx.simulate_keystrokes(window.into(), "shift-tab"); + assert_eq!(palette_tab(window, cx), Some(Tab::Files)); + } + + #[test] + fn selection_follows_its_row_when_rows_arrive_above_it() { + let chats = |ids: &[&str]| { + PaletteSection::new( + Section::Threads, + ids.iter().map(|id| RowKind::Chat((*id).into())), + ) + }; + let before = [chats(&["a", "b", "c"])]; + let active = flat_rows(&before)[2].key(); + let after = [ + PaletteSection::new(Section::Commands, [RowKind::Command(Command::NewChat)]), + chats(&["a", "b", "c"]), + ]; + assert_eq!(row_position(Some(&active), 2, &flat_rows(&after)), 3); + // A vanished row falls back to its old place, clamped. + let shorter = [chats(&["a"])]; + assert_eq!(row_position(Some(&active), 2, &flat_rows(&shorter)), 0); + // Two headers precede the fourth row in the scroll container. + assert_eq!(child_index(&after, 3), 5); + assert_eq!(child_index(&after, 0), 1); + // Keyboard scrolling brings a section's header along with its first + // row; later rows scroll to themselves. + assert_eq!(scroll_child_index(&after, 0), 0); + assert_eq!(scroll_child_index(&after, 1), 2); + assert_eq!(scroll_child_index(&after, 2), 4); + } + #[test] fn x11_unflagged_enter_repeats_activate_once_until_release() { let mut enter = EnterPress::default(); @@ -721,43 +2753,46 @@ mod tests { #[test] fn action_search_hides_empty_section_and_preserves_order() { assert_eq!( - actions_for("", true), + commands_for("", true, false), vec![ - Entry::NewChat, - Entry::NewProject, - Entry::Settings, - Entry::Theme(AppearanceMode::Light) + Command::NewChat, + Command::NewProject, + Command::Settings, + Command::Theme(AppearanceMode::Light) ] ); assert_eq!( - actions_for("new", true), - vec![Entry::NewChat, Entry::NewProject] + commands_for("new", true, false), + vec![Command::NewChat, Command::NewProject] + ); + assert_eq!( + commands_for("settings", true, false), + vec![Command::Settings] ); - assert_eq!(actions_for("settings", true), vec![Entry::Settings]); assert_eq!( - actions_for("theme", true), - vec![Entry::Theme(AppearanceMode::Light)] + commands_for("theme", true, false), + vec![Command::Theme(AppearanceMode::Light)] ); - assert!(actions_for("deployment", true).is_empty()); + assert!(commands_for("deployment", true, false).is_empty()); } #[test] fn theme_action_targets_the_opposite_resolved_appearance() { assert_eq!( - actions_for("theme", true), - vec![Entry::Theme(AppearanceMode::Light)] + commands_for("theme", true, false), + vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - actions_for("theme", false), - vec![Entry::Theme(AppearanceMode::Dark)] + commands_for("theme", false, false), + vec![Command::Theme(AppearanceMode::Dark)] ); assert_eq!( - actions_for("light", true), - vec![Entry::Theme(AppearanceMode::Light)] + commands_for("light", true, false), + vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - actions_for("dark", false), - vec![Entry::Theme(AppearanceMode::Dark)] + commands_for("dark", false, false), + vec![Command::Theme(AppearanceMode::Dark)] ); } diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 7c890f482d..67786b3435 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -5,6 +5,27 @@ use crate::settings::{FILES_PANEL_MAX, FILES_PANEL_MIN}; pub(super) struct FilesPanelResize; +/// Files kept per chat for the command palette's Recent Files. +const RECENT_FILES_PER_CHAT: usize = 20; + +impl Shell { + /// Record `path` as the chat's most recently opened file. + pub(super) fn note_recent_file(&mut self, chat_id: &str, path: &str) { + if chat_id.is_empty() { + return; + } + let recent = self.recent_files.entry(chat_id.to_owned()).or_default(); + recent.retain(|candidate| candidate != path); + recent.push_front(path.to_owned()); + recent.truncate(RECENT_FILES_PER_CHAT); + } + + /// The chat's opened files, most recent first. + pub(super) fn recent_files(&self, chat_id: &str) -> impl Iterator { + self.recent_files.get(chat_id).into_iter().flatten() + } +} + /// Allocate a real column to Files, reducing its preferred width before the /// chat or surface minima. The shell fit policy hides a whole column when its /// minimum cannot fit, so no settled column is rendered as a sliver. diff --git a/crates/ui/src/shell/search_warm.rs b/crates/ui/src/shell/search_warm.rs new file mode 100644 index 0000000000..5c0d76ed9a --- /dev/null +++ b/crates/ui/src/shell/search_warm.rs @@ -0,0 +1,174 @@ +//! Warms the focused chat's workspace search index on its host once, when +//! the chat gains focus, so its first `@`, file-tree or cmd+K search does not +//! pay for the scan. The host evicts the index after it sits unused (15 min) +//! or for capacity; refocusing the chat warms it again. Hosts that predate +//! `WarmWorkspaceSearch` are skipped silently. + +use gpui::Context; + +use super::Shell; +use crate::files::client::{FilesRequestContext, WorkspaceFilesClient}; +use crate::state::EngineHandle; + +/// The focus last warmed, so unrelated state churn does not warm again. +pub(super) struct SearchWarm { + chat_id: String, + context: FilesRequestContext, + engine: EngineHandle, +} + +impl SearchWarm { + fn is_for(&self, chat_id: &str, context: &FilesRequestContext, engine: &EngineHandle) -> bool { + self.chat_id == chat_id && &self.context == context && self.engine.same_connection(engine) + } +} + +impl Shell { + /// Follow the selected chat: warm its index when it gains focus. + pub(super) fn sync_search_warm(&mut self, cx: &mut Context) { + let (focused, engine) = { + let state = self.state.read(cx); + let focused = state.selected_chat.as_deref().and_then(|chat_id| { + FilesRequestContext::for_chat(state, chat_id) + .map(|context| (chat_id.to_owned(), context)) + }); + (focused, state.engine().cloned()) + }; + let (Some((chat_id, context)), Some(engine)) = (focused, engine) else { + self.search_warm = None; + return; + }; + if self + .search_warm + .as_ref() + .is_some_and(|warm| warm.is_for(&chat_id, &context, &engine)) + { + return; + } + let client = WorkspaceFilesClient::new(engine.clone(), context.clone()); + cx.spawn(async move |_, _| client.hint_search_warm().await) + .detach(); + self.search_warm = Some(SearchWarm { + chat_id, + context, + engine, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppState; + use crate::theme::Theme; + use gpui::AppContext as _; + + fn test_shell( + cx: &mut gpui::TestAppContext, + path: &std::path::Path, + ) -> gpui::WindowHandle { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + }); + cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + crate::EngineBootConfig { + data_dir: path.into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }) + } + + fn chat(id: &str) -> zeron_proto::Chat { + serde_json::from_value(serde_json::json!({ + "id": id, "title": id, "deviceId": "local", "archived": false, + "createdAt": "2026-09-20T00:00:00Z", + })) + .unwrap() + } + + /// The chat ids of every WarmWorkspaceSearch sent so far, answering each. + fn drain_warms( + cx: &mut gpui::TestAppContext, + runtime: &tokio::runtime::Runtime, + requests: &mut tokio::sync::mpsc::Receiver, + replies: &tokio::sync::mpsc::Sender, + ) -> Vec { + let mut warms = Vec::new(); + loop { + cx.run_until_parked(); + let Ok(request) = requests.try_recv() else { + return warms; + }; + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + if request["method"] == zeron_rpc::methods::WARM_WORKSPACE_SEARCH { + warms.push(request["params"]["chatId"].as_str().unwrap().to_owned()); + } + let reply = serde_json::json!({ "id": request["id"], "ok": { "state": "ready" } }); + runtime.block_on(replies.send(reply.to_string())).unwrap(); + } + } + + #[gpui::test] + fn focusing_a_chat_warms_its_index_once(cx: &mut gpui::TestAppContext) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel(16); + let (replies, inbound) = tokio::sync::mpsc::channel(16); + let engine = + crate::state::EngineHandle::from_test_client(zeron_rpc::RpcClient::new(out, inbound)); + let dir = tempfile::tempdir().unwrap(); + let window = test_shell(cx, dir.path()); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.local_device_id = Some("local".into()); + state.chats_synced = true; + state.chats = vec![chat("a"), chat("b")]; + state.set_test_engine(engine); + state.select_chat(Some("a".into()), cx); + }); + }) + .unwrap(); + assert_eq!(drain_warms(cx, &runtime, &mut requests, &replies), ["a"]); + + window + .update(cx, |shell, _, cx| { + shell + .state + .update(cx, |state, cx| state.select_chat(Some("b".into()), cx)); + }) + .unwrap(); + // Leaving "a" sends nothing; the host's idle eviction owns it now. + assert_eq!(drain_warms(cx, &runtime, &mut requests, &replies), ["b"]); + + // Unrelated state churn does not re-send. + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |_, cx| cx.notify()) + }) + .unwrap(); + assert!(drain_warms(cx, &runtime, &mut requests, &replies).is_empty()); + } +} diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 75a3a3989a..ad74912e25 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -9,10 +9,16 @@ //! Child module of `shell` so it renders straight off `Shell`'s private state. use super::*; -use crate::pickers::{breadcrumbs, browser_rows, completion_prefix_len, parent_path}; +use crate::pickers::{ + breadcrumbs, browser_rows, completion_prefix_len, folder_path_key, parent_path, + same_folder_path, +}; use gpui::{FocusHandle, Window}; use std::collections::HashSet; -use zeron_proto::{ChatIndicator, Device, DriveEntry, DriveListing, FolderListing, Space}; +use zeron_proto::{ + ChatIndicator, Device, DriveEntry, DriveListing, FolderListing, HomeFolderMatch, + HomeFolderSearchResult, SearchHomeFoldersRequest, Space, +}; /// Promote the user's ordered pins above the untouched activity projection. /// Every unpinned id keeps exactly the relative order supplied by recency. @@ -2318,9 +2324,239 @@ pub(super) struct AddSpaceFlow { load_task: Option>, drives_task: Option>, submit_task: Option>, + /// Folders matching the query anywhere under the device's home (Home + /// location only). + home_search: HomeFolderSearch, _search_events: Subscription, } +/// Shorter queries match nearly every folder under home; they are not sent. +const HOME_FOLDER_MIN_QUERY_CHARS: usize = 2; +const HOME_FOLDER_RESULTS: u16 = 20; +const HOME_FOLDER_INDEX_RETRY: Duration = Duration::from_millis(500); +const HOME_FOLDER_INDEX_RETRY_MAX: Duration = Duration::from_secs(2); + +/// The new-project picker's search over the device's home, answered by the +/// host's home search index (the one projectless chats use). Entering Home +/// starts the index; leaving Home or closing the picker releases it unless a +/// projectless chat warmed it. +#[derive(Default)] +struct HomeFolderSearch { + /// Bumped per request; an answer to an older one is dropped. + generation: u64, + loading: bool, + /// The host's index was still scanning at the last answer. + indexing: bool, + results: Vec, + /// A request reached the host, so it may hold an index to release. + started: bool, + /// The host predates `SearchHomeFolders`; the section stays hidden. + unsupported: bool, + task: Option>, + /// The request that started the host's index. + warm_task: Option>, +} + +/// Refresh `SearchHomeFolders` until indexing finishes, backing off between +/// replies. `generation` identifies the query; `None` is a one-shot warm-up +/// whose answer only tells whether the host supports the method. +fn request_home_folders( + engine: crate::state::EngineHandle, + device_id: Option, + local: Option, + query: String, + generation: Option, + cx: &mut Context, +) -> Task<()> { + let request = SearchHomeFoldersRequest { + query, + limit: Some(HOME_FOLDER_RESULTS), + }; + cx.spawn(async move |this, cx| { + let mut params = match serde_json::to_value(&request) { + Ok(serde_json::Value::Object(params)) => params, + _ => return, + }; + add_space_target(&mut params, device_id.as_deref(), local.as_deref()); + let params = serde_json::Value::Object(params); + let mut delay = HOME_FOLDER_INDEX_RETRY; + loop { + let result = engine + .client() + .call(methods::SEARCH_HOME_FOLDERS, params.clone()) + .await; + let retry = this + .update(cx, |shell, cx| { + let selected = shell + .add_space + .as_ref() + .and_then(|flow| shell.add_space_folder_rows(cx).get(flow.active).cloned()); + let Some(search) = shell.add_space.as_mut().map(|flow| &mut flow.home_search) + else { + return false; + }; + if generation.is_some() && generation != Some(search.generation) { + return false; + } + if let Err(zeron_rpc::RpcError::UnknownMethod(_)) = &result { + search.unsupported = true; + search.started = false; + search.loading = false; + search.indexing = false; + cx.notify(); + return false; + } + if generation.is_none() || search.unsupported { + return false; + } + search.loading = false; + search.indexing = false; + match result.map(serde_json::from_value::) { + Ok(Ok(found)) => { + search.indexing = found.indexing; + search.results = found.matches; + } + Ok(Err(error)) => { + tracing::debug!(%error, "home folder search answer did not decode"); + } + Err(error) => tracing::debug!(%error, "home folder search failed"), + } + let retry = search.indexing; + // New partial results may reorder the rows under the cursor. + if let Some(selected) = selected + && let Some(active) = shell + .add_space_folder_rows(cx) + .iter() + .position(|row| row == &selected) + && let Some(flow) = shell.add_space.as_mut() + { + flow.active = active; + } + cx.notify(); + retry + }) + .unwrap_or(false); + if !retry { + break; + } + // The stored task is dropped on edits, navigation or close. Wait + // after the reply so slow hosts never accumulate parallel calls. + cx.background_executor().timer(delay).await; + delay = (delay * 2).min(HOME_FOLDER_INDEX_RETRY_MAX); + let current = this + .update(cx, |shell, _| { + shell.add_space_in_home() + && shell.add_space.as_ref().is_some_and(|flow| { + generation == Some(flow.home_search.generation) + && !flow.home_search.unsupported + }) + }) + .unwrap_or(false); + if !current { + break; + } + } + }) +} + +/// One row of the Folders step, in navigation order. +#[derive(Clone, Debug, PartialEq)] +enum FolderRow { + /// A subfolder of the open folder, matched by name. + Local(zeron_proto::FolderEntry), + /// A folder anywhere under home. + Home(HomeFolderMatch), +} + +/// `targetDeviceId` for a remote device; local calls skip the relay. +fn add_space_target( + params: &mut serde_json::Map, + device_id: Option<&str>, + local: Option<&str>, +) { + if let Some(target) = device_id + && local != Some(target) + { + params.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.to_owned()), + ); + } +} + +/// The title over the picker's matches from under home, with the host's +/// search status trailing. +fn home_folders_header(theme: &Theme, status: Option<&'static str>) -> gpui::Div { + div() + .px(px(8.0)) + .pt(px(2.0)) + .pb(px(4.0)) + .flex() + .items_center() + .gap(px(8.0)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child("In Home") + .when_some(status, |header, status| { + header.child(div().opacity(0.7).child(status)) + }) +} + +/// A folder from under home: its name (query highlighted), then where it +/// lives (`~/…`), marked when it is a repository. +fn home_folder_row( + row: gpui::Stateful, + found: &HomeFolderMatch, + query: &str, + theme: &Theme, + cx: &Context, +) -> gpui::Stateful { + let (name, parent) = match found.relative.rsplit_once('/') { + Some((parent, name)) => (name, format!("~/{parent}")), + None => (found.relative.as_str(), "~".to_owned()), + }; + let (path, is_repo) = (found.path.clone(), found.is_repo); + row.aria_label(SharedString::from(format!("{name} in {parent}"))) + .child( + icon(icons::FOLDER) + .size(px(16.0)) + .flex_none() + .text_color(theme.text_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_center() + .gap(px(8.0)) + .overflow_hidden() + .child(div().flex_none().child(popover::search_highlight( + SharedString::from(name.to_owned()), + Some(query), + theme, + ))) + .child( + div() + .min_w_0() + .truncate() + .text_color(theme.text_muted) + .child(SharedString::from(parent)), + ), + ) + .when(is_repo, |el| { + el.child( + icon(icons::GIT_BRANCH) + .size(px(14.0)) + .flex_none() + .text_color(theme.text_muted), + ) + }) + .on_click( + cx.listener(move |this, _, _, cx| this.add_space_descend(path.clone(), is_repo, cx)), + ) +} + /// Folder crumbs shown before the middle folds into `…`, and how many of the /// deepest stay visible once it does. const CRUMB_FOLDERS_MAX: usize = 3; @@ -2366,6 +2602,8 @@ fn device_glyph(platform: &str) -> &'static str { /// `/media/ab`); a root base covers everything. Either separator counts, so /// Windows drive paths (`D:\` under `D:\`) work too. fn path_under(path: &str, base: &str) -> bool { + let path = folder_path_key(path); + let base = folder_path_key(base); let base = base.trim_end_matches(['/', '\\']); base.is_empty() || path @@ -5706,6 +5944,7 @@ impl Shell { } pub(super) fn open_add_space(&mut self, cx: &mut Context) { + self.release_home_folder_search(cx); self.command_palette = None; self.project_crumb_menu = popover::Popup::default(); // "PaletteSearch" context: navigation keys stay unbound so ↑↓/←/→/⏎ @@ -5726,6 +5965,7 @@ impl Shell { flow.active = 0; flow.list_scroll.set_offset(gpui::Point::default()); } + this.search_home_folders(cx); cx.notify(); } }); @@ -5750,11 +5990,109 @@ impl Shell { load_task: None, drives_task: None, submit_task: None, + home_search: HomeFolderSearch::default(), _search_events: search_events, }); cx.notify(); } + /// Close the new-project picker, releasing the host's home index. + pub(super) fn close_add_space(&mut self, cx: &mut Context) { + self.release_home_folder_search(cx); + self.add_space = None; + cx.notify(); + } + + /// Whether the picker browses the Home location, where the query also + /// searches every folder under home. + fn add_space_in_home(&self) -> bool { + self.add_space.as_ref().is_some_and(|flow| { + flow.step == ProjectStep::Folders + && flow + .location + .as_ref() + .is_some_and(|(_, path)| path.is_none()) + }) + } + + /// Look the query up under the device's home. The first call in Home + /// also starts the host's index, so the first real query is fast. + fn search_home_folders(&mut self, cx: &mut Context) { + let in_home = self.add_space_in_home(); + let engine = self.state.read(cx).engine().cloned(); + let local = self.state.read(cx).local_device_id.clone(); + let Some(flow) = self.add_space.as_mut() else { + return; + }; + let query = flow.search.read(cx).text().trim().to_owned(); + let device_id = flow.device.as_ref().map(|d| d.id.clone()); + let search = &mut flow.home_search; + search.generation += 1; + search.loading = false; + search.indexing = false; + search.task = None; + search.results.clear(); + let Some(engine) = engine.filter(|_| in_home && !search.unsupported) else { + return; + }; + if !search.started { + // Its own task: typing must not cancel it. + search.started = true; + search.warm_task = Some(request_home_folders( + engine.clone(), + device_id.clone(), + local.clone(), + String::new(), + None, + cx, + )); + } + if query.chars().count() < HOME_FOLDER_MIN_QUERY_CHARS + || crate::pickers::is_typed_path(&query) + { + return; + } + search.loading = true; + let generation = Some(search.generation); + search.task = Some(request_home_folders( + engine, device_id, local, query, generation, cx, + )); + } + + /// Tell the host the picker is done with its home index (it stays when a + /// projectless chat warmed it). + fn release_home_folder_search(&mut self, cx: &mut Context) { + let engine = self.state.read(cx).engine().cloned(); + let local = self.state.read(cx).local_device_id.clone(); + let Some(flow) = self.add_space.as_mut() else { + return; + }; + let search = std::mem::take(&mut flow.home_search); + flow.home_search.unsupported = search.unsupported; + let (true, Some(engine)) = (search.started, engine) else { + return; + }; + let mut params = serde_json::Map::new(); + add_space_target( + &mut params, + flow.device.as_ref().map(|d| d.id.as_str()), + local.as_deref(), + ); + cx.spawn(async move |_, _| { + if let Err(error) = engine + .client() + .call( + methods::RELEASE_HOME_FOLDER_SEARCH, + serde_json::Value::Object(params), + ) + .await + { + tracing::debug!(%error, "home folder search release failed"); + } + }) + .detach(); + } + /// Selecting a device advances to its locations. fn add_space_pick_device(&mut self, device: Device, cx: &mut Context) { let Some(flow) = self.add_space.as_mut() else { @@ -5789,6 +6127,9 @@ impl Shell { path: Option, cx: &mut Context, ) { + if path.is_some() { + self.release_home_folder_search(cx); + } let Some(flow) = self.add_space.as_mut() else { return; }; @@ -5802,9 +6143,12 @@ impl Shell { input.set_text("", cx); }); self.load_space_folders(path, cx); + // Entering Home starts the host's index (an empty query only warms). + self.search_home_folders(cx); } fn add_space_back_to(&mut self, step: ProjectStep, cx: &mut Context) { + self.release_home_folder_search(cx); let Some(flow) = self.add_space.as_mut() else { return; }; @@ -5936,6 +6280,41 @@ impl Shell { .collect() } + /// The Folders step's rows: the open folder's matching subfolders, then + /// matches from anywhere under home not already listed. + fn add_space_folder_rows(&self, cx: &App) -> Vec { + let local = self.add_space_filtered(cx); + let Some(flow) = self.add_space.as_ref() else { + return Vec::new(); + }; + let open = flow.browser.ready().map(|listing| listing.path.as_str()); + let listed: HashSet = open + .map(|open| { + local + .iter() + .map(|entry| crate::pickers::child_path(open, &entry.name)) + .chain(std::iter::once(open.to_owned())) + .map(|path| folder_path_key(&path)) + .collect() + }) + .unwrap_or_default(); + let home = if self.add_space_in_home() { + flow.home_search.results.as_slice() + } else { + &[] + }; + local + .into_iter() + .map(FolderRow::Local) + .chain( + home.iter() + .filter(|found| !listed.contains(&folder_path_key(&found.path))) + .cloned() + .map(FolderRow::Home), + ) + .collect() + } + /// Descend into the highlighted (filtered) folder; clears the query. /// A path-shaped query with no matching rows browses the typed path /// instead — `/disk2⏎` must work, not sit on "No folders match" (an @@ -5959,7 +6338,7 @@ impl Shell { } ProjectStep::Folders => {} } - let rows = self.add_space_filtered(cx); + let rows = self.add_space_folder_rows(cx); let Some(flow) = self.add_space.as_ref() else { return; }; @@ -5970,10 +6349,16 @@ impl Shell { } return; } - let Some(listing) = flow.browser.ready() else { - return; + let entry = match rows.get(flow.active) { + Some(FolderRow::Local(entry)) => entry, + Some(FolderRow::Home(found)) => { + let (path, is_repo) = (found.path.clone(), found.is_repo); + self.add_space_descend(path, is_repo, cx); + return; + } + None => return, }; - let Some(entry) = rows.get(flow.active) else { + let Some(listing) = flow.browser.ready() else { return; }; let full = crate::pickers::child_path(&listing.path, &entry.name); @@ -6187,10 +6572,10 @@ impl Shell { .read(cx) .spaces .iter() - .find(|s| s.device_id == device.id && s.path == path) + .find(|s| s.device_id == device.id && same_folder_path(&s.path, &path)) .map(|s| s.id.clone()) { - self.add_space = None; + self.close_add_space(cx); self.land_in_space(existing, cx); return; } @@ -6232,7 +6617,7 @@ impl Shell { this.update(cx, |shell, cx| { match result { Ok(_) => { - shell.add_space = None; + shell.close_add_space(cx); shell.land_in_space(submit_id.clone(), cx); } Err(err) => { @@ -6273,7 +6658,7 @@ impl Shell { .and_then(|(_, path)| path.as_deref()) .or(flow.home.as_deref()); let parent = listing - .filter(|l| Some(l.path.as_str()) != root) + .filter(|l| !root.is_some_and(|root| same_folder_path(&l.path, root))) .and_then(|l| parent_path(&l.path)); if let Some(parent) = parent { self.add_space_descend(parent, false, cx); @@ -6317,15 +6702,14 @@ impl Shell { ); match key { popover::MenuKey::Escape => { - self.add_space = None; - cx.notify(); + self.close_add_space(cx); cx.stop_propagation(); } popover::MenuKey::Up | popover::MenuKey::Down => { let count = match self.add_space.as_ref().map(|f| f.step) { Some(ProjectStep::Devices) => self.add_space_devices(cx).len(), Some(ProjectStep::Locations) => self.add_space_locations(cx).len(), - _ => self.add_space_filtered(cx).len(), + _ => self.add_space_folder_rows(cx).len(), }; let delta = if key == popover::MenuKey::Up { -1 } else { 1 }; if let Some(flow) = self.add_space.as_mut() { @@ -6435,6 +6819,14 @@ impl Shell { let active = flow.active; let loading = matches!(flow.browser, Loadable::Idle | Loadable::Loading); let drives_loading = matches!(flow.drives, Loadable::Loading); + let home_loading = flow.home_search.loading; + let home_status = if flow.home_search.indexing { + Some("Indexing…") + } else if home_loading { + Some("Searching…") + } else { + None + }; let ghost = self .add_space_completion(cx) .map(|(_, suffix)| SharedString::from(suffix)); @@ -6523,8 +6915,27 @@ impl Shell { } ProjectStep::Folders => { if !loading && load_error.is_none() { - for (ix, entry) in self.add_space_filtered(cx).into_iter().enumerate() { - let base = listing.as_ref().map(|l| l.path.as_str()).unwrap_or(""); + let base = listing.as_ref().map(|l| l.path.as_str()).unwrap_or(""); + let mut first_home = true; + for (ix, folder) in self.add_space_folder_rows(cx).into_iter().enumerate() { + let entry = match folder { + FolderRow::Local(entry) => entry, + FolderRow::Home(found) => { + let el = home_folder_row(row(ix), &found, &query, &theme, cx); + rows.push(if std::mem::take(&mut first_home) { + div() + .flex() + .flex_col() + .when(ix > 0, |el| el.pt(px(6.0))) + .child(home_folders_header(&theme, home_status)) + .child(el) + .into_any_element() + } else { + el.into_any_element() + }); + continue; + } + }; let full = crate::pickers::child_path(base, &entry.name); let is_repo = entry.is_repo; rows.push( @@ -6604,6 +7015,14 @@ impl Shell { crate::settings::badge_combo("mod-enter") ), ), + ProjectStep::Folders if home_loading => ( + "Searching Home…", + "Looking for matching folders anywhere under Home.".to_string(), + ), + ProjectStep::Folders if home_status == Some("Indexing…") => ( + "No folders match yet", + "Home is still being indexed — keep typing or try again.".to_string(), + ), ProjectStep::Folders => ( "No folders match", "Type a path like ~/code or /mnt to jump there.".to_string(), @@ -6652,7 +7071,11 @@ impl Shell { .map(|l| l.path.clone()) .or(browser_path) .or(root.clone()); - let at_root = open_path.is_none() || open_path == root; + let at_root = open_path.is_none() + || open_path + .as_deref() + .zip(root.as_deref()) + .is_some_and(|(open, root)| same_folder_path(open, root)); crumb_key.push_str(&name); specs.push(Crumb { name: name.clone().into(), @@ -6683,7 +7106,7 @@ impl Shell { specs.push(Crumb { name: name.into(), glyph: None, - current: full == open_path, + current: same_folder_path(&full, &open_path), target: CrumbTarget::Folder(full), }); } @@ -6845,7 +7268,7 @@ impl Shell { .hover(|s| s.bg(theme.element_hover)) .on_click(cx.listener(|this, _, window, cx| { if this.add_space.as_ref().map(|f| f.step) == Some(ProjectStep::Devices) { - this.add_space = None; + this.close_add_space(cx); this.toggle_command_palette(window, cx); } else { this.add_space_go_up(cx); @@ -6936,8 +7359,7 @@ impl Shell { if this.project_crumb_menu.get().is_some() { return; } - this.add_space = None; - cx.notify(); + this.close_add_space(cx); })) .child(command_palette::palette_header( &theme, @@ -7335,6 +7757,13 @@ mod project_flow_tests { assert!(path_under(r"D:\Random", r"D:\")); assert!(!path_under(r"D:\Random2", r"D:\Random")); assert!(!path_under(r"C:\Random", r"D:\")); + assert!(path_under(r"c:\Random\repo", r"C:\Random")); + assert!(!path_under(r"C:\random\repo", r"C:\Random")); + assert!(path_under(r"\\?\D:\Random", r"D:\")); + assert!(path_under(r"D:\Random", r"\\?\D:\")); + assert!(path_under(r"\\?\UNC\server\share\repo", r"\\server\share\")); + assert!(!path_under(r"\\server\share2\repo", r"\\server\share\")); + assert!(!path_under(r"\\other\share\repo", r"\\server\share\")); } #[gpui::test] @@ -7413,3 +7842,580 @@ mod project_flow_tests { }); } } + +#[cfg(test)] +mod home_folder_search_tests { + use super::*; + use gpui::TestAppContext; + + /// The engine side of a shell under test. ListFolders answers with + /// `listing`; methods not being awaited answer `{}`. + struct FakeEngine { + runtime: tokio::runtime::Runtime, + requests: tokio::sync::mpsc::Receiver, + replies: tokio::sync::mpsc::Sender, + listing: serde_json::Value, + } + + impl FakeEngine { + fn take(&mut self, method: &str, cx: &mut TestAppContext) -> Vec { + let mut found = Vec::new(); + loop { + cx.run_until_parked(); + let Ok(request) = self.requests.try_recv() else { + return found; + }; + let request: serde_json::Value = serde_json::from_str(&request).unwrap(); + if request["method"] == method { + found.push(request); + } else if request["method"] == methods::LIST_FOLDERS { + let mut listing = self.listing.clone(); + if let Some(path) = request["params"]["path"].as_str() { + listing["path"] = path.into(); + listing["entries"] = serde_json::json!([]); + } + self.reply(&request, listing); + } else { + self.reply(&request, serde_json::json!({})); + } + } + } + + fn reply(&self, request: &serde_json::Value, ok: serde_json::Value) { + self.send(serde_json::json!({ "id": request["id"], "ok": ok })); + } + + fn fail(&self, request: &serde_json::Value, err: &str) { + self.send(serde_json::json!({ "id": request["id"], "err": err })); + } + + fn send(&self, reply: serde_json::Value) { + // The client's reader task runs on this runtime: let it drain. + self.runtime.block_on(async { + self.replies.send(reply.to_string()).await.unwrap(); + while self.replies.capacity() < self.replies.max_capacity() { + tokio::task::yield_now().await; + } + }); + } + } + + fn device(id: &str) -> Device { + serde_json::from_value(serde_json::json!({ + "id": id, "name": id, "platform": "linux", "lastSeenAt": null, + })) + .unwrap() + } + + /// A shell with the new-project picker open on `device` (local: "local"). + fn picker( + cx: &mut TestAppContext, + device_id: &str, + ) -> (gpui::WindowHandle, tempfile::TempDir, FakeEngine) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (out, requests) = tokio::sync::mpsc::channel(64); + let (replies, inbound) = tokio::sync::mpsc::channel(64); + let engine = { + let _guard = runtime.enter(); + crate::state::EngineHandle::from_test_client(zeron_rpc::RpcClient::new(out, inbound)) + }; + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let data_dir = dir.path().to_path_buf(); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| crate::state::AppState::new()); + Shell::new( + state, + crate::EngineBootConfig { + data_dir, + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + let picked = device(device_id); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, _| { + state.local_device_id = Some("local".into()); + state.devices = vec![picked.clone()]; + state.set_test_engine(engine); + }); + shell.open_add_space(cx); + shell.add_space_pick_device(picked, cx); + }) + .unwrap(); + let fake = FakeEngine { + runtime, + requests, + replies, + listing: serde_json::json!({ + "path": "/home/me", + "entries": [{ "name": "code", "isDir": true, "isRepo": false }], + }), + }; + (window, dir, fake) + } + + fn go_home(window: gpui::WindowHandle, cx: &mut TestAppContext) { + window + .update(cx, |shell, _, cx| { + shell.add_space_goto_location("Home".into(), None, cx) + }) + .unwrap(); + } + + fn type_query(window: gpui::WindowHandle, query: &str, cx: &mut TestAppContext) { + window + .update(cx, |shell, _, cx| { + let search = shell.add_space.as_ref().unwrap().search.clone(); + search.update(cx, |input, cx| input.set_text(query, cx)); + }) + .unwrap(); + } + + fn rows(window: gpui::WindowHandle, cx: &mut TestAppContext) -> Vec { + window + .read_with(cx, |shell, cx| shell.add_space_folder_rows(cx)) + .unwrap() + } + + fn found(path: &str, relative: &str, is_repo: bool) -> serde_json::Value { + serde_json::json!({ "path": path, "relative": relative, "isRepo": is_repo }) + } + + fn begin_home_search( + cx: &mut TestAppContext, + ) -> ( + gpui::WindowHandle, + tempfile::TempDir, + FakeEngine, + serde_json::Value, + ) { + let (window, dir, mut engine) = picker(cx, "remote"); + go_home(window, cx); + let warm = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(warm.len(), 1); + engine.reply( + &warm[0], + serde_json::json!({ "matches": [], "indexing": true }), + ); + cx.run_until_parked(); + // Merely warming Home must not start polling an empty query. + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + type_query(window, "comet", cx); + let request = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(request.len(), 1); + (window, dir, engine, request.into_iter().next().unwrap()) + } + + #[gpui::test] + fn home_search_refreshes_with_capped_backoff_and_no_overlapping_requests( + cx: &mut TestAppContext, + ) { + let (window, _dir, mut engine, first) = begin_home_search(cx); + engine.reply( + &first, + serde_json::json!({ "matches": [], "indexing": true }), + ); + cx.run_until_parked(); + assert!(rows(window, cx).is_empty()); + let comet = found("/home/me/dev/comet", "dev/comet", true); + for (round, millis) in [500, 1000, 2000, 2000].into_iter().enumerate() { + cx.executor() + .advance_clock(Duration::from_millis(millis - 1)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + cx.executor().advance_clock(Duration::from_millis(1)); + let requests = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["params"], first["params"]); + assert_eq!(requests[0]["params"]["targetDeviceId"], "remote"); + // A slow response does not queue more requests or erase rows. + let partial = rows(window, cx); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + assert_eq!(rows(window, cx), partial); + let matches = if round == 0 { + vec![comet.clone()] + } else { + vec![ + found("/home/me/new/comet", "new/comet", true), + comet.clone(), + ] + }; + engine.reply( + &requests[0], + serde_json::json!({ + "matches": matches, "indexing": round < 3, + }), + ); + cx.run_until_parked(); + window + .read_with(cx, |shell, cx| { + let flow = shell.add_space.as_ref().unwrap(); + assert_eq!(flow.home_search.indexing, round < 3); + assert!(!flow.home_search.loading); + // The original selected folder survives result reordering. + assert!(matches!( + &shell.add_space_folder_rows(cx)[flow.active], + FolderRow::Home(found) if found.relative == "dev/comet" + )); + }) + .unwrap(); + } + assert_eq!(rows(window, cx).len(), 2); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + } + + #[gpui::test] + fn home_search_cancels_retries_on_edits_navigation_and_close(cx: &mut TestAppContext) { + for action in ["clear", "short", "path", "drive", "device", "close"] { + let (window, _dir, mut engine, first) = begin_home_search(cx); + engine.reply( + &first, + serde_json::json!({ "matches": [], "indexing": true }), + ); + cx.run_until_parked(); + match action { + "clear" => type_query(window, "", cx), + "short" => type_query(window, "c", cx), + "path" => type_query(window, "/mnt/data", cx), + _ => window + .update(cx, |shell, _, cx| match action { + "drive" => shell.add_space_goto_location( + "Data".into(), + Some("/mnt/data".into()), + cx, + ), + "device" => { + shell.add_space_back_to(ProjectStep::Devices, cx); + shell.add_space_pick_device(device("local"), cx); + } + "close" => shell.close_add_space(cx), + _ => unreachable!(), + }) + .unwrap(), + } + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty(), + "{action}" + ); + window + .read_with(cx, |shell, _| { + assert!( + !shell + .add_space + .as_ref() + .is_some_and(|flow| flow.home_search.indexing) + ); + }) + .unwrap(); + } + } + + #[gpui::test] + fn new_home_query_resets_backoff_and_ignores_in_flight_answers(cx: &mut TestAppContext) { + let (window, _dir, mut engine, first) = begin_home_search(cx); + engine.reply( + &first, + serde_json::json!({ "matches": [], "indexing": true }), + ); + cx.run_until_parked(); + cx.executor().advance_clock(HOME_FOLDER_INDEX_RETRY); + let stale = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(stale.len(), 1); + type_query(window, "updated", cx); + let updated = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0]["params"]["query"], "updated"); + engine.reply( + &updated[0], + serde_json::json!({ "matches": [], "indexing": true }), + ); + engine.reply( + &stale[0], + serde_json::json!({ + "matches": [found("/home/me/dev/comet", "dev/comet", true)], "indexing": true, + }), + ); + cx.run_until_parked(); + assert!(rows(window, cx).is_empty()); + cx.executor() + .advance_clock(HOME_FOLDER_INDEX_RETRY - Duration::from_millis(1)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + cx.executor().advance_clock(Duration::from_millis(1)); + let retry = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(retry.len(), 1); + assert_eq!(retry[0]["params"], updated[0]["params"]); + engine.reply( + &retry[0], + serde_json::json!({ "matches": [], "indexing": false }), + ); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + } + + #[gpui::test] + fn home_search_stops_retrying_after_errors(cx: &mut TestAppContext) { + for error in ["failed", "unsupported", "decode"] { + let (window, _dir, mut engine, first) = begin_home_search(cx); + engine.reply( + &first, + serde_json::json!({ "matches": [], "indexing": true }), + ); + cx.run_until_parked(); + cx.executor().advance_clock(HOME_FOLDER_INDEX_RETRY); + let retry = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(retry.len(), 1); + match error { + "failed" => engine.fail(&retry[0], "search failed"), + "unsupported" => engine.fail(&retry[0], "unknown method: SearchHomeFolders"), + "decode" => engine.reply(&retry[0], serde_json::json!({})), + _ => unreachable!(), + } + cx.run_until_parked(); + window + .read_with(cx, |shell, _| { + let search = &shell.add_space.as_ref().unwrap().home_search; + assert!(!search.indexing); + assert!(!search.loading); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_secs(10)); + assert!( + engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty(), + "{error}" + ); + } + } + + #[gpui::test] + fn home_warms_on_entry_and_lists_matches_below_the_open_folder(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = picker(cx, "local"); + go_home(window, cx); + let warm = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(warm.len(), 1); + assert_eq!(warm[0]["params"]["query"], ""); + assert!(warm[0]["params"].get("targetDeviceId").is_none()); + engine.reply( + &warm[0], + serde_json::json!({ "matches": [], "indexing": true }), + ); + + type_query(window, "c", cx); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + type_query(window, "co", cx); + let search = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(search[0]["params"]["query"], "co"); + engine.reply( + &search[0], + serde_json::json!({ + "matches": [ + found("/home/me/code", "code", false), + found("/home/me/dev/comet", "dev/comet", true), + ], + "indexing": false, + }), + ); + cx.run_until_parked(); + // The open folder's own `code` is listed once, as a local row. + let rows = rows(window, cx); + assert_eq!(rows.len(), 2); + assert!(matches!(&rows[0], FolderRow::Local(entry) if entry.name == "code")); + assert!(matches!(&rows[1], FolderRow::Home(m) if m.relative == "dev/comet" && m.is_repo)); + } + + #[gpui::test] + fn remote_windows_home_matches_navigate_and_reuse_existing_spaces(cx: &mut TestAppContext) { + for (home, extended_home) in [ + (r"C:\Users\Ana", r"\\?\C:\Users\Ana"), + (r"\\server\share\Ana", r"\\?\UNC\server\share\Ana"), + ] { + let (window, _dir, mut engine) = picker(cx, "remote"); + cx.update(|cx| { + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + }); + engine.listing["path"] = home.into(); + go_home(window, cx); + let warm = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + engine.reply( + &warm[0], + serde_json::json!({ "matches": [], "indexing": false }), + ); + type_query(window, "co", cx); + let search = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + let code = format!("{extended_home}\\code"); + let comet = format!("{code}\\comet"); + engine.reply( + &search[0], + serde_json::json!({ + "matches": [found(&code, "code", false), found(&comet, "code/comet", true)], + "indexing": false, + }), + ); + cx.run_until_parked(); + // Plain ListFolders and extended SearchHomeFolders paths refer + // to the same local row; only the nested match is added. + let listed = rows(window, cx); + assert_eq!(listed.len(), 2); + assert!(matches!(&listed[0], FolderRow::Local(entry) if entry.name == "code")); + window + .update(cx, |shell, _, cx| { + shell.add_space.as_mut().unwrap().active = 1; + shell.add_space_open_active(cx); + }) + .unwrap(); + let browse = engine.take(methods::LIST_FOLDERS, cx); + assert_eq!(browse[0]["params"]["path"], comet); + assert_eq!(browse[0]["params"]["targetDeviceId"], "remote"); + engine.reply( + &browse[0], + serde_json::json!({ + "path": comet, + "entries": [{ "name": "src", "isDir": true, "isRepo": false }], + }), + ); + cx.run_until_parked(); + window + .update(cx, |shell, _, cx| shell.add_space_open_active(cx)) + .unwrap(); + let child = engine.take(methods::LIST_FOLDERS, cx); + assert_eq!(child[0]["params"]["path"], format!("{comet}\\src")); + engine.reply( + &child[0], + serde_json::json!({ "path": format!("{comet}\\src"), "entries": [] }), + ); + cx.run_until_parked(); + window + .update(cx, |shell, _, cx| shell.add_space_go_up(cx)) + .unwrap(); + let parent = engine.take(methods::LIST_FOLDERS, cx); + assert_eq!(parent[0]["params"]["path"], comet); + engine.reply( + &parent[0], + serde_json::json!({ "path": comet, "entries": [] }), + ); + cx.run_until_parked(); + window.update(cx, |shell, _, cx| { + shell.state.update(cx, |state, _| { + state.spaces.push(serde_json::from_value(serde_json::json!({ + "id": "existing", "deviceId": "remote", "path": format!("{home}\\code\\comet"), + "gitDetected": true, "createdAt": "2026-09-20T00:00:00Z", + })).unwrap()); + }); + shell.submit_add_space(cx); + assert!(shell.add_space.is_none()); + assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("existing")); + assert_eq!(shell.state.read(cx).spaces.len(), 1); + }).unwrap(); + assert!(engine.take(methods::MUTATE, cx).is_empty()); + } + } + + #[gpui::test] + fn opening_a_home_match_browses_it(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = picker(cx, "local"); + go_home(window, cx); + engine.take(methods::SEARCH_HOME_FOLDERS, cx); + type_query(window, "comet", cx); + let search = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + engine.reply( + &search[0], + serde_json::json!({ + "matches": [found("/home/me/dev/comet", "dev/comet", true)], + "indexing": false, + }), + ); + cx.run_until_parked(); + window + .update(cx, |shell, _, cx| shell.add_space_open_active(cx)) + .unwrap(); + let browse = engine.take(methods::LIST_FOLDERS, cx); + assert_eq!(browse[0]["params"]["path"], "/home/me/dev/comet"); + window + .read_with(cx, |shell, _| { + let flow = shell.add_space.as_ref().unwrap(); + assert!(flow.browser_repo); + }) + .unwrap(); + } + + #[gpui::test] + fn closing_or_leaving_home_releases_the_index(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = picker(cx, "remote"); + go_home(window, cx); + let warm = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + assert_eq!(warm[0]["params"]["targetDeviceId"], "remote"); + // A drive location neither searches nor keeps the home index. + window + .update(cx, |shell, _, cx| { + shell.add_space_goto_location("Data".into(), Some("/mnt/data".into()), cx) + }) + .unwrap(); + let released = engine.take(methods::RELEASE_HOME_FOLDER_SEARCH, cx); + assert_eq!(released.len(), 1); + assert_eq!(released[0]["params"]["targetDeviceId"], "remote"); + type_query(window, "comet", cx); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + + go_home(window, cx); + engine.take(methods::SEARCH_HOME_FOLDERS, cx); + window + .update(cx, |shell, _, cx| shell.close_add_space(cx)) + .unwrap(); + assert_eq!( + engine.take(methods::RELEASE_HOME_FOLDER_SEARCH, cx).len(), + 1 + ); + // Nothing left to release. + window + .update(cx, |shell, _, cx| shell.close_add_space(cx)) + .unwrap(); + assert!( + engine + .take(methods::RELEASE_HOME_FOLDER_SEARCH, cx) + .is_empty() + ); + } + + #[gpui::test] + fn an_older_host_is_not_asked_again(cx: &mut TestAppContext) { + let (window, _dir, mut engine) = picker(cx, "local"); + go_home(window, cx); + let warm = engine.take(methods::SEARCH_HOME_FOLDERS, cx); + engine.fail(&warm[0], "unknown method: SearchHomeFolders"); + cx.run_until_parked(); + type_query(window, "comet", cx); + assert!(engine.take(methods::SEARCH_HOME_FOLDERS, cx).is_empty()); + window + .update(cx, |shell, _, cx| shell.close_add_space(cx)) + .unwrap(); + assert!( + engine + .take(methods::RELEASE_HOME_FOLDER_SEARCH, cx) + .is_empty() + ); + } +} diff --git a/crates/ui/src/shell/thread_search.rs b/crates/ui/src/shell/thread_search.rs new file mode 100644 index 0000000000..7af8d62de1 --- /dev/null +++ b/crates/ui/src/shell/thread_search.rs @@ -0,0 +1,166 @@ +//! Scored thread search for the command palette, modeled on the mobile +//! workspace view's `score_row` (`zeron_client`): every query word must match +//! some field; a match scores its field's weight, plus half again when it +//! starts a word. Archived threads score half, and ties go to the most +//! recently active thread. + +/// Field weights: a title hit outranks any number of metadata hits. +const TITLE: u32 = 100; +const PROJECT: u32 = 40; +const BRANCH: u32 = 30; +const DEVICE: u32 = 20; +const PULL_REQUEST: u32 = 20; + +/// The searchable text of one thread. +#[derive(Debug, Default, Clone, Copy)] +pub(super) struct ThreadFields<'a> { + pub title: &'a str, + pub project: &'a str, + pub branch: &'a str, + pub device: &'a str, + /// Number, title and refs of the thread's pull request. + pub pull_request: &'a str, + pub archived: bool, +} + +/// Lowercased whitespace-separated query words. +pub(super) fn search_terms(query: &str) -> Vec { + query.split_whitespace().map(str::to_lowercase).collect() +} + +/// `None` when some term matches no field (or there are no terms). +pub(super) fn score_thread(fields: &ThreadFields, terms: &[String]) -> Option { + if terms.is_empty() { + return None; + } + let haystacks = [ + (fields.title.to_lowercase(), TITLE), + (fields.project.to_lowercase(), PROJECT), + (fields.branch.to_lowercase(), BRANCH), + (fields.device.to_lowercase(), DEVICE), + (fields.pull_request.to_lowercase(), PULL_REQUEST), + ]; + let mut total = 0; + for term in terms { + total += haystacks + .iter() + .filter_map(|(haystack, weight)| field_score(haystack, term, *weight)) + .max()?; + } + Some(if fields.archived { total / 2 } else { total }) +} + +fn field_score(haystack: &str, term: &str, weight: u32) -> Option { + let mut best = None; + for (at, _) in haystack.match_indices(term) { + let starts_word = haystack[..at] + .chars() + .next_back() + .is_none_or(|previous| !previous.is_alphanumeric()); + if starts_word { + return Some(weight + weight / 2); + } + best = Some(weight); + } + best +} + +/// Best first: higher score, then more recent activity, then the caller's +/// order. Keeps at most `limit`. +pub(super) fn rank(mut scored: Vec<(T, u32, i64)>, limit: usize) -> Vec { + scored.sort_by(|(_, score_a, active_a), (_, score_b, active_b)| { + score_b.cmp(score_a).then(active_b.cmp(active_a)) + }); + scored.truncate(limit); + scored.into_iter().map(|(item, _, _)| item).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn title(title: &str) -> ThreadFields<'_> { + ThreadFields { + title, + ..Default::default() + } + } + + #[test] + fn every_word_must_match_some_field() { + let fields = ThreadFields { + title: "Fix authentication", + device: "MacBook", + ..Default::default() + }; + assert!(score_thread(&fields, &search_terms("mac auth")).is_some()); + assert!(score_thread(&fields, &search_terms("mac windows")).is_none()); + assert!(score_thread(&fields, &search_terms(" ")).is_none()); + } + + #[test] + fn fields_are_weighted_title_first() { + let terms = search_terms("search"); + let in_title = score_thread(&title("search"), &terms).unwrap(); + let in_project = score_thread( + &ThreadFields { + project: "search", + ..Default::default() + }, + &terms, + ) + .unwrap(); + let in_branch = score_thread( + &ThreadFields { + branch: "search", + ..Default::default() + }, + &terms, + ) + .unwrap(); + let in_pr = score_thread( + &ThreadFields { + pull_request: "#12 search", + ..Default::default() + }, + &terms, + ) + .unwrap(); + assert!(in_title > in_project && in_project > in_branch && in_branch > in_pr); + } + + #[test] + fn word_starts_earn_a_bonus() { + let terms = search_terms("cache"); + let start = score_thread(&title("Fix cache eviction"), &terms).unwrap(); + let inside = score_thread(&title("Fix precached rows"), &terms).unwrap(); + assert_eq!(start, 150); + assert_eq!(inside, 100); + // A later word start still wins over an earlier mid-word hit. + assert_eq!(score_thread(&title("precache cache"), &terms).unwrap(), 150); + } + + #[test] + fn archived_threads_score_half() { + let terms = search_terms("deploy"); + let active = score_thread(&title("deploy"), &terms).unwrap(); + let archived = score_thread( + &ThreadFields { + archived: true, + ..title("deploy") + }, + &terms, + ) + .unwrap(); + assert_eq!(archived, active / 2); + } + + #[test] + fn ties_break_by_recent_activity() { + let ranked = rank( + vec![("old", 150, 10), ("new", 150, 20), ("best", 200, 0)], + 2, + ); + assert_eq!(ranked, vec!["best", "new"]); + } +} diff --git a/docs/file-tree-interactions.md b/docs/file-tree-interactions.md index 7810271376..76138db68a 100644 --- a/docs/file-tree-interactions.md +++ b/docs/file-tree-interactions.md @@ -33,6 +33,17 @@ Folders opened by hover stay open. The chat column retains its own drop zone, so a gesture ending there adds a reference instead of moving the entry. OS file attachments continue through the existing composer attachment pipeline. +## Search + +The explorer's search box queries the host's workspace search index (fff; see +[performance-workspace-search.md](performance-workspace-search.md)), so a query +no longer walks the folder. Matching is fuzzy and typo-tolerant: exact file +names and recently changed files rank first, and new, renamed or deleted +entries appear without reopening the tree. Ignored files, symbolic links and, +outside git repositories, dotfiles are not indexed. With "show ignored" on, the +search walks the folder directly instead and matches names and paths as plain +case-insensitive substrings. + ## Workspace and editor consistency Operations run through the owning engine, locally or remotely. Directory pages diff --git a/docs/performance-workspace-search.md b/docs/performance-workspace-search.md new file mode 100644 index 0000000000..e09b708495 --- /dev/null +++ b/docs/performance-workspace-search.md @@ -0,0 +1,148 @@ +# Workspace search on fff + +One engine-side index, [`fff-search`](https://crates.io/crates/fff-search) +`=0.11.0`, now serves every workspace search surface: + +| Surface | RPC | Before | Now | +| --- | --- | --- | --- | +| Composer `@` | `SearchFiles` | `nucleo-matcher` over a walk cached for 10 s | fff names (files and folders) | +| File tree search | `SearchWorkspaceFiles` | a full disk walk per query | fff names; "show ignored" still walks | +| cmd+K Files | `SearchWorkspaceFiles` | — | fff names, focused chat's folder | +| cmd+K In Files | `SearchWorkspaceContent` (new) | — | fff plain-text grep, smart case | + +`WarmWorkspaceSearch` (new) lets the UI start an index before the first query. +Both new methods are forwardable, so remote chats search on their host. Older +hosts answer `UnknownMethod`: warm-ups are skipped silently and the palette's +In Files section explains that the device needs an update. + +The code lives in `crates/engine/src/workspace_search.rs` (`WorkspaceSearch`, +owned by `Repos`). `SearchFiles` and `SearchWorkspaceFiles` keep their wire +shapes; iOS's `@` uses `SearchFiles` and only sees the new ranking. + +## Index lifecycle + +One fff file picker per working folder (repository, worktree or plain +folder), keyed by the canonical root that `WorkspaceFiles::resolve_target` +returns: + +- Created by the first warm-up or search of that root. It scans in the + background and keeps itself current with its own file watcher. +- A search waits up to 300 ms (`SCAN_WAIT`) for the initial scan, then answers + from whatever is indexed; content answers carry `indexing: true` then. +- Dropped after 15 minutes without a search or warm-up (`IDLE_EVICT`; a + reaper thread checks every 60 s, so up to 16 minutes in practice). +- At most 7 live indexes (`MAX_LIVE`); creating an eighth evicts the least + recently used. Each index runs its own file watcher, so the cap also bounds + inotify instances. +- The UI warms the focused chat's index once, when the chat gains focus. There + is no heartbeat or pinning: a focused chat left without searches for 15 + minutes loses its index and the next search (or refocus) rebuilds it. +- An index a chat warmed survives `release_unless_warmed`, which the + new-project folder picker calls on the home index when it closes. +- Each index caps its cached file contents at 16 MB (`CACHE_BUDGET_BYTES`); + fff would otherwise size it up to 512 MB. +- Eviction stops the scan and watcher, drops the index, and asks glibc to + return the freed memory (`malloc_trim`) on Linux. + +Fixed options: no content (bigram) index — see "Paths-only index" below —, +mmap warm-up off, no frecency database (`SharedDb::noop`), +git recency on, symlinks not followed. fff's tracing and panic hooks are not +installed. + +## Measurements + +`crates/engine/examples/workspace_search_bench.rs`, release build, Linux +(Fedora 44, x86_64), 10 timed runs per query after a warm-up; medians. "Before" +is the same benchmark at commit `0c6b94ed`, before the port. comet is this +repository (≈2.7k tracked files, 3.3k entries walked, 139k with ignored ones); +t3code is a ≈27.7k-file TypeScript monorepo. + +| Median per query | comet before | comet after | t3code before | t3code after | +| --- | ---: | ---: | ---: | ---: | +| `SearchFiles` (`@`, 8 results) | 0.13–0.58 ms¹ | 0.70–1.57 ms | 1.0–31 ms¹ | 1.0–3.6 ms | +| `SearchWorkspaceFiles` (tree, ≤200) | 27.7–30.1 ms | 0.70–2.6 ms | 209–409 ms | 1.8–4.4 ms | +| …including ignored (walk) | 310–781 ms | 237–248 ms | 105–320 ms | 91–109 ms | +| `SearchWorkspaceContent` (100, 3 per file) | — | 0.17–0.43 ms | — | 0.72–1.99 ms | +| Index build (scan + content index) | 8–11 ms walk² | 51–61 ms | 29 ms walk² | 625–685 ms | + +¹ The old `@` index was rebuilt by a full walk whenever it was older than 10 s; +the benchmark's runs all hit the cache. ² The old walk had no content index. + +Queries: `composer`, `repos`, `cmdpal`, `Cargo.toml`, `workspace_files`. + +| Process RSS | comet | t3code | +| --- | ---: | ---: | +| Baseline (no index) | 6.1 MB | 6.0 MB | +| Index built | 25.5 MB (+19.4) | 90.5 MB (+84.5) | +| After every query above | 39.3 MB | 123.0 MB | +| 2 s after evicting the index | 16.6 MB (+10.5) | 56.7 MB (+50.7) | + +The residual after eviction is allocator state, not a live index: repeated +create/evict cycles of comet's index plateau at about +20 MB over the first +baseline after four cycles and stop growing (glibc keeps per-thread arenas +for fff's search and scan threads). Before, the old `@` cache cost 3.6 MB for +comet and 12 MB for t3code, but every tree query re-walked the disk. + +Against the plan's targets: tree and `@` p50 ≤ 2 ms in comet — met (0.70–2.6 +ms; the tree's `Cargo.toml` query, 200 results, is the slowest). Content p50 +≤ 50 ms — met by two orders of magnitude. ≤ 20 MB per index in comet — met for +the index itself (+19.4 MB); query-time allocations add more until eviction. +Back within ±10 MB of baseline after eviction — not met on Linux (+10.5 MB +after one cycle, plateauing near +20 MB). + +## Paths-only index + +The tables above were taken with fff's content index on. It is now off: +indexes hold paths only and `SearchWorkspaceContent` greps the indexed files +without a bigram prefilter. Re-measured the same way: + +| | comet, content index | comet, paths only | t3code, content index | t3code, paths only | +| --- | ---: | ---: | ---: | ---: | +| Index ready | 51–61 ms | 41 ms | 625–685 ms | 303 ms | +| RSS with the index | 25.5 MB | 19.5 MB | 90.5 MB | 76.7 MB | +| RSS after every query | 39.3 MB | 43.8 MB | 123.0 MB | 91.7 MB | +| `SearchWorkspaceContent` median | 0.17–0.43 ms | 0.48–2.3 ms | 0.72–1.99 ms | 1.1–21 ms | + +Name searches are unchanged. Content queries with many hits stay near 1 ms; +rare or absent strings scan every candidate (~20 ms on t3code's 27.7k files, +page cache warm). A cold disk makes the first such query slower; that was not +measured. A full rescan of t3code drops from ~1.7 s to ~1.05 s of CPU, and +build CPU from ~2.0 s to ~0.9 s. + +## Behavior changes + +- Ranking follows fff: typo-tolerant fuzzy matching with filename, git-recency + and modification-time boosts. The top results are better; a long tail of + weak matches fills the tree's 200 slots where the old substring ranking + returned fewer rows. +- An empty `@` query lists the chat's featured paths first, then recently + changed files (it used to list the shallowest paths). +- Dotfiles are indexed inside git repositories only; in a plain folder fff + skips them. `.git` is never indexed. +- Symbolic links are not indexed (fff does not follow them), so name and + content searches skip them; the "show ignored" walk still lists them. +- The tree's "show ignored" mode keeps a direct walk (fff has no + include-ignored mode), now ranked by plain case-insensitive substring: name + prefix, then name, then path matches, shorter paths first. +- Binary files and files over 10 MB are never content-searched. + +## Cost + +- Dependencies: 53 new lockfile entries (most are Windows target shims), + including C builds of `libgit2-sys`, `libz-sys` and `lmdb-master-sys` (a C + compiler is required on every platform) and `notify 9.0.0-rc` alongside our + `notify 7`. `nucleo-matcher` is gone. +- Threads: fff's global search and background pools, plus one watcher per + live index (at most seven). + +## Reproduce + +```text +BENCH_ROOT=/path/to/checkout cargo run --release -p zeron-engine \ + --example workspace_search_bench +``` + +`BENCH_QUERIES=a,b,c` changes the queries and `BENCH_RUNS` the timed runs. +Index creation and eviction are logged at `info` (`workspace search index +created` / `scanned` / `evicted`, with root, file count, scan time and arena +bytes). diff --git a/docs/screenshots/command-palette/README.md b/docs/screenshots/command-palette/README.md index 2084953ab3..363756c527 100644 --- a/docs/screenshots/command-palette/README.md +++ b/docs/screenshots/command-palette/README.md @@ -1,21 +1,67 @@ These screenshots show the real GPUI shell with isolated sample chats from `crates/ui/examples/command-palette-fixture.rs`. The fixture uses temporary -storage and does not start an engine or connect to an account. +storage and does not start an engine or connect to an account, so it shows no +Files or In Files results. Run `cargo run -p zeron-ui --example command-palette-fixture` on a desktop, then press Cmd+K (Ctrl+K on Linux/Windows). Set `ZERON_PALETTE_LIGHT=1` for light mode. -Verified interactively on Linux/X11: +## Tabs and sections + +The palette searches threads, commands and the focused chat's workspace. Tabs +under the search field narrow it: **All**, **Threads**, **Commands** and +**Files**. Cmd+1…4 (Ctrl+1…4) select them while the palette is open — the +bindings live in the palette's `CommandPalette` key context, where they outrank +the same keys' session jumps; with the palette closed those keys jump to +sessions as before. Tab and Shift+Tab cycle the tabs. The palette always opens +on All. + +With an empty query, All shows: + +1. **Recent Files** — up to five files the focused chat opened in editor tabs, + newest first, with the parent folder in muted text. +2. **Quick Actions** — New chat, New project, Open settings, the theme switch + and, with a focused unarchived chat, Archive thread, each with its shortcut. +3. **Recent Threads** — up to five other unarchived top-level threads by + latest activity. + +With a query, All shows **Threads** (five), **Commands**, **Files** (five) and +**In Files** (five); empty sections are omitted. The Threads tab lists up to 50 +threads, the Files tab up to 50 file names and 100 content matches (three per +file). + +- Threads are ranked in the UI (`crates/ui/src/shell/thread_search.rs`): every + query word must match the title (weight 100), project (40), branch (30), + device or pull request (20); a match starting a word earns half its weight + again; archived threads score half; ties go to recent activity. Side chats + and subagent workers never appear. +- Files and In Files query the focused chat's host (`SearchWorkspaceFiles` at + once, `SearchWorkspaceContent` after an 80 ms pause) once the query has two + characters. Their headers read "Searching…" while a request runs and + "Indexing…" while the host is still building its index; a host too old for + content search says so in the In Files header. +- Answers to superseded queries are dropped, and the highlighted row is kept by + identity, so results arriving never move the selection. +- Enter opens the row: a thread, a command (the theme switch keeps the palette + open), a file in an editor tab, a folder revealed in the files tree, or a + content match at its line. + +Footer hints: ↑ ↓ Navigate, ↵ Open, Cmd/Ctrl+1…4 Tabs, Esc Close. + +`tabs-empty-state.png`, `tabs-thread-search.png` and +`tabs-commands-no-results.png` show the redesign (captured under Xvfb, so +without the backdrop blur). The other images predate the tabs. + +## Earlier verification + +Verified interactively on Linux/X11 before the tabs: - First-open typing searches the palette, leaving the composer untouched. -- Actions and chat metadata filter together; a chat-only match hides Actions. - Matching text keeps a compact rounded code wash and code text color while preserving the row font and original text spacing. This applies to action labels and chat metadata. -- The action/history divider spans the card; chat history has no heading. - Palette chat hover leaves the sidebar pixels unchanged (including Archive state). - Chat rows use the sidebar height calculation and 2px gap; action rows are 32px. - Up/Down wraps and scrolls the selected result into view; Enter opens it. - New chat, New project, and Open settings reach their respective screens. -- New project no longer shows the Cmd+K chip. - Escape, a second Ctrl+K, and clicking outside dismiss the palette. - No matches produces the empty state. diff --git a/docs/screenshots/command-palette/tabs-commands-no-results.png b/docs/screenshots/command-palette/tabs-commands-no-results.png new file mode 100644 index 0000000000..843bd14da6 Binary files /dev/null and b/docs/screenshots/command-palette/tabs-commands-no-results.png differ diff --git a/docs/screenshots/command-palette/tabs-empty-state.png b/docs/screenshots/command-palette/tabs-empty-state.png new file mode 100644 index 0000000000..7ba8c24fad Binary files /dev/null and b/docs/screenshots/command-palette/tabs-empty-state.png differ diff --git a/docs/screenshots/command-palette/tabs-thread-search.png b/docs/screenshots/command-palette/tabs-thread-search.png new file mode 100644 index 0000000000..94a945c141 Binary files /dev/null and b/docs/screenshots/command-palette/tabs-thread-search.png differ