From d076a0b44d73106f5d8e8c54404ca07c157ccba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 18:55:16 -0300 Subject: [PATCH 01/36] build: add fff-search --- Cargo.lock | 569 +++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 + crates/engine/Cargo.toml | 1 + 3 files changed, 550 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9fb91a59b3..42a8df5e2f 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" @@ -952,7 +961,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -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" @@ -6334,6 +6609,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 +7068,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 +7166,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 +7287,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 +7314,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 +7335,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 +7357,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 +8360,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 +9273,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash 2.1.3", @@ -9558,7 +9912,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -9568,8 +9922,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 +10086,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 +10134,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 +10750,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 +11891,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 +12546,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 +12573,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 +12606,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 +12630,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 +12687,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 +12950,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -12812,7 +13334,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,12 +13542,13 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "fff-search", "futures", "gethostname", "ignore", "libc", "loro", - "notify", + "notify 7.0.0", "nucleo-matcher", "portable-pty", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 020bd94200..efba32526e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,6 +153,9 @@ ttf-parser = "0.25.1" 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..17f60387f5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,6 +36,7 @@ sha2.workspace = true reqwest.workspace = true ignore.workspace = true nucleo-matcher.workspace = true +fff-search.workspace = true tempfile.workspace = true From 0c6b94edbf719e3c01b580c05f3f2ab3e0a9d6b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:01:18 -0300 Subject: [PATCH 02/36] test(engine): add a workspace search benchmark --- .../engine/examples/workspace_search_bench.rs | 136 ++++++++++++++++++ crates/engine/src/workspace_files.rs | 12 ++ 2 files changed, 148 insertions(+) create mode 100644 crates/engine/examples/workspace_search_bench.rs diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs new file mode 100644 index 0000000000..16cac641b8 --- /dev/null +++ b/crates/engine/examples/workspace_search_bench.rs @@ -0,0 +1,136 @@ +//! Workspace search benchmark: the `SearchFiles` (`@`) and +//! `SearchWorkspaceFiles` (file tree) 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 started = Instant::now(); + let first = runtime.block_on(repos.search_files(root.clone(), "a".into(), Vec::new()))?; + println!( + "SearchFiles first query (builds the index): {:?}, {} results", + started.elapsed(), + first.len() + ); + print_rss("after SearchFiles 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(&root, query, false))?; + report(&format!("SearchWorkspaceFiles {query}"), &timings, results); + } + for query in &queries { + let (timings, results) = measure(runs, || tree_search(&root, query, true))?; + report(&format!("…including ignored {query}"), &timings, results); + } + print_rss("after all queries"); + Ok(()) +} + +fn tree_search( + root: &Path, + query: &str, + include_ignored: bool, +) -> Result> { + Ok(zeron_engine::workspace_files::bench_search_root( + 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/workspace_files.rs b/crates/engine/src/workspace_files.rs index fd89a7d8ac..17bb855828 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -1196,6 +1196,18 @@ 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( + root: &Path, + query: &str, + include_ignored: bool, + limit: usize, +) -> Result, WorkspaceFilesError> { + search_workspace_blocking(root, query, include_ignored, limit, &AtomicBool::new(false)) +} + fn search_workspace_blocking( root: &Path, query: &str, From be67fc7c2d6da27420c091fcee3cca592c18db73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:06:12 -0300 Subject: [PATCH 03/36] feat(engine): add WorkspaceSearch on top of fff --- crates/engine/src/lib.rs | 2 + crates/engine/src/repos.rs | 8 + crates/engine/src/workspace_search.rs | 651 ++++++++++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 crates/engine/src/workspace_search.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4d47d3bc2c..74038ad438 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,6 +44,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}; @@ -75,6 +76,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..b25e0f833a 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -25,6 +25,7 @@ use zeron_proto::{ }; use crate::EngineError; +use crate::WorkspaceSearch; /// Existence probe timeout for user-chosen / remembered paths, which can point at /// dead network mounts where a bare `stat` hangs for minutes. @@ -138,6 +139,8 @@ struct ReposInner { 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. @@ -167,6 +170,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 { @@ -190,6 +197,7 @@ impl Repos { 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(), }), } } diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs new file mode 100644 index 0000000000..ebfc3419f8 --- /dev/null +++ b/crates/engine/src/workspace_search.rs @@ -0,0 +1,651 @@ +//! 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`]. A pinned index (the +//! focused chat's, renewed by the UI heartbeat) is evicted for capacity only +//! when every live index is pinned. + +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, FFFMode, FilePickerOptions, FuzzySearchOptions, GrepConfig, + 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(5 * 60); +/// Live indexes at once; creating one more evicts the least recently used. +pub const MAX_LIVE: usize = 3; +/// Per-index cap on cached file contents (fff defaults to up to 512 MB). +pub const CACHE_BUDGET_BYTES: u64 = 64 * 1024 * 1024; +/// A pin outlives the UI's 60 s warm heartbeat by a comfortable margin, so a +/// client that disappears cannot keep its index alive forever. +pub const PIN_TTL: Duration = Duration::from_secs(3 * 60); +/// 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); +/// 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(30); +/// Ceiling for logging a finished scan; a scan still running then is not logged. +const SCAN_LOG_WAIT: Duration = Duration::from_secs(10 * 60); + +#[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, +} + +#[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, + reaper_started: std::sync::Once, +} + +struct Entry { + picker: SharedFilePicker, + last_used: Instant, + pinned_until: Option, +} + +impl Default for WorkspaceSearch { + fn default() -> Self { + Self::new() + } +} + +impl WorkspaceSearch { + pub fn new() -> Self { + Self::with_clock(Arc::new(Instant::now)) + } + + fn with_clock(clock: Clock) -> Self { + Self { + inner: Arc::new(Inner { + entries: Mutex::new(HashMap::new()), + clock, + reaper_started: std::sync::Once::new(), + }), + } + } + + /// Start (or keep alive) the index for `root`. `pin` protects it from + /// capacity eviction for [`PIN_TTL`]; `pin: false` releases a pin. + pub fn warm(&self, root: &Path, pin: bool) -> Result { + let picker = self.acquire(root, Some(pin))?; + 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). + pub fn search_names( + &self, + root: &Path, + query: &str, + limit: usize, + kinds: NameKinds, + ) -> Result { + let picker = self.acquire(root, None)?; + let indexing = !picker.wait_for_scan(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 { + return Ok(NameSearch::default()); + }; + let options = FuzzySearchOptions { + pagination: PaginationArgs { offset: 0, limit }, + ..Default::default() + }; + let mut matches = Vec::with_capacity(limit); + 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, None)?; + 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 { + return Ok(ContentSearch::default()); + }; + let parsed = QueryParser::new(GrepConfig).parse(query); + 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() + }; + let result = fff.grep(&parsed, &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, + }) + } + + /// 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"); + } + } + + fn acquire( + &self, + root: &Path, + pin: Option, + ) -> 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; + match pin { + Some(true) => entry.pinned_until = Some(now + PIN_TTL), + Some(false) => entry.pinned_until = None, + None => {} + } + entry.picker.clone() + } else { + if entries.len() >= MAX_LIVE { + evicted = lru_victim(&entries, now) + .and_then(|victim| entries.remove(&victim).map(|entry| (victim, entry))); + } + let picker = create_picker(root)?; + entries.insert( + root.to_path_buf(), + Entry { + picker: picker.clone(), + last_used: now, + pinned_until: (pin == Some(true)).then(|| now + PIN_TTL), + }, + ); + 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(); + } +} + +/// Least recently used unpinned index; the least recently used overall when +/// every index is pinned (the live cap holds regardless). +fn lru_victim(entries: &HashMap, now: Instant) -> Option { + let pinned = |entry: &Entry| entry.pinned_until.is_some_and(|until| until > now); + entries + .iter() + .filter(|(_, entry)| !pinned(entry)) + .min_by_key(|(_, entry)| entry.last_used) + .or_else(|| entries.iter().min_by_key(|(_, entry)| entry.last_used)) + .map(|(root, _)| root.clone()) +} + +fn create_picker(root: &Path) -> Result { + let home = std::fs::canonicalize(crate::repos::home_dir()).is_ok_and(|home| home == root); + 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, + // The bigram index makes content search fast; a whole home folder + // is too large to be worth it, so it is searched unindexed there. + enable_content_indexing: !home, + mode: FFFMode::Neovim, + cache_budget: ContentCacheBudget::from_overrides(0, CACHE_BUDGET_BYTES, 0), + watch: true, + 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); + 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 { + 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))), + 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, false).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 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, false).unwrap(); + clock.advance(IDLE_EVICT / 2); + search.warm(&b, false).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..4).map(|_| folder(&[("x.rs", b"")])).collect(); + let (search, clock) = manual(); + for (_, root) in &folders[..3] { + search.warm(root, false).unwrap(); + clock.advance(Duration::from_secs(1)); + } + // Touching the oldest makes the second the least recently used. + search.warm(&folders[0].1, false).unwrap(); + clock.advance(Duration::from_secs(1)); + + search.warm(&folders[3].1, false).unwrap(); + + let entries = lock(&search.inner.entries); + assert_eq!(entries.len(), MAX_LIVE); + assert!(!entries.contains_key(&folders[1].1)); + } + + #[test] + fn a_pinned_index_survives_capacity_eviction() { + let folders: Vec<_> = (0..4).map(|_| folder(&[("x.rs", b"")])).collect(); + let (search, clock) = manual(); + search.warm(&folders[0].1, true).unwrap(); + for (_, root) in &folders[1..] { + clock.advance(Duration::from_secs(1)); + search.warm(root, false).unwrap(); + } + + let entries = lock(&search.inner.entries); + assert_eq!(entries.len(), MAX_LIVE); + assert!(entries.contains_key(&folders[0].1)); + 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() + ); + } +} From 5a1149961722d45ed9dd60e4014ea7e40fc98bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:09:03 -0300 Subject: [PATCH 04/36] feat(engine): serve SearchFiles (@) from fff --- crates/engine/src/repos.rs | 427 +++++++------------------------------ 1 file changed, 80 insertions(+), 347 deletions(-) diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index b25e0f833a..76c9bee45a 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}; @@ -26,6 +25,7 @@ use zeron_proto::{ use crate::EngineError; use crate::WorkspaceSearch; +use crate::workspace_search::NameKinds; /// Existence probe timeout for user-chosen / remembered paths, which can point at /// dead network mounts where a bare `stat` hangs for minutes. @@ -43,9 +43,6 @@ 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); 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; @@ -134,11 +131,9 @@ 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 @@ -147,19 +142,6 @@ struct ReposInner { 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, @@ -187,7 +169,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") @@ -196,7 +177,6 @@ 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(), }), } @@ -1429,54 +1409,23 @@ 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. + /// 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())), } } @@ -1540,14 +1489,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, @@ -1928,246 +1869,75 @@ 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) -} - -#[cfg(test)] -fn search_files_blocking_with_cancel bool + Sync>( - root: &Path, - 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)) -} - -fn search_files_cached bool + Sync>( - cache: &FileIndexCache, - root: &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 - } - }; - if cancelled() { - return Err(EngineError::Other("file search cancelled".into())); - } - Ok(rank_file_matches( - &index.entries, - &root, - query, - featured_paths, - )) -} - -fn canonical_search_root(root: &Path) -> Result { - std::fs::canonicalize(root) - .map_err(|e| EngineError::Other(format!("could not search workspace: {e}"))) -} - -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>, - } - 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 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 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 { + 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( + &root, + query, + FILE_SEARCH_MAX_RESULTS + matches.len(), + NameKinds::FilesAndDirectories, + ) + .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) { 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); } + matches.push(FileSearchMatch { + path: found.path, + is_dir: found.is_dir, + }); } - matches.sort_by(|a, b| compare_file_matches(query, a, b)); - matches.truncate(FILE_SEARCH_MAX_RESULTS); - matches - .into_iter() - .map(|(_, _, path, is_dir)| FileSearchMatch { path, is_dir }) - .collect() + Ok(matches) +} + +/// 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 @@ -2379,23 +2149,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(); @@ -2544,13 +2297,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); @@ -2584,10 +2330,24 @@ tmpfs /run tmpfs rw 0 0 assert!(compact[1].parent_shas.is_empty()); } + 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(); @@ -2651,33 +2411,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(); From bf019b92eb4e94729f030065cd4654ea10d18d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:12:05 -0300 Subject: [PATCH 05/36] feat(engine): serve SearchWorkspaceFiles (file tree) from fff --- .../engine/examples/workspace_search_bench.rs | 6 +- crates/engine/src/workspace_files.rs | 244 ++++++++++++------ 2 files changed, 176 insertions(+), 74 deletions(-) diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs index 16cac641b8..35262fd3e0 100644 --- a/crates/engine/examples/workspace_search_bench.rs +++ b/crates/engine/examples/workspace_search_bench.rs @@ -71,11 +71,11 @@ fn main() -> Result<(), Box> { report(&format!("SearchFiles {query}"), &timings, results); } for query in &queries { - let (timings, results) = measure(runs, || tree_search(&root, query, false))?; + 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(&root, query, true))?; + let (timings, results) = measure(runs, || tree_search(&repos, &root, query, true))?; report(&format!("…including ignored {query}"), &timings, results); } print_rss("after all queries"); @@ -83,11 +83,13 @@ fn main() -> Result<(), Box> { } 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, diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 17bb855828..b8b9b34a4d 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -24,7 +24,8 @@ use zeron_proto::{ }; use zeron_rpc::RpcError; -use crate::{Repos, WorkspaceHost}; +use crate::workspace_search::NameKinds; +use crate::{Repos, WorkspaceHost, WorkspaceSearch}; mod mutations; @@ -416,8 +417,10 @@ impl WorkspaceFiles { 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, @@ -1200,15 +1203,27 @@ fn filtered_directory_paths(root: &Path, target: &Path) -> HashSet { /// `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(root, query, include_ignored, limit, &AtomicBool::new(false)) -} - + 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, @@ -1219,20 +1234,99 @@ fn search_workspace_blocking( 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(root, query, limit, NameKinds::FilesAndDirectories) + .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)) + }) + .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, + ) + } } - let query_lower = query.to_lowercase(); - let mut matches = Vec::new(); + impl PartialEq for Ranked { + fn eq(&self, other: &Self) -> bool { + self.key() == other.key() + } + } + 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 = 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, @@ -1244,9 +1338,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 @@ -1257,27 +1350,36 @@ 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 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 validate_workspace_search_query(query: &str) -> Result<(), WorkspaceFilesError> { @@ -1294,17 +1396,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, @@ -2139,40 +2230,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() @@ -2352,6 +2409,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(); @@ -2361,7 +2435,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 @@ -2397,11 +2474,34 @@ 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_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 read_fixture(bytes: &[u8]) -> WorkspaceFileText { From e81c6e634c2ae1aba9302d5f25b94d50e81a71de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:12:44 -0300 Subject: [PATCH 06/36] chore: drop nucleo-matcher --- Cargo.lock | 11 ----------- Cargo.toml | 1 - crates/engine/Cargo.toml | 1 - 3 files changed, 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42a8df5e2f..1d351570d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6234,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" @@ -13549,7 +13539,6 @@ dependencies = [ "libc", "loro", "notify 7.0.0", - "nucleo-matcher", "portable-pty", "reqwest", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index efba32526e..a972eb66ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,6 @@ 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" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 17f60387f5..4bcca072a1 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -35,7 +35,6 @@ 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 From c56fd02d7826db8de3e8ecef4e00eaeb5965b556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:16:04 -0300 Subject: [PATCH 07/36] feat(rpc): add WarmWorkspaceSearch --- crates/engine/src/rpc.rs | 14 +++++++++ crates/engine/src/workspace_files.rs | 39 +++++++++++++++++++------ crates/engine/tests/device_routing.rs | 13 +++++++++ crates/proto/src/entities.rs | 41 +++++++++++++++++++++++++++ crates/rpc/src/lib.rs | 2 ++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 48c9150565..1778fa6b36 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1440,6 +1440,7 @@ fn forwardable(method: &str) -> bool { | methods::SEARCH_FILES | methods::LIST_WORKSPACE_DIRECTORY | methods::SEARCH_WORKSPACE_FILES + | methods::WARM_WORKSPACE_SEARCH | methods::READ_WORKSPACE_IMAGE | methods::READ_WORKSPACE_FILE | methods::DELETE_WORKSPACE_ENTRY @@ -3086,6 +3087,17 @@ impl RpcService for EngineRpc { .map_err(RpcError::from)?; RpcReply::value(&matches) } + 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( @@ -4031,6 +4043,8 @@ 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!(!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 b8b9b34a4d..d0baa9466d 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -15,16 +15,17 @@ 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, + WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, WatchWorkspaceFilesRequest, + 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::workspace_search::NameKinds; +use crate::workspace_search::{IndexState, NameKinds}; use crate::{Repos, WorkspaceHost, WorkspaceSearch}; mod mutations; @@ -434,6 +435,24 @@ impl WorkspaceFiles { result } + 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, request.pin)) + .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, @@ -2497,7 +2516,11 @@ mod tests { 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"))); + 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"); diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index 142f71c4c1..d216723f1d 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -1442,6 +1442,19 @@ 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", + "pin": true, + "targetDeviceId": "device-b", + }), + ) + .await + .expect("remote search warm-up"); + assert!(matches!(warm["state"].as_str(), Some("building" | "ready"))); + let read = client .call( methods::READ_WORKSPACE_FILE, diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 3947465ea6..d9afa129e8 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -555,6 +555,32 @@ pub struct WorkspaceFileSearchMatch { pub score: i64, } +/// Start (or keep alive) the host's search index for a workspace. `pin` +/// marks it as the focused chat's: the UI renews it every minute, and a +/// pinned index is the last to be evicted. `pin: false` releases the pin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WarmWorkspaceSearchRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + #[serde(default)] + pub pin: bool, +} + +#[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 { @@ -1376,6 +1402,21 @@ mod tests { assert!(requests.iter().all(|value| value["chatId"] == "chat-1")); } + #[test] + fn warm_workspace_search_flattens_its_target() { + let request: WarmWorkspaceSearchRequest = + serde_json::from_value(serde_json::json!({ "chatId": "chat-1" })).unwrap(); + assert_eq!(request.target.chat_id.as_deref(), Some("chat-1")); + assert!(!request.pin); + 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 ec3bacfa63..fb19028064 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -169,6 +169,8 @@ pub mod methods { // 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"; pub const READ_WORKSPACE_IMAGE: &str = "ReadWorkspaceImage"; pub const READ_WORKSPACE_FILE: &str = "ReadWorkspaceFile"; pub const MOVE_WORKSPACE_ENTRY: &str = "MoveWorkspaceEntry"; From 83b3a59e12dfdc68a12b56bd41a631d6f4a3e449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:27:49 -0300 Subject: [PATCH 08/36] test(ui): add the missing images field to a ToolItem fixture --- crates/ui/src/transcript.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 975eb387f0..dbbf2187d4 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -15591,6 +15591,7 @@ mod tests { call: ToolCall::ReadFile { path: path.into() }, is_error: false, resolved: true, + images: Arc::new([]), detail: None, invocation: None, output_ref: None, From 259bf0686b275cbdc6a938dfad4066c4b7bddd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:32:12 -0300 Subject: [PATCH 09/36] feat(ui): warm workspace search on chat focus --- crates/ui/src/files/client.rs | 32 ++++- crates/ui/src/files/mod.rs | 12 ++ crates/ui/src/shell.rs | 5 + crates/ui/src/shell/search_warm.rs | 195 +++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 crates/ui/src/shell/search_warm.rs diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index 8b100ccba2..f3bfd0fbfb 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -6,7 +6,8 @@ use serde_json::Value; use tokio::sync::mpsc; use zeron_proto::{ ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceFilesRequest, - WatchWorkspaceFilesRequest, WorkspaceDirectoryPage, WorkspaceFileSearchMatch, + WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, WatchWorkspaceFilesRequest, + WorkspaceDirectoryPage, WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, }; use zeron_rpc::{RpcError, methods}; @@ -55,6 +56,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 +72,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()), } } @@ -179,6 +184,31 @@ impl WorkspaceFilesClient { self.call(methods::SEARCH_WORKSPACE_FILES, &request).await } + /// Start or keep alive the host's search index for this workspace. + pub async fn warm_search( + &self, + pin: bool, + ) -> Result { + let request = WarmWorkspaceSearchRequest { + target: self.context.target.clone(), + pin, + }; + self.call(methods::WARM_WORKSPACE_SEARCH, &request).await + } + + /// [`Self::warm_search`] as a hint: failures are logged, never surfaced. + /// `false` once the host turns out not to support it. + pub async fn hint_search_warm(&self, pin: bool) -> bool { + match self.warm_search(pin).await { + Ok(_) => true, + Err(FilesClientError::Unsupported(_)) => false, + Err(error) => { + tracing::debug!(%error, "workspace search warm-up failed"); + true + } + } + } + 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..7d574b210e 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -881,6 +881,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 (unpinned: the focused chat's pin is the shell's). + 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(false).await; + }) + .detach(); + } } pub fn retry_root(&mut self, cx: &mut Context) { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 82ef0503a5..2eb03e5a94 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -75,6 +75,7 @@ mod project_icon; mod side_chats; mod sidebar_pins; mod sidebar_sections; +mod search_warm; pub(crate) mod spaces; use side_chats::SideChatTab; mod tabs; @@ -2031,6 +2032,8 @@ pub struct Shell { /// The New project palette's collapsed-breadcrumbs (`…`) menu. project_crumb_menu: popover::Popup<()>, command_palette: Option, + /// The focused chat's pinned workspace search index (see `search_warm`). + search_warm: Option, pending_workspace_command: Option, /// The sidebar's space-filter dropdown. spaces_menu: popover::Popup, @@ -2477,6 +2480,7 @@ impl Shell { add_space: None, project_crumb_menu: popover::Popup::default(), command_palette: None, + search_warm: None, pending_workspace_command: None, spaces_menu: popover::Popup::default(), spaces_menu_bar: popover::MenuScrollbarState::default(), @@ -2642,6 +2646,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; diff --git a/crates/ui/src/shell/search_warm.rs b/crates/ui/src/shell/search_warm.rs new file mode 100644 index 0000000000..bf1a14ed4c --- /dev/null +++ b/crates/ui/src/shell/search_warm.rs @@ -0,0 +1,195 @@ +//! Keeps the focused chat's workspace search index warm on its host, so the +//! first `@`, file-tree or cmd+K search in a chat does not pay for the scan. +//! +//! Focusing a chat warms its index pinned and renews the pin every +//! [`WARM_HEARTBEAT`]; leaving it releases the pin (`pin: false`), handing +//! the index to the host's idle eviction. Hosts that predate +//! `WarmWorkspaceSearch` are skipped silently. + +use std::time::Duration; + +use gpui::{Context, Task}; + +use super::Shell; +use crate::files::client::{FilesRequestContext, WorkspaceFilesClient}; +use crate::state::EngineHandle; + +/// Well inside the host's pin lifetime (`PIN_TTL`, 3 min). +pub(super) const WARM_HEARTBEAT: Duration = Duration::from_secs(60); + +pub(super) struct SearchWarm { + chat_id: String, + context: FilesRequestContext, + engine: EngineHandle, + _heartbeat: Task<()>, +} + +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: pin its index, unpin the one left behind. + 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 current = focused.as_ref().zip(engine.as_ref()); + if let (Some(warm), Some(((chat_id, context), engine))) = (&self.search_warm, current) + && warm.is_for(chat_id, context, engine) + { + return; + } + if let Some(previous) = self.search_warm.take() { + let client = WorkspaceFilesClient::new(previous.engine, previous.context); + cx.spawn(async move |_, _| client.hint_search_warm(false).await) + .detach(); + } + let (Some((chat_id, context)), Some(engine)) = (focused, engine) else { + return; + }; + let client = WorkspaceFilesClient::new(engine.clone(), context.clone()); + let heartbeat = cx.spawn(async move |_, cx| { + while client.hint_search_warm(true).await { + cx.background_executor().timer(WARM_HEARTBEAT).await; + } + }); + self.search_warm = Some(SearchWarm { + chat_id, + context, + engine, + _heartbeat: heartbeat, + }); + } +} + +#[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() + } + + /// `(chatId, pin)` 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<(String, bool)> { + 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(), + request["params"]["pin"].as_bool().unwrap(), + )); + } + 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_pins_its_index_and_releases_the_previous(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), + vec![("a".to_owned(), true)] + ); + + window + .update(cx, |shell, _, cx| { + shell + .state + .update(cx, |state, cx| state.select_chat(Some("b".into()), cx)); + }) + .unwrap(); + let mut warms = drain_warms(cx, &runtime, &mut requests, &replies); + warms.sort(); + assert_eq!(warms, vec![("a".to_owned(), false), ("b".to_owned(), true)]); + + // 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()); + } +} From 3ded7cdd43eae5f1c1fa305a1077aebb2661c912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:39:21 -0300 Subject: [PATCH 10/36] feat(rpc): add SearchWorkspaceContent --- .../engine/examples/workspace_search_bench.rs | 30 ++- crates/engine/src/rpc.rs | 14 ++ crates/engine/src/workspace_files.rs | 175 +++++++++++++++++- crates/engine/src/workspace_search.rs | 9 + crates/engine/tests/device_routing.rs | 18 ++ crates/proto/src/entities.rs | 39 ++++ crates/rpc/src/lib.rs | 2 + 7 files changed, 278 insertions(+), 9 deletions(-) diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs index 35262fd3e0..3146fc3de7 100644 --- a/crates/engine/examples/workspace_search_bench.rs +++ b/crates/engine/examples/workspace_search_bench.rs @@ -1,5 +1,6 @@ -//! Workspace search benchmark: the `SearchFiles` (`@`) and -//! `SearchWorkspaceFiles` (file tree) paths against one folder. +//! 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 \ @@ -48,14 +49,17 @@ fn main() -> Result<(), Box> { println!("root: {}", root.display()); print_rss("baseline"); + let search = repos.workspace_search(); let started = Instant::now(); - let first = runtime.block_on(repos.search_files(root.clone(), "a".into(), Vec::new()))?; + search.warm(&root, false)?; + if !search.wait_until_indexed(&root, Duration::from_secs(600)) { + return Err("index did not finish within 10 minutes".into()); + } println!( - "SearchFiles first query (builds the index): {:?}, {} results", - started.elapsed(), - first.len() + "index scanned and content-indexed in {:?}", + started.elapsed() ); - print_rss("after SearchFiles index"); + print_rss("after index"); println!(); println!( @@ -78,6 +82,18 @@ fn main() -> Result<(), Box> { 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"); Ok(()) } diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 1778fa6b36..e93a057d30 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1441,6 +1441,7 @@ fn forwardable(method: &str) -> bool { | 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 @@ -3087,6 +3088,17 @@ 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( @@ -4045,6 +4057,8 @@ mod tests { 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!(!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 d0baa9466d..a9e274502a 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -14,8 +14,9 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Notify, broadcast, mpsc}; use tokio_util::sync::CancellationToken; use zeron_proto::{ - ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceFilesRequest, - WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, WatchWorkspaceFilesRequest, + ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceContentRequest, + SearchWorkspaceFilesRequest, WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, + WatchWorkspaceFilesRequest, WorkspaceContentMatch, WorkspaceContentSearchResult, WorkspaceDirectoryPage, WorkspaceEntry, WorkspaceEntryKind, WorkspaceFileChange, WorkspaceFileChangeKind, WorkspaceFileChanges, WorkspaceFileConflictReason, WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceFileWriteResult, WorkspaceLineEnding, @@ -37,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; @@ -435,6 +439,20 @@ 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, @@ -1401,6 +1419,44 @@ fn search_including_ignored( .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)) + }) + .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, + }) +} + fn validate_workspace_search_query(query: &str) -> Result<(), WorkspaceFilesError> { if query.trim().is_empty() { return Err(WorkspaceFilesError::BadParams( @@ -2527,6 +2583,121 @@ mod tests { assert_eq!(walked[0].kind, WorkspaceEntryKind::File); } + fn content( + root: &Path, + query: &str, + per_file: Option, + ) -> Result { + let search = WorkspaceSearch::new(); + search.warm(root, false).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_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 { let root = tempfile::tempdir().unwrap(); std::fs::write(root.path().join("file.txt"), bytes).unwrap(); diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index ebfc3419f8..56f012b0f9 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -271,6 +271,15 @@ impl WorkspaceSearch { }) } + /// 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)) + } + /// Number of live indexes. pub fn live(&self) -> usize { lock(&self.inner.entries).len() diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index d216723f1d..ff37207d8d 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -1455,6 +1455,24 @@ async fn workspace_file_surface_proxies_over_the_relay() { .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 d9afa129e8..56158de90c 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -555,6 +555,45 @@ 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. `pin` /// marks it as the focused chat's: the UI renews it every minute, and a /// pinned index is the last to be evicted. `pin: false` releases the pin. diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index fb19028064..92fc282123 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -171,6 +171,8 @@ pub mod methods { 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"; From 7b11b6d04bac48addc5a98c3c1e800e91a3b43cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:42:54 -0300 Subject: [PATCH 11/36] feat(ui): scored thread search for the command palette --- crates/ui/src/shell.rs | 3 + crates/ui/src/shell/thread_search.rs | 166 +++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 crates/ui/src/shell/thread_search.rs diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2eb03e5a94..3897d9ca69 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -77,6 +77,9 @@ mod sidebar_pins; mod sidebar_sections; mod search_warm; pub(crate) mod spaces; +// Adopted by the command palette's Threads section. +#[cfg_attr(not(test), allow(dead_code))] +mod thread_search; use side_chats::SideChatTab; mod tabs; mod voice_stage; 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"]); + } +} From f6464a424ed288f55787857ca01f8ff94d6addb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:48:20 -0300 Subject: [PATCH 12/36] refactor(ui): split the command palette into sections and tabs --- crates/ui/src/shell/command_palette.rs | 560 ++++++++++++++++++------- 1 file changed, 403 insertions(+), 157 deletions(-) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 6798adfe2e..eb101025a4 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -1,4 +1,6 @@ -//! 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; @@ -9,7 +11,11 @@ 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. @@ -36,30 +42,107 @@ impl EnterPress { } } +// Selected from the tab row (next commit). +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Tab { + #[default] + All, + Threads, + Commands, + Files, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Section { + Threads, + Commands, +} + +impl Section { + fn title(self) -> &'static str { + match self { + Self::Threads => "Threads", + Self::Commands => "Commands", + } + } +} + #[derive(Clone, Debug, PartialEq)] -enum Entry { +enum Command { NewChat, NewProject, Settings, Theme(AppearanceMode), - Chat(String), } -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", + } + } + + 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(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum RowKind { + Command(Command), + Chat(String), +} + +#[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}"), + }; + RowKey(self.section, id) + } +} + +/// One titled group of rows, already limited for the tab. +#[derive(Clone, Debug, PartialEq)] +struct PaletteSection { + section: Section, + rows: Vec, +} + +impl PaletteSection { + fn new(section: Section, kinds: impl IntoIterator) -> Self { + Self { + section, + rows: kinds + .into_iter() + .map(|kind| PaletteRow::new(section, kind)) + .collect(), } } } @@ -69,22 +152,64 @@ 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 { +fn commands_for(query: &str, is_dark: 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)) + .filter(|command| matches_query(query, command.label())) .collect() } +/// 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() +} + +/// 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() { @@ -104,7 +229,7 @@ impl Shell { 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))); } cx.notify(); @@ -115,7 +240,9 @@ 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(), @@ -133,12 +260,42 @@ impl Shell { } } - fn command_entries(&self, cx: &App) -> Vec { + /// 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 commands = || { + PaletteSection::new( + Section::Commands, + commands_for(&query, Theme::of(cx).appearance.is_dark()) + .into_iter() + .map(RowKind::Command), + ) + }; + let threads = |limit| { + PaletteSection::new( + Section::Threads, + self.thread_matches(&query, limit, cx) + .into_iter() + .map(RowKind::Chat), + ) + }; + let sections = match palette.tab { + Tab::All => vec![commands(), threads(HISTORY_RESULT_LIMIT)], + Tab::Threads => vec![threads(HISTORY_RESULT_LIMIT)], + Tab::Commands => vec![commands()], + Tab::Files => Vec::new(), + }; + sections + .into_iter() + .filter(|section| !section.rows.is_empty()) + .collect() + } + + /// Top-level chats matching `query`, in sidebar order. + fn thread_matches(&self, query: &str, limit: usize, cx: &App) -> Vec { let state = self.state.read(cx); // Global history deliberately ignores the sidebar's project filter and // collapsed groups. Archived conversations remain searchable too. @@ -165,7 +322,7 @@ impl Shell { }) .unwrap_or_default(); matches_query( - &query, + query, &format!( "{} {project} {device} {branch} {pr}", chat.title.as_deref().unwrap_or("New session") @@ -175,99 +332,114 @@ impl Shell { .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 + chats + .into_iter() + .take(limit) + .map(|chat| chat.id.clone()) + .collect() } /// 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); + palette.scroll.scroll_to_item(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::Chat(id) => self.open_chat(id, cx), } } - pub(super) fn render_command_palette( + 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::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, - viewport: gpui::Size, - window: &mut Window, + row: &PaletteRow, + ix: usize, + active: bool, + query: &str, + theme: &Theme, 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); - } - 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))); - } - 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}")) + 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 +447,25 @@ 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::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 +505,77 @@ 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 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())) + .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 +586,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", @@ -370,18 +600,7 @@ impl Shell { 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,15 +611,7 @@ impl Shell { cx.stop_propagation(); return; } - let entries = this.command_entries(cx); - if let Some(entry) = this - .command_palette - .as_ref() - .and_then(|p| entries.get(p.active)) - .cloned() - { - this.activate_command(entry, window, cx); - } + this.activate_palette_selection(window, cx); } "escape" => this.close_command_palette(window, cx), _ => return, @@ -435,6 +646,17 @@ impl Shell { } } +/// A section's title, in the sidebar's secondary text style. +fn palette_section_header(theme: &Theme, title: &'static str) -> gpui::Div { + div() + .px(px(16.0)) + .pt(px(2.0)) + .pb(px(4.0)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(title) +} + /// 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) @@ -616,10 +838,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() @@ -695,6 +918,29 @@ mod tests { .unwrap(); } + #[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); + } + #[test] fn x11_unflagged_enter_repeats_activate_once_until_release() { let mut enter = EnterPress::default(); @@ -721,43 +967,43 @@ mod tests { #[test] fn action_search_hides_empty_section_and_preserves_order() { assert_eq!( - actions_for("", true), + commands_for("", true), 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), + vec![Command::NewChat, Command::NewProject] ); - assert_eq!(actions_for("settings", true), vec![Entry::Settings]); + assert_eq!(commands_for("settings", true), vec![Command::Settings]); assert_eq!( - actions_for("theme", true), - vec![Entry::Theme(AppearanceMode::Light)] + commands_for("theme", true), + vec![Command::Theme(AppearanceMode::Light)] ); - assert!(actions_for("deployment", true).is_empty()); + assert!(commands_for("deployment", true).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), + vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - actions_for("theme", false), - vec![Entry::Theme(AppearanceMode::Dark)] + commands_for("theme", false), + vec![Command::Theme(AppearanceMode::Dark)] ); assert_eq!( - actions_for("light", true), - vec![Entry::Theme(AppearanceMode::Light)] + commands_for("light", true), + vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - actions_for("dark", false), - vec![Entry::Theme(AppearanceMode::Dark)] + commands_for("dark", false), + vec![Command::Theme(AppearanceMode::Dark)] ); } From af67633686017041c68c3c833a4b73eb54f76b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:54:16 -0300 Subject: [PATCH 13/36] =?UTF-8?q?feat(ui):=20command=20palette=20tabs=20wi?= =?UTF-8?q?th=20mod-1=E2=80=A64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui/src/shell.rs | 22 +++ crates/ui/src/shell/command_palette.rs | 206 ++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 3897d9ca69..b7f9f5cba0 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -322,6 +322,13 @@ fn titlebar_new_session_alpha(is_chat_route: bool, has_selected_chat: bool) -> f #[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) // --------------------------------------------------------------------------- @@ -525,6 +532,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). diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index eb101025a4..46478de840 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -4,6 +4,8 @@ use super::*; use crate::appearance::AppearanceMode; +/// Key context of the palette card: its mod-1…4 tab bindings live here. +pub(super) const KEY_CONTEXT: &str = "CommandPalette"; const HISTORY_RESULT_LIMIT: usize = 30; const RESULTS_FADE_BAND: f32 = 18.0; @@ -42,8 +44,6 @@ impl EnterPress { } } -// Selected from the tab row (next commit). -#[allow(dead_code)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) enum Tab { #[default] @@ -53,6 +53,34 @@ pub(super) enum Tab { 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 { Threads, @@ -251,6 +279,18 @@ impl Shell { 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 { @@ -539,6 +579,7 @@ impl Shell { 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; @@ -595,7 +636,43 @@ 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)); + popover::menu_row( + &theme, + tab == current_tab, + format!("command-tab-{}", tab.index()), + ) + .id(("command-tab", tab.index())) + .role(gpui::Role::Tab) + .aria_label(tab.label()) + .gap(px(6.0)) + .py(px(3.0)) + .on_click(cx.listener(move |this, _, _, cx| this.select_palette_tab(tab, cx))) + .child(tab.label()) + .child(popover::kbd_hint(&theme, &combo)) + })); + let tab_keys = format!( + "{}…{}", + crate::settings::badge_combo("mod-1"), + Tab::ALL.len() + ); 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() { @@ -613,6 +690,16 @@ impl Shell { } this.activate_palette_selection(window, cx); } + "tab" => { + let forward = !event.keystroke.modifiers.shift; + if let Some(tab) = this + .command_palette + .as_ref() + .map(|palette| palette.tab.step(forward)) + { + this.select_palette_tab(tab, cx); + } + } "escape" => this.close_command_palette(window, cx), _ => return, } @@ -635,11 +722,13 @@ 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, &tab_keys, "Tabs")) .child(command_key_hint(&theme, "Esc", "Close")), ); Some(palette_overlay(viewport, card)) @@ -918,6 +1007,117 @@ mod tests { .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]| { From 5d3f9ecb489a4284bba3b84b2fc7f30237b2bfff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:57:43 -0300 Subject: [PATCH 14/36] feat(ui): empty-state sections in the command palette --- crates/ui/src/shell.rs | 7 + crates/ui/src/shell/command_palette.rs | 315 ++++++++++++++++++++++--- crates/ui/src/shell/files_panel.rs | 21 ++ 3 files changed, 316 insertions(+), 27 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b7f9f5cba0..6415b86655 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2059,6 +2059,8 @@ pub struct Shell { command_palette: Option, /// The focused chat's pinned workspace search index (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, @@ -2506,6 +2508,7 @@ impl Shell { 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(), @@ -3437,6 +3440,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) => { diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 46478de840..1fe0ea5abf 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -7,6 +7,10 @@ use crate::appearance::AppearanceMode; /// Key context of the palette card: its mod-1…4 tab bindings live here. pub(super) const KEY_CONTEXT: &str = "CommandPalette"; const HISTORY_RESULT_LIMIT: usize = 30; +/// 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 RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { @@ -83,6 +87,9 @@ impl Tab { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Section { + RecentFiles, + QuickActions, + RecentThreads, Threads, Commands, } @@ -90,6 +97,9 @@ enum Section { 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", } @@ -102,6 +112,7 @@ enum Command { NewProject, Settings, Theme(AppearanceMode), + ArchiveThread, } impl Command { @@ -113,6 +124,7 @@ impl Command { 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", } } @@ -122,6 +134,7 @@ impl Command { Self::NewProject => icons::FOLDER, Self::Settings => icons::SETTINGS, Self::Theme(mode) => mode.icon(), + Self::ArchiveThread => icons::ARCHIVE_MINIMALISTIC, } } } @@ -130,6 +143,11 @@ impl Command { enum RowKind { Command(Command), Chat(String), + /// A path in the focused chat's workspace. + File { + path: String, + is_dir: bool, + }, } #[derive(Clone, Debug, PartialEq)] @@ -151,6 +169,7 @@ impl PaletteRow { let id = match &self.kind { RowKind::Command(command) => format!("command:{}", command.label()), RowKind::Chat(id) => format!("chat:{id}"), + RowKind::File { path, .. } => format!("file:{path}"), }; RowKey(self.section, id) } @@ -180,7 +199,8 @@ fn matches_query(query: &str, text: &str) -> bool { query.split_whitespace().all(|word| text.contains(word)) } -fn commands_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 { [ Command::NewChat, Command::NewProject, @@ -192,10 +212,20 @@ fn commands_for(query: &str, is_dark: bool) -> Vec { }), ] .into_iter() + .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 { @@ -306,10 +336,11 @@ impl Shell { return Vec::new(); }; let query = palette.search.read(cx).text().trim().to_lowercase(); - let commands = || { + let can_archive = self.state.read(cx).archivable_selected_chat().is_some(); + let commands = |section| { PaletteSection::new( - Section::Commands, - commands_for(&query, Theme::of(cx).appearance.is_dark()) + section, + commands_for(&query, Theme::of(cx).appearance.is_dark(), can_archive) .into_iter() .map(RowKind::Command), ) @@ -322,11 +353,38 @@ impl Shell { .map(RowKind::Chat), ) }; - let sections = match palette.tab { - Tab::All => vec![commands(), threads(HISTORY_RESULT_LIMIT)], - Tab::Threads => vec![threads(HISTORY_RESULT_LIMIT)], - Tab::Commands => vec![commands()], - Tab::Files => Vec::new(), + let recent_threads = |limit| { + PaletteSection::new( + Section::RecentThreads, + self.recent_threads(limit, cx) + .into_iter() + .map(RowKind::Chat), + ) + }; + let recent_files = |limit| { + PaletteSection::new( + Section::RecentFiles, + self.recent_files(&self.active_chat) + .take(limit) + .map(|path| RowKind::File { + path: path.clone(), + is_dir: false, + }), + ) + }; + 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![commands(Section::Commands), threads(HISTORY_RESULT_LIMIT)], + (Tab::Threads, true) => vec![recent_threads(THREADS_TAB_LIMIT)], + (Tab::Threads, false) => vec![threads(HISTORY_RESULT_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::new(), }; sections .into_iter() @@ -334,6 +392,23 @@ impl Shell { .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 `query`, in sidebar order. fn thread_matches(&self, query: &str, limit: usize, cx: &App) -> Vec { let state = self.state.read(cx); @@ -445,15 +520,51 @@ impl Shell { 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) + } } } + /// 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, + path: String, + is_dir: bool, + location: Option<(u32, Option)>, + window: &mut Window, + cx: &mut Context, + ) { + if self.active_chat.is_empty() { + return; + } + 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)); + } + 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); @@ -505,6 +616,50 @@ impl Shell { }) .into_any_element() } + 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::Chat(id) => { let state = self.state.read(cx); let chat = state.chats.iter().find(|chat| &chat.id == id)?; @@ -953,10 +1108,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", @@ -983,30 +1136,135 @@ 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() + } + + #[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")); + } + /// A shell with the real app keymap and two sidebar sessions. fn keyed_palette_window( cx: &mut TestAppContext, @@ -1167,7 +1425,7 @@ mod tests { #[test] fn action_search_hides_empty_section_and_preserves_order() { assert_eq!( - commands_for("", true), + commands_for("", true, false), vec![ Command::NewChat, Command::NewProject, @@ -1176,33 +1434,36 @@ mod tests { ] ); assert_eq!( - commands_for("new", true), + commands_for("new", true, false), vec![Command::NewChat, Command::NewProject] ); - assert_eq!(commands_for("settings", true), vec![Command::Settings]); assert_eq!( - commands_for("theme", true), + commands_for("settings", true, false), + vec![Command::Settings] + ); + assert_eq!( + commands_for("theme", true, false), vec![Command::Theme(AppearanceMode::Light)] ); - assert!(commands_for("deployment", true).is_empty()); + assert!(commands_for("deployment", true, false).is_empty()); } #[test] fn theme_action_targets_the_opposite_resolved_appearance() { assert_eq!( - commands_for("theme", true), + commands_for("theme", true, false), vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - commands_for("theme", false), + commands_for("theme", false, false), vec![Command::Theme(AppearanceMode::Dark)] ); assert_eq!( - commands_for("light", true), + commands_for("light", true, false), vec![Command::Theme(AppearanceMode::Light)] ); assert_eq!( - commands_for("dark", false), + 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 46164330ef..4397913b0e 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_DEFAULT, 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. Reduce its preferred width before taking /// space from the chat/editor minima; below those minima, share the shortage /// proportionally so no open panel covers another. From 32be256863342175831ef36f4e8e1effa8777349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 19:59:34 -0300 Subject: [PATCH 15/36] feat(ui): ranked thread search in the command palette --- crates/ui/src/shell.rs | 2 - crates/ui/src/shell/command_palette.rs | 112 ++++++++++++++++++------- 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6415b86655..2582d8719b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -77,8 +77,6 @@ mod sidebar_pins; mod sidebar_sections; mod search_warm; pub(crate) mod spaces; -// Adopted by the command palette's Threads section. -#[cfg_attr(not(test), allow(dead_code))] mod thread_search; use side_chats::SideChatTab; mod tabs; diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 1fe0ea5abf..6465daa041 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -6,7 +6,6 @@ use crate::appearance::AppearanceMode; /// Key context of the palette card: its mod-1…4 tab bindings live here. pub(super) const KEY_CONTEXT: &str = "CommandPalette"; -const HISTORY_RESULT_LIMIT: usize = 30; /// 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; @@ -378,9 +377,9 @@ impl Shell { commands(Section::QuickActions), recent_threads(ALL_TAB_LIMIT), ], - (Tab::All, false) => vec![commands(Section::Commands), threads(HISTORY_RESULT_LIMIT)], + (Tab::All, false) => vec![threads(ALL_TAB_LIMIT), commands(Section::Commands)], (Tab::Threads, true) => vec![recent_threads(THREADS_TAB_LIMIT)], - (Tab::Threads, false) => vec![threads(HISTORY_RESULT_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)], @@ -409,25 +408,20 @@ impl Shell { .collect() } - /// Top-level chats matching `query`, in sidebar order. + /// 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!( @@ -436,22 +430,24 @@ 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. - chats - .into_iter() - .take(limit) - .map(|chat| chat.id.clone()) - .collect() + thread_search::rank(scored, limit) } /// Pointer motion moves the highlight, so hover and keyboard never light @@ -1174,6 +1170,66 @@ mod tests { .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); From c5929060f934294b0e903a9761a69260446b1ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 20:04:54 -0300 Subject: [PATCH 16/36] feat(ui): search file names from the command palette --- crates/ui/src/files/client.rs | 5 + crates/ui/src/shell/command_palette.rs | 423 ++++++++++++++++++++++++- 2 files changed, 421 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index f3bfd0fbfb..624ee7df0b 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -127,6 +127,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, diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 6465daa041..ed4d767a5f 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -3,6 +3,8 @@ //! sections; tabs narrow them to one kind. use super::*; use crate::appearance::AppearanceMode; +use crate::files::client::{FilesClientError, FilesRequestContext, WorkspaceFilesClient}; +use zeron_proto::{SearchWorkspaceFilesRequest, WorkspaceEntryKind, WorkspaceFileSearchMatch}; /// Key context of the palette card: its mod-1…4 tab bindings live here. pub(super) const KEY_CONTEXT: &str = "CommandPalette"; @@ -10,6 +12,8 @@ pub(super) const KEY_CONTEXT: &str = "CommandPalette"; const ALL_TAB_LIMIT: usize = 5; const THREADS_TAB_LIMIT: usize = 50; const FILES_TAB_LIMIT: usize = 50; +/// Shorter file-name queries match nearly every path; they are not sent. +const MIN_FILE_QUERY_CHARS: usize = 2; const RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { @@ -26,9 +30,72 @@ pub(super) struct CommandPalette { // 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>, _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, + 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 + } + + /// Header status while loading or after a failure. + fn status(&self) -> Option { + if self.loading { + Some("Searching…".into()) + } else { + self.error.clone() + } + } +} + +/// 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)) +} + +fn search_error_message(error: &FilesClientError) -> SharedString { + match error { + FilesClientError::Unsupported(_) => { + "The session's device runs an older zeron — update it to search its files".into() + } + FilesClientError::Transport(_) => "The session's device is unreachable".into(), + FilesClientError::Encode(_) + | FilesClientError::Decode(_) + | FilesClientError::Request(_) => "File 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)] @@ -91,6 +158,7 @@ enum Section { RecentThreads, Threads, Commands, + Files, } impl Section { @@ -101,6 +169,7 @@ impl Section { Self::RecentThreads => "Recent Threads", Self::Threads => "Threads", Self::Commands => "Commands", + Self::Files => "Files", } } } @@ -179,6 +248,8 @@ impl PaletteRow { struct PaletteSection { section: Section, rows: Vec, + /// Shown beside the title: "Searching…", or why the search failed. + status: Option, } impl PaletteSection { @@ -189,8 +260,18 @@ impl PaletteSection { .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 { @@ -281,7 +362,7 @@ impl Shell { } self.add_space = None; 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) { @@ -289,6 +370,7 @@ impl Shell { palette.reset_active(); palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); } + this.search_palette_files(cx); cx.notify(); } }); @@ -303,6 +385,7 @@ impl Shell { enter_press: EnterPress::default(), focus_pending: true, scroll: gpui::ScrollHandle::new(), + files: WorkspaceQuery::default(), _search_events: events, }); cx.notify(); @@ -329,6 +412,54 @@ impl Shell { } } + /// Look the query up among the focused workspace's file names. + fn search_palette_files(&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(); + let Some(client) = workspace.filter(|_| query.chars().count() >= MIN_FILE_QUERY_CHARS) + else { + palette.files.clear(); + return; + }; + let generation = palette.files.begin(); + let request = SearchWorkspaceFilesRequest { + target: client.target().clone(), + query, + include_ignored: false, + limit: Some(FILES_TAB_LIMIT as u16), + }; + palette.files.task = Some(cx.spawn(async move |this, cx| { + let result = client.search(request).await; + this.update(cx, |shell, cx| { + let Some(files) = shell + .command_palette + .as_mut() + .map(|palette| &mut palette.files) + .filter(|files| files.generation == generation) + else { + return; + }; + files.loading = false; + match result { + Ok(results) => { + files.error = None; + files.results = results; + } + Err(error) => { + tracing::warn!(%error, "palette file search failed"); + files.error = Some(search_error_message(&error)); + files.results.clear(); + } + } + cx.notify(); + }) + .ok(); + })); + } + /// The open palette's sections for its tab and query. fn palette_sections(&self, cx: &App) -> Vec { let Some(palette) = &self.command_palette else { @@ -371,23 +502,42 @@ impl Shell { }), ) }; + 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 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)], + (Tab::All, false) => vec![ + threads(ALL_TAB_LIMIT), + commands(Section::Commands), + 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::new(), + (Tab::Files, false) => vec![files(FILES_TAB_LIMIT)], }; sections .into_iter() - .filter(|section| !section.rows.is_empty()) + .filter(|section| !section.is_empty()) .collect() } @@ -745,7 +895,11 @@ impl Shell { } children.push( header - .child(palette_section_header(&theme, section.section.title())) + .child(palette_section_header( + &theme, + section.section.title(), + section.status.clone(), + )) .into_any_element(), ); for row in §ion.rows { @@ -886,15 +1040,26 @@ impl Shell { } } -/// A section's title, in the sidebar's secondary text style. -fn palette_section_header(theme: &Theme, title: &'static str) -> gpui::Div { +/// 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. @@ -1321,6 +1486,250 @@ mod tests { 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. + 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 { + self.reply(&request, serde_json::json!({})); + } + } + } + + fn reply(&self, request: &serde_json::Value, ok: serde_json::Value) { + let reply = serde_json::json!({ "id": request["id"], "ok": ok }); + // 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 }) + } + + #[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(); + } + /// A shell with the real app keymap and two sidebar sessions. fn keyed_palette_window( cx: &mut TestAppContext, From 92327f64133e5b05c415ff75d0624f9199186a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 20:09:54 -0300 Subject: [PATCH 17/36] feat(ui): search file contents from the command palette --- crates/ui/src/files/client.rs | 10 +- crates/ui/src/popover.rs | 41 +++- crates/ui/src/shell/command_palette.rs | 324 ++++++++++++++++++++++--- 3 files changed, 335 insertions(+), 40 deletions(-) diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index 624ee7df0b..5fd5faa1fb 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -5,7 +5,8 @@ use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use tokio::sync::mpsc; use zeron_proto::{ - ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceFilesRequest, + ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceContentRequest, + SearchWorkspaceFilesRequest, WorkspaceContentSearchResult, WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, WatchWorkspaceFilesRequest, WorkspaceDirectoryPage, WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, @@ -189,6 +190,13 @@ 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, diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index c9b28e287c..68a8fa1b81 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2392,10 +2392,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, @@ -2438,6 +2449,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()); @@ -2469,7 +2495,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/command_palette.rs b/crates/ui/src/shell/command_palette.rs index ed4d767a5f..567cdbf196 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -4,7 +4,10 @@ use super::*; use crate::appearance::AppearanceMode; use crate::files::client::{FilesClientError, FilesRequestContext, WorkspaceFilesClient}; -use zeron_proto::{SearchWorkspaceFilesRequest, WorkspaceEntryKind, WorkspaceFileSearchMatch}; +use zeron_proto::{ + SearchWorkspaceContentRequest, SearchWorkspaceFilesRequest, WorkspaceContentMatch, + WorkspaceEntryKind, WorkspaceFileSearchMatch, +}; /// Key context of the palette card: its mod-1…4 tab bindings live here. pub(super) const KEY_CONTEXT: &str = "CommandPalette"; @@ -12,8 +15,12 @@ pub(super) const KEY_CONTEXT: &str = "CommandPalette"; const ALL_TAB_LIMIT: usize = 5; const THREADS_TAB_LIMIT: usize = 50; const FILES_TAB_LIMIT: usize = 50; -/// Shorter file-name queries match nearly every path; they are not sent. -const MIN_FILE_QUERY_CHARS: usize = 2; +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); const RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { @@ -32,6 +39,8 @@ pub(super) struct CommandPalette { scroll: gpui::ScrollHandle, /// File-name matches in the focused chat's workspace. files: WorkspaceQuery>, + /// Content matches in the focused chat's workspace. + content: WorkspaceQuery>, _search_events: Subscription, } @@ -42,6 +51,8 @@ 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>, @@ -64,14 +75,45 @@ impl WorkspaceQuery { self.generation } - /// Header status while loading or after a failure. + /// Header status while loading, while the host indexes, or after a + /// failure. fn status(&self) -> Option { - if self.loading { + 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. @@ -84,15 +126,22 @@ fn palette_workspace(shell: &Shell, cx: &App) -> Option { Some(WorkspaceFilesClient::new(state.engine()?.clone(), context)) } -fn search_error_message(error: &FilesClientError) -> SharedString { +/// `what` ("file" or "content") names the search in the message. +fn search_error_message(error: &FilesClientError, what: &str) -> SharedString { match error { FilesClientError::Unsupported(_) => { - "The session's device runs an older zeron — update it to search its files".into() + 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(_) => "File search failed".into(), + | FilesClientError::Request(_) => "Search failed".into(), } } @@ -159,6 +208,7 @@ enum Section { Threads, Commands, Files, + InFiles, } impl Section { @@ -170,6 +220,7 @@ impl Section { Self::Threads => "Threads", Self::Commands => "Commands", Self::Files => "Files", + Self::InFiles => "In Files", } } } @@ -216,6 +267,8 @@ enum RowKind { path: String, is_dir: bool, }, + /// A line of a file in the focused chat's workspace. + Content(WorkspaceContentMatch), } #[derive(Clone, Debug, PartialEq)] @@ -238,6 +291,9 @@ impl PaletteRow { 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) } @@ -370,7 +426,7 @@ impl Shell { palette.reset_active(); palette.scroll.set_offset(gpui::point(px(0.0), px(0.0))); } - this.search_palette_files(cx); + this.search_palette_workspace(cx); cx.notify(); } }); @@ -386,6 +442,7 @@ impl Shell { focus_pending: true, scroll: gpui::ScrollHandle::new(), files: WorkspaceQuery::default(), + content: WorkspaceQuery::default(), _search_events: events, }); cx.notify(); @@ -412,49 +469,60 @@ impl Shell { } } - /// Look the query up among the focused workspace's file names. - fn search_palette_files(&mut self, cx: &mut Context) { + /// 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(); - let Some(client) = workspace.filter(|_| query.chars().count() >= MIN_FILE_QUERY_CHARS) + let Some(client) = workspace.filter(|_| 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: query.clone(), include_ignored: false, limit: Some(FILES_TAB_LIMIT as u16), }; + let names = client.clone(); palette.files.task = Some(cx.spawn(async move |this, cx| { - let result = client.search(request).await; + let result = names.search(request).await.map(|found| (found, false)); this.update(cx, |shell, cx| { - let Some(files) = shell - .command_palette - .as_mut() - .map(|palette| &mut palette.files) - .filter(|files| files.generation == generation) - else { - return; - }; - files.loading = false; - match result { - Ok(results) => { - files.error = None; - files.results = results; - } - Err(error) => { - tracing::warn!(%error, "palette file search failed"); - files.error = Some(search_error_message(&error)); - files.results.clear(); - } + if let Some(palette) = shell.command_palette.as_mut() + && palette.files.finish(generation, result, "file") + { + cx.notify(); + } + }) + .ok(); + })); + + 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 result = client + .search_content(request) + .await + .map(|found| (found.matches, found.indexing)); + this.update(cx, |shell, cx| { + if let Some(palette) = shell.command_palette.as_mut() + && palette.content.finish(generation, result, "content") + { + cx.notify(); } - cx.notify(); }) .ok(); })); @@ -517,6 +585,19 @@ impl Shell { ) .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), @@ -527,13 +608,14 @@ impl Shell { 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)], + (Tab::Files, false) => vec![files(FILES_TAB_LIMIT), in_files(CONTENT_TAB_LIMIT)], }; sections .into_iter() @@ -671,6 +753,11 @@ impl Shell { 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 = u32::try_from(found.column + 1).ok(); + self.open_palette_path(found.path, false, Some((line, column)), window, cx) + } } } @@ -806,6 +893,56 @@ impl Shell { ) .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)?; @@ -1499,6 +1636,7 @@ mod tests { 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; @@ -1513,7 +1651,14 @@ mod tests { } fn reply(&self, request: &serde_json::Value, ok: serde_json::Value) { - let reply = serde_json::json!({ "id": request["id"], "ok": ok }); + 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(); @@ -1730,6 +1875,113 @@ mod tests { .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_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, From dd854c45f9555b398e081e2b7b087447427a4c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 20:20:06 -0300 Subject: [PATCH 18/36] perf(engine): return an evicted index's memory to the allocator --- .../engine/examples/workspace_search_bench.rs | 5 +++++ crates/engine/src/workspace_search.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs index 3146fc3de7..86385252bc 100644 --- a/crates/engine/examples/workspace_search_bench.rs +++ b/crates/engine/examples/workspace_search_bench.rs @@ -95,6 +95,11 @@ fn main() -> Result<(), Box> { ); } 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(()) } diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index 56f012b0f9..b5bf1e210c 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -280,6 +280,15 @@ impl WorkspaceSearch { 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"); + } + } + /// Number of live indexes. pub fn live(&self) -> usize { lock(&self.inner.entries).len() @@ -449,6 +458,14 @@ fn release(root: &Path, entry: Entry, reason: &'static str) { 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, From b2e8dde3139b25cda03531863df07f7bec997170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 20:25:35 -0300 Subject: [PATCH 19/36] perf(engine): cap each search index's content cache at 16 MB --- crates/engine/src/workspace_search.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index b5bf1e210c..2d17d0f6a4 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -27,7 +27,7 @@ pub const IDLE_EVICT: Duration = Duration::from_secs(5 * 60); /// Live indexes at once; creating one more evicts the least recently used. pub const MAX_LIVE: usize = 3; /// Per-index cap on cached file contents (fff defaults to up to 512 MB). -pub const CACHE_BUDGET_BYTES: u64 = 64 * 1024 * 1024; +pub const CACHE_BUDGET_BYTES: u64 = 16 * 1024 * 1024; /// A pin outlives the UI's 60 s warm heartbeat by a comfortable margin, so a /// client that disappears cannot keep its index alive forever. pub const PIN_TTL: Duration = Duration::from_secs(3 * 60); From 7ed0d3538edc6ba01c9b6b9e7ce4dbb7a7743bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 20:28:31 -0300 Subject: [PATCH 20/36] docs: workspace search on fff --- docs/file-tree-interactions.md | 11 ++ docs/performance-workspace-search.md | 127 ++++++++++++++++++ docs/screenshots/command-palette/README.md | 56 +++++++- .../tabs-commands-no-results.png | Bin 0 -> 71541 bytes .../command-palette/tabs-empty-state.png | Bin 0 -> 99915 bytes .../command-palette/tabs-thread-search.png | Bin 0 -> 75813 bytes 6 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 docs/performance-workspace-search.md create mode 100644 docs/screenshots/command-palette/tabs-commands-no-results.png create mode 100644 docs/screenshots/command-palette/tabs-empty-state.png create mode 100644 docs/screenshots/command-palette/tabs-thread-search.png 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..f815c89881 --- /dev/null +++ b/docs/performance-workspace-search.md @@ -0,0 +1,127 @@ +# 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 5 minutes without a search or warm-up (`IDLE_EVICT`; a reaper + thread checks every 30 s). +- At most 3 live indexes (`MAX_LIVE`); creating a fourth evicts the least + recently used. The focused chat's index is *pinned*: the UI warms it with + `pin: true` on focus and every 60 s, and releases the pin when focus moves. + A pin lasts 3 minutes (`PIN_TTL`) without renewal, so a vanished client + cannot keep an index alive. Pinned indexes are evicted for capacity only + when all three are pinned. +- 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: content indexing on (except for the home folder, which is +searched unindexed), 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). + +## 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 three). + +## 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 0000000000000000000000000000000000000000..843bd14da6395bd73c4342c1c96cfff28842f481 GIT binary patch literal 71541 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%0DK-%cU}x{p>RNt~;_L2s8~|Sl75| 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7ba8c24fad2808af411c1a2d1413b64eb53d359f GIT binary patch literal 99915 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 D>fnwS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94a945c141e1058ac1e698a8d82d2ab94ab878ab GIT binary patch literal 75813 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 Date: Fri, 9 Oct 2026 21:42:35 -0300 Subject: [PATCH 21/36] feat(rpc): add SearchHomeFolders --- crates/engine/src/repos.rs | 267 +++++++++++++++++++++++++- crates/engine/src/rpc.rs | 26 +++ crates/engine/src/workspace_search.rs | 84 +++++++- crates/proto/src/entities.rs | 29 +++ crates/rpc/src/lib.rs | 4 + 5 files changed, 403 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index 76c9bee45a..8685754bcf 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -19,8 +19,8 @@ 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; @@ -42,6 +42,13 @@ 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); pub const GIT_HISTORY_DEFAULT_LIMIT: usize = 100; pub const GIT_HISTORY_MAX_LIMIT: usize = 200; @@ -1409,6 +1416,36 @@ impl Repos { .await } + // ── 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 focused projectless chat pins it. + pub fn release_home_folder_search(&self) { + if let Ok(home) = home_search_root() { + self.inner.workspace_search.release_if_unpinned(&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 @@ -1930,6 +1967,120 @@ fn search_files_blocking( Ok(matches) } +/// 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, + limit: usize, +) -> Result { + search_folders_under(search, &home_search_root()?, query, limit) +} + +/// `SearchHomeFolders` against an already-resolved (canonical) root. +fn search_folders_under( + search: &WorkspaceSearch, + home: &Path, + query: &str, + limit: usize, +) -> Result { + // 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), + }; + 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: home.join(&relative).to_string_lossy().into_owned(), + relative, + is_repo, + }) + .collect(); + Ok(HomeFolderSearchResult { + matches, + indexing: found.indexing, + }) +} + +/// 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; + } + let tail = &path[path.len() - segments.len()..]; + if !tail + .iter() + .zip(&segments) + .all(|(seg, q)| seg.starts_with(q)) + { + return 0; + } + return if tail.last() == segments.last() { 3 } else { 2 }; + } + 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() + .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") @@ -2330,6 +2481,118 @@ 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, + home.join("work/clients/comet").to_string_lossy() + ); + assert!( + search_folders_under(&search, &home, "", 20) + .unwrap() + .matches + .is_empty() + ); + } + + 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, diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index e93a057d30..58da191b9c 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1437,6 +1437,8 @@ 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 @@ -3034,6 +3036,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 @@ -4059,6 +4083,8 @@ mod tests { 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_search.rs b/crates/engine/src/workspace_search.rs index 2d17d0f6a4..5627340215 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -17,9 +17,9 @@ use std::time::{Duration, Instant}; use fff_search::file_picker::FilePicker; use fff_search::{ - Casing, ContentCacheBudget, FFFMode, FilePickerOptions, FuzzySearchOptions, GrepConfig, - GrepMode, GrepSearchOptions, MixedItemRef, MixedSearchConfig, PaginationArgs, QueryParser, - SharedFilePicker, SharedFrecency, + Casing, ContentCacheBudget, DirSearchConfig, FFFMode, FilePickerOptions, FuzzySearchOptions, + GrepConfig, GrepMode, GrepSearchOptions, MixedItemRef, MixedSearchConfig, PaginationArgs, + QueryParser, SharedFilePicker, SharedFrecency, }; /// An index nobody searched or warmed for this long is dropped. @@ -57,6 +57,7 @@ pub enum IndexState { pub enum NameKinds { FilesAndDirectories, Files, + Directories, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -148,7 +149,7 @@ impl WorkspaceSearch { } /// Fuzzy file (and directory) names. An empty query lists files by - /// recency (git and modification time). + /// recency (git and modification time), or no directories at all. pub fn search_names( &self, root: &Path, @@ -173,7 +174,24 @@ impl WorkspaceSearch { ..Default::default() }; let mut matches = Vec::with_capacity(limit); - if kinds == NameKinds::Files || query.trim().is_empty() { + 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) { @@ -289,6 +307,24 @@ impl WorkspaceSearch { } } + /// Drop `root`'s index now unless it is pinned (a focused chat's), for a + /// caller done with an index it may have started. `true` if dropped. + pub fn release_if_unpinned(&self, root: &Path) -> bool { + let now = (self.inner.clock)(); + let removed = { + let mut entries = lock(&self.inner.entries); + let pinned = entries + .get(root) + .is_some_and(|entry| entry.pinned_until.is_some_and(|until| until > now)); + if pinned { 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() @@ -559,6 +595,44 @@ mod tests { 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] + fn releasing_spares_a_pinned_index() { + let (_a, a) = folder(&[("a.rs", b"")]); + let (_b, b) = folder(&[("b.rs", b"")]); + let search = WorkspaceSearch::new(); + search.warm(&a, true).unwrap(); + search.warm(&b, false).unwrap(); + + assert!(!search.release_if_unpinned(&a)); + assert!(search.release_if_unpinned(&b)); + assert!(!search.release_if_unpinned(&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"")]); diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 56158de90c..4d6aae7b49 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")] diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 92fc282123..e3d4deab42 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -163,6 +163,10 @@ 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; From 0cfd6d1769f01c790727f9438759ec87026138e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 21:42:35 -0300 Subject: [PATCH 22/36] feat(ui): search folders under home when adding a project --- crates/ui/src/shell.rs | 9 +- crates/ui/src/shell/command_palette.rs | 2 +- crates/ui/src/shell/spaces.rs | 671 ++++++++++++++++++++++++- 3 files changed, 660 insertions(+), 22 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2582d8719b..01d7ad0187 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -9749,8 +9749,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() { @@ -13053,8 +13052,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); } @@ -13376,8 +13374,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 567cdbf196..b7f4dd4bff 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -416,7 +416,7 @@ 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 threads, commands and files…", "PaletteSearch", cx) }); diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 9f52147dad..8fdf55f8a3 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -12,7 +12,10 @@ use super::*; use crate::pickers::{breadcrumbs, browser_rows, completion_prefix_len, parent_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. @@ -2289,9 +2292,194 @@ 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; + +/// 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 +/// focused projectless chat pins 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>, +} + +/// Send one `SearchHomeFolders`. `generation` identifies a search whose +/// answer fills the section; `None` is the 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 result = engine + .client() + .call( + methods::SEARCH_HOME_FOLDERS, + serde_json::Value::Object(params), + ) + .await; + this.update(cx, |shell, cx| { + let Some(search) = shell.add_space.as_mut().map(|flow| &mut flow.home_search) else { + return; + }; + if let Err(zeron_rpc::RpcError::UnknownMethod(_)) = &result { + search.unsupported = true; + search.started = false; + search.loading = false; + cx.notify(); + return; + } + if generation != Some(search.generation) { + return; + } + search.loading = 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"), + } + cx.notify(); + }) + .ok(); + }) +} + +/// 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; @@ -5494,6 +5682,7 @@ impl Shell { // ---- add-space flow ---- 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 ↑↓/←/→/⏎ @@ -5514,6 +5703,7 @@ impl Shell { flow.active = 0; flow.list_scroll.set_offset(gpui::Point::default()); } + this.search_home_folders(cx); cx.notify(); } }); @@ -5538,11 +5728,108 @@ 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.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 + /// focused projectless chat pins 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 { @@ -5577,6 +5864,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; }; @@ -5590,9 +5880,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; }; @@ -5724,6 +6017,40 @@ 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())) + .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(&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 @@ -5747,7 +6074,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; }; @@ -5758,10 +6085,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); @@ -5978,7 +6311,7 @@ impl Shell { .find(|s| s.device_id == device.id && s.path == path) .map(|s| s.id.clone()) { - self.add_space = None; + self.close_add_space(cx); self.land_in_space(existing, cx); return; } @@ -6020,7 +6353,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) => { @@ -6105,15 +6438,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() { @@ -6223,6 +6555,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)); @@ -6311,8 +6651,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( @@ -6392,6 +6751,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(), @@ -6633,7 +7000,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); @@ -6724,8 +7091,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, @@ -7201,3 +7567,278 @@ 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 }) + } + + #[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 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() + ); + } +} From 1da2e3c364536314d80c5723c34a05a175ba5725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 21:58:14 -0300 Subject: [PATCH 23/36] perf(engine): index workspace paths only, grep contents without a bigram index --- .../engine/examples/workspace_search_bench.rs | 2 +- crates/engine/src/workspace_search.rs | 12 ++++++---- docs/performance-workspace-search.md | 23 +++++++++++++++++-- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs index 86385252bc..6dafb7e2d6 100644 --- a/crates/engine/examples/workspace_search_bench.rs +++ b/crates/engine/examples/workspace_search_bench.rs @@ -56,7 +56,7 @@ fn main() -> Result<(), Box> { return Err("index did not finish within 10 minutes".into()); } println!( - "index scanned and content-indexed in {:?}", + "index ready in {:?}", started.elapsed() ); print_rss("after index"); diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index 5627340215..0e3bbb9a66 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -6,7 +6,8 @@ //! 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`]. A pinned index (the +//! of each one is capped at [`CACHE_BUDGET_BYTES`]. Indexes hold paths only: +//! content search greps the indexed files without a bigram prefilter. A pinned index (the //! focused chat's, renewed by the UI heartbeat) is evicted for capacity only //! when every live index is pinned. @@ -428,7 +429,6 @@ fn lru_victim(entries: &HashMap, now: Instant) -> Option Result { - let home = std::fs::canonicalize(crate::repos::home_dir()).is_ok_and(|home| home == root); let picker = SharedFilePicker::default(); let started = Instant::now(); FilePicker::new_with_shared_state( @@ -437,9 +437,11 @@ fn create_picker(root: &Path) -> Result FilePickerOptions { base_path: root.to_string_lossy().into_owned(), enable_mmap_cache: false, - // The bigram index makes content search fast; a whole home folder - // is too large to be worth it, so it is searched unindexed there. - enable_content_indexing: !home, + // 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: true, diff --git a/docs/performance-workspace-search.md b/docs/performance-workspace-search.md index f815c89881..f2ad4ead3e 100644 --- a/docs/performance-workspace-search.md +++ b/docs/performance-workspace-search.md @@ -42,8 +42,8 @@ returns: - Eviction stops the scan and watcher, drops the index, and asks glibc to return the freed memory (`malloc_trim`) on Linux. -Fixed options: content indexing on (except for the home folder, which is -searched unindexed), mmap warm-up off, no frecency database (`SharedDb::noop`), +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. @@ -88,6 +88,25 @@ 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 From eab3cc7bc23b16bc1ddd905d835a420d9c51076c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 22:29:03 -0300 Subject: [PATCH 24/36] perf(engine): keep idle search indexes 15 min, up to 7, warmed once on focus --- .../engine/examples/workspace_search_bench.rs | 2 +- crates/engine/src/repos.rs | 4 +- crates/engine/src/workspace_files.rs | 4 +- crates/engine/src/workspace_search.rs | 129 +++++++----------- crates/engine/tests/device_routing.rs | 1 - crates/proto/src/entities.rs | 11 +- crates/ui/src/files/client.rs | 30 ++-- crates/ui/src/files/mod.rs | 4 +- crates/ui/src/shell.rs | 2 +- crates/ui/src/shell/search_warm.rs | 69 ++++------ docs/performance-workspace-search.md | 20 +-- 11 files changed, 109 insertions(+), 167 deletions(-) diff --git a/crates/engine/examples/workspace_search_bench.rs b/crates/engine/examples/workspace_search_bench.rs index 6dafb7e2d6..b33aaa4043 100644 --- a/crates/engine/examples/workspace_search_bench.rs +++ b/crates/engine/examples/workspace_search_bench.rs @@ -51,7 +51,7 @@ fn main() -> Result<(), Box> { let search = repos.workspace_search(); let started = Instant::now(); - search.warm(&root, false)?; + search.warm(&root)?; if !search.wait_until_indexed(&root, Duration::from_secs(600)) { return Err("index did not finish within 10 minutes".into()); } diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index 8685754bcf..abcf3d5a51 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -1439,10 +1439,10 @@ impl Repos { } /// The new-project picker is done with the home index: drop it now - /// unless a focused projectless chat pins it. + /// 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_if_unpinned(&home); + self.inner.workspace_search.release_unless_warmed(&home); } } diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index a9e274502a..99fe960502 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -459,7 +459,7 @@ impl WorkspaceFiles { ) -> 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, request.pin)) + 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()))?; @@ -2589,7 +2589,7 @@ mod tests { per_file: Option, ) -> Result { let search = WorkspaceSearch::new(); - search.warm(root, false).unwrap(); + search.warm(root).unwrap(); let request = SearchWorkspaceContentRequest { target: WorkspaceTarget { chat_id: Some("chat".into()), diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index 0e3bbb9a66..aeeaa9e5d8 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -7,9 +7,8 @@ //! 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. A pinned index (the -//! focused chat's, renewed by the UI heartbeat) is evicted for capacity only -//! when every live index is pinned. +//! 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::collections::HashMap; use std::path::{Path, PathBuf}; @@ -24,20 +23,18 @@ use fff_search::{ }; /// An index nobody searched or warmed for this long is dropped. -pub const IDLE_EVICT: Duration = Duration::from_secs(5 * 60); +pub const IDLE_EVICT: Duration = Duration::from_secs(15 * 60); /// Live indexes at once; creating one more evicts the least recently used. -pub const MAX_LIVE: usize = 3; +/// 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 pin outlives the UI's 60 s warm heartbeat by a comfortable margin, so a -/// client that disappears cannot keep its index alive forever. -pub const PIN_TTL: Duration = Duration::from_secs(3 * 60); /// 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); /// 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(30); +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); @@ -108,13 +105,18 @@ pub struct WorkspaceSearch { 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, - pinned_until: Option, + /// 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 { @@ -125,23 +127,23 @@ impl Default for WorkspaceSearch { impl WorkspaceSearch { pub fn new() -> Self { - Self::with_clock(Arc::new(Instant::now)) + Self::with_clock(Arc::new(Instant::now), true) } - fn with_clock(clock: Clock) -> Self { + 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 `root`. `pin` protects it from - /// capacity eviction for [`PIN_TTL`]; `pin: false` releases a pin. - pub fn warm(&self, root: &Path, pin: bool) -> Result { - let picker = self.acquire(root, Some(pin))?; + /// 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 { @@ -158,7 +160,7 @@ impl WorkspaceSearch { limit: usize, kinds: NameKinds, ) -> Result { - let picker = self.acquire(root, None)?; + let picker = self.acquire(root, false)?; let indexing = !picker.wait_for_scan(SCAN_WAIT); if limit == 0 { return Ok(NameSearch { @@ -236,7 +238,7 @@ impl WorkspaceSearch { limit: usize, per_file: usize, ) -> Result { - let picker = self.acquire(root, None)?; + 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 { @@ -308,16 +310,13 @@ impl WorkspaceSearch { } } - /// Drop `root`'s index now unless it is pinned (a focused chat's), for a - /// caller done with an index it may have started. `true` if dropped. - pub fn release_if_unpinned(&self, root: &Path) -> bool { - let now = (self.inner.clock)(); + /// 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 pinned = entries - .get(root) - .is_some_and(|entry| entry.pinned_until.is_some_and(|until| until > now)); - if pinned { None } else { entries.remove(root) } + 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; @@ -352,11 +351,8 @@ impl WorkspaceSearch { } } - fn acquire( - &self, - root: &Path, - pin: Option, - ) -> Result { + /// `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; @@ -364,24 +360,20 @@ impl WorkspaceSearch { let mut entries = lock(&self.inner.entries); if let Some(entry) = entries.get_mut(root) { entry.last_used = now; - match pin { - Some(true) => entry.pinned_until = Some(now + PIN_TTL), - Some(false) => entry.pinned_until = None, - None => {} - } + entry.warmed |= warm; entry.picker.clone() } else { if entries.len() >= MAX_LIVE { - evicted = lru_victim(&entries, now) + evicted = lru_victim(&entries) .and_then(|victim| entries.remove(&victim).map(|entry| (victim, entry))); } - let picker = create_picker(root)?; + let picker = create_picker(root, self.inner.watch)?; entries.insert( root.to_path_buf(), Entry { picker: picker.clone(), last_used: now, - pinned_until: (pin == Some(true)).then(|| now + PIN_TTL), + warmed: warm, }, ); picker @@ -416,19 +408,14 @@ fn reap(inner: Weak) { } } -/// Least recently used unpinned index; the least recently used overall when -/// every index is pinned (the live cap holds regardless). -fn lru_victim(entries: &HashMap, now: Instant) -> Option { - let pinned = |entry: &Entry| entry.pinned_until.is_some_and(|until| until > now); +fn lru_victim(entries: &HashMap) -> Option { entries .iter() - .filter(|(_, entry)| !pinned(entry)) .min_by_key(|(_, entry)| entry.last_used) - .or_else(|| entries.iter().min_by_key(|(_, entry)| entry.last_used)) .map(|(root, _)| root.clone()) } -fn create_picker(root: &Path) -> Result { +fn create_picker(root: &Path, watch: bool) -> Result { let picker = SharedFilePicker::default(); let started = Instant::now(); FilePicker::new_with_shared_state( @@ -444,7 +431,7 @@ fn create_picker(root: &Path) -> Result enable_content_indexing: false, mode: FFFMode::Neovim, cache_budget: ContentCacheBudget::from_overrides(0, CACHE_BUDGET_BYTES, 0), - watch: true, + watch, follow_symlinks: false, enable_fs_root_scanning: false, // Projectless chats live in the home folder; fff skips its dotfiles @@ -541,7 +528,7 @@ mod tests { let clock = Arc::new(ManualClock(Mutex::new(Instant::now()))); let reader = clock.clone(); ( - WorkspaceSearch::with_clock(Arc::new(move || *lock(&reader.0))), + WorkspaceSearch::with_clock(Arc::new(move || *lock(&reader.0)), false), clock, ) } @@ -558,7 +545,7 @@ mod tests { } fn ready(search: &WorkspaceSearch, root: &Path) -> SharedFilePicker { - search.warm(root, false).unwrap(); + search.warm(root).unwrap(); let picker = lock(&search.inner.entries)[root].picker.clone(); assert!(picker.wait_for_indexing_complete(Duration::from_secs(20))); picker @@ -620,16 +607,18 @@ mod tests { } #[test] - fn releasing_spares_a_pinned_index() { + 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, true).unwrap(); - search.warm(&b, false).unwrap(); + search.warm(&a).unwrap(); + search + .search_names(&b, "b", 5, NameKinds::Directories) + .unwrap(); - assert!(!search.release_if_unpinned(&a)); - assert!(search.release_if_unpinned(&b)); - assert!(!search.release_if_unpinned(&b)); + 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)); @@ -656,9 +645,9 @@ mod tests { let (_a, a) = folder(&[("a.rs", b"")]); let (_b, b) = folder(&[("b.rs", b"")]); let (search, clock) = manual(); - search.warm(&a, false).unwrap(); + search.warm(&a).unwrap(); clock.advance(IDLE_EVICT / 2); - search.warm(&b, false).unwrap(); + search.warm(&b).unwrap(); clock.advance(IDLE_EVICT / 2); search.evict_idle(); @@ -670,36 +659,20 @@ mod tests { #[test] fn creating_past_the_cap_evicts_the_least_recently_used() { - let folders: Vec<_> = (0..4).map(|_| folder(&[("x.rs", b"")])).collect(); + let folders: Vec<_> = (0..=MAX_LIVE).map(|_| folder(&[("x.rs", b"")])).collect(); let (search, clock) = manual(); - for (_, root) in &folders[..3] { - search.warm(root, false).unwrap(); + 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, false).unwrap(); + search.warm(&folders[0].1).unwrap(); clock.advance(Duration::from_secs(1)); - search.warm(&folders[3].1, false).unwrap(); - - let entries = lock(&search.inner.entries); - assert_eq!(entries.len(), MAX_LIVE); - assert!(!entries.contains_key(&folders[1].1)); - } - - #[test] - fn a_pinned_index_survives_capacity_eviction() { - let folders: Vec<_> = (0..4).map(|_| folder(&[("x.rs", b"")])).collect(); - let (search, clock) = manual(); - search.warm(&folders[0].1, true).unwrap(); - for (_, root) in &folders[1..] { - clock.advance(Duration::from_secs(1)); - search.warm(root, false).unwrap(); - } + search.warm(&folders[MAX_LIVE].1).unwrap(); let entries = lock(&search.inner.entries); assert_eq!(entries.len(), MAX_LIVE); - assert!(entries.contains_key(&folders[0].1)); assert!(!entries.contains_key(&folders[1].1)); } diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index ff37207d8d..8602fe4da2 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -1447,7 +1447,6 @@ async fn workspace_file_surface_proxies_over_the_relay() { methods::WARM_WORKSPACE_SEARCH, serde_json::json!({ "chatId": "chat-files", - "pin": true, "targetDeviceId": "device-b", }), ) diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 4d6aae7b49..80083d730e 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -623,16 +623,13 @@ pub struct WorkspaceContentSearchResult { pub indexing: bool, } -/// Start (or keep alive) the host's search index for a workspace. `pin` -/// marks it as the focused chat's: the UI renews it every minute, and a -/// pinned index is the last to be evicted. `pin: false` releases the pin. +/// 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, - #[serde(default)] - pub pin: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1472,10 +1469,10 @@ mod tests { #[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" })).unwrap(); + 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!(!request.pin); assert_eq!( serde_json::to_value(WarmWorkspaceSearchResult { state: WorkspaceSearchIndexState::Building, diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index 5fd5faa1fb..8d70505ad9 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -6,10 +6,10 @@ use serde_json::Value; use tokio::sync::mpsc; use zeron_proto::{ ListWorkspaceDirectoryRequest, ReadWorkspaceFileRequest, SearchWorkspaceContentRequest, - SearchWorkspaceFilesRequest, WorkspaceContentSearchResult, - WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, WatchWorkspaceFilesRequest, - WorkspaceDirectoryPage, WorkspaceFileSearchMatch, - WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, WriteWorkspaceFileRequest, + SearchWorkspaceFilesRequest, WarmWorkspaceSearchRequest, WarmWorkspaceSearchResult, + WatchWorkspaceFilesRequest, WorkspaceContentSearchResult, WorkspaceDirectoryPage, + WorkspaceFileSearchMatch, WorkspaceFileText, WorkspaceTarget, WriteWorkspaceFileOutcome, + WriteWorkspaceFileRequest, }; use zeron_rpc::{RpcError, methods}; @@ -198,27 +198,19 @@ impl WorkspaceFilesClient { } /// Start or keep alive the host's search index for this workspace. - pub async fn warm_search( - &self, - pin: bool, - ) -> Result { + pub async fn warm_search(&self) -> Result { let request = WarmWorkspaceSearchRequest { target: self.context.target.clone(), - pin, }; self.call(methods::WARM_WORKSPACE_SEARCH, &request).await } - /// [`Self::warm_search`] as a hint: failures are logged, never surfaced. - /// `false` once the host turns out not to support it. - pub async fn hint_search_warm(&self, pin: bool) -> bool { - match self.warm_search(pin).await { - Ok(_) => true, - Err(FilesClientError::Unsupported(_)) => false, - Err(error) => { - tracing::debug!(%error, "workspace search warm-up failed"); - true - } + /// [`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"), } } diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 7d574b210e..2c3b03bd64 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -882,14 +882,14 @@ 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 (unpinned: the focused chat's pin is the shell's). + // 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(false).await; + client.hint_search_warm().await; }) .detach(); } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 01d7ad0187..7e6588dc92 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2055,7 +2055,7 @@ pub struct Shell { /// The New project palette's collapsed-breadcrumbs (`…`) menu. project_crumb_menu: popover::Popup<()>, command_palette: Option, - /// The focused chat's pinned workspace search index (see `search_warm`). + /// 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>, diff --git a/crates/ui/src/shell/search_warm.rs b/crates/ui/src/shell/search_warm.rs index bf1a14ed4c..5c0d76ed9a 100644 --- a/crates/ui/src/shell/search_warm.rs +++ b/crates/ui/src/shell/search_warm.rs @@ -1,27 +1,20 @@ -//! Keeps the focused chat's workspace search index warm on its host, so the -//! first `@`, file-tree or cmd+K search in a chat does not pay for the scan. -//! -//! Focusing a chat warms its index pinned and renews the pin every -//! [`WARM_HEARTBEAT`]; leaving it releases the pin (`pin: false`), handing -//! the index to the host's idle eviction. Hosts that predate +//! 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 std::time::Duration; - -use gpui::{Context, Task}; +use gpui::Context; use super::Shell; use crate::files::client::{FilesRequestContext, WorkspaceFilesClient}; use crate::state::EngineHandle; -/// Well inside the host's pin lifetime (`PIN_TTL`, 3 min). -pub(super) const WARM_HEARTBEAT: Duration = Duration::from_secs(60); - +/// The focus last warmed, so unrelated state churn does not warm again. pub(super) struct SearchWarm { chat_id: String, context: FilesRequestContext, engine: EngineHandle, - _heartbeat: Task<()>, } impl SearchWarm { @@ -31,7 +24,7 @@ impl SearchWarm { } impl Shell { - /// Follow the selected chat: pin its index, unpin the one left behind. + /// 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); @@ -41,31 +34,24 @@ impl Shell { }); (focused, state.engine().cloned()) }; - let current = focused.as_ref().zip(engine.as_ref()); - if let (Some(warm), Some(((chat_id, context), engine))) = (&self.search_warm, current) - && warm.is_for(chat_id, context, engine) - { - return; - } - if let Some(previous) = self.search_warm.take() { - let client = WorkspaceFilesClient::new(previous.engine, previous.context); - cx.spawn(async move |_, _| client.hint_search_warm(false).await) - .detach(); - } 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()); - let heartbeat = cx.spawn(async move |_, cx| { - while client.hint_search_warm(true).await { - cx.background_executor().timer(WARM_HEARTBEAT).await; - } - }); + cx.spawn(async move |_, _| client.hint_search_warm().await) + .detach(); self.search_warm = Some(SearchWarm { chat_id, context, engine, - _heartbeat: heartbeat, }); } } @@ -119,13 +105,13 @@ mod tests { .unwrap() } - /// `(chatId, pin)` of every WarmWorkspaceSearch sent so far, answering each. + /// 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<(String, bool)> { + ) -> Vec { let mut warms = Vec::new(); loop { cx.run_until_parked(); @@ -134,10 +120,7 @@ mod tests { }; 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(), - request["params"]["pin"].as_bool().unwrap(), - )); + 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(); @@ -145,7 +128,7 @@ mod tests { } #[gpui::test] - fn focusing_a_chat_pins_its_index_and_releases_the_previous(cx: &mut gpui::TestAppContext) { + fn focusing_a_chat_warms_its_index_once(cx: &mut gpui::TestAppContext) { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -168,10 +151,7 @@ mod tests { }); }) .unwrap(); - assert_eq!( - drain_warms(cx, &runtime, &mut requests, &replies), - vec![("a".to_owned(), true)] - ); + assert_eq!(drain_warms(cx, &runtime, &mut requests, &replies), ["a"]); window .update(cx, |shell, _, cx| { @@ -180,9 +160,8 @@ mod tests { .update(cx, |state, cx| state.select_chat(Some("b".into()), cx)); }) .unwrap(); - let mut warms = drain_warms(cx, &runtime, &mut requests, &replies); - warms.sort(); - assert_eq!(warms, vec![("a".to_owned(), false), ("b".to_owned(), true)]); + // 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 diff --git a/docs/performance-workspace-search.md b/docs/performance-workspace-search.md index f2ad4ead3e..e09b708495 100644 --- a/docs/performance-workspace-search.md +++ b/docs/performance-workspace-search.md @@ -29,14 +29,16 @@ returns: 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 5 minutes without a search or warm-up (`IDLE_EVICT`; a reaper - thread checks every 30 s). -- At most 3 live indexes (`MAX_LIVE`); creating a fourth evicts the least - recently used. The focused chat's index is *pinned*: the UI warms it with - `pin: true` on focus and every 60 s, and releases the pin when focus moves. - A pin lasts 3 minutes (`PIN_TTL`) without renewal, so a vanished client - cannot keep an index alive. Pinned indexes are evicted for capacity only - when all three are pinned. +- 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 @@ -131,7 +133,7 @@ build CPU from ~2.0 s to ~0.9 s. 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 three). + live index (at most seven). ## Reproduce From daa72034bfa09595805362f71f0b3637b55c5ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 22:37:16 -0300 Subject: [PATCH 25/36] fix(ui): refresh palette content search while indexing --- crates/ui/src/shell/command_palette.rs | 171 +++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index b7f4dd4bff..4ef07ddf92 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -21,6 +21,8 @@ const CONTENT_MATCHES_PER_FILE: usize = 3; 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. +const CONTENT_INDEX_RETRY: Duration = Duration::from_millis(500); const RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { @@ -513,18 +515,31 @@ impl Shell { }; palette.content.task = Some(cx.spawn(async move |this, cx| { cx.background_executor().timer(CONTENT_DEBOUNCE).await; - let result = client - .search_content(request) - .await - .map(|found| (found.matches, found.indexing)); - this.update(cx, |shell, cx| { - if let Some(palette) = shell.command_palette.as_mut() - && palette.content.finish(generation, result, "content") - { - cx.notify(); + 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; } - }) - .ok(); + // 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(CONTENT_INDEX_RETRY).await; + } })); } @@ -1944,6 +1959,140 @@ mod tests { 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); + 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 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); From d3435bbf56de92ccd62161448e6a0bb3236119c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 22:41:07 -0300 Subject: [PATCH 26/36] fix(engine): preserve literal workspace content queries --- crates/engine/src/workspace_files.rs | 60 +++++++++++++++++++++++++++ crates/engine/src/workspace_search.rs | 10 +++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 99fe960502..146a70d0c9 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -2643,6 +2643,66 @@ mod tests { 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(); diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index aeeaa9e5d8..345d82086a 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -18,8 +18,8 @@ use std::time::{Duration, Instant}; use fff_search::file_picker::FilePicker; use fff_search::{ Casing, ContentCacheBudget, DirSearchConfig, FFFMode, FilePickerOptions, FuzzySearchOptions, - GrepConfig, GrepMode, GrepSearchOptions, MixedItemRef, MixedSearchConfig, PaginationArgs, - QueryParser, SharedFilePicker, SharedFrecency, + GrepMode, GrepSearchOptions, MixedItemRef, MixedSearchConfig, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, }; /// An index nobody searched or warmed for this long is dropped. @@ -250,7 +250,6 @@ impl WorkspaceSearch { let Some(fff) = guard.as_ref() else { return Ok(ContentSearch::default()); }; - let parsed = QueryParser::new(GrepConfig).parse(query); let options = GrepSearchOptions { max_matches_per_file: per_file, casing: Some(Casing::Smart), @@ -260,7 +259,10 @@ impl WorkspaceSearch { enforce_time_budget: true, ..Default::default() }; - let result = fff.grep(&parsed, &options); + // 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 { From 31d647b4203f1b91b8225176eb0251bb39e1e25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 23:22:36 -0300 Subject: [PATCH 27/36] fix(ui): refresh palette file search while indexing --- crates/ui/src/shell/command_palette.rs | 230 +++++++++++++++++++++++-- 1 file changed, 220 insertions(+), 10 deletions(-) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 4ef07ddf92..040272f264 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -6,7 +6,7 @@ use crate::appearance::AppearanceMode; use crate::files::client::{FilesClientError, FilesRequestContext, WorkspaceFilesClient}; use zeron_proto::{ SearchWorkspaceContentRequest, SearchWorkspaceFilesRequest, WorkspaceContentMatch, - WorkspaceEntryKind, WorkspaceFileSearchMatch, + WorkspaceEntryKind, WorkspaceFileSearchMatch, WorkspaceSearchIndexState, }; /// Key context of the palette card: its mod-1…4 tab bindings live here. @@ -23,6 +23,7 @@ const MIN_WORKSPACE_QUERY_CHARS: usize = 2; const CONTENT_DEBOUNCE: Duration = Duration::from_millis(80); /// Refresh partial results until the host finishes its initial scan. const CONTENT_INDEX_RETRY: Duration = Duration::from_millis(500); +const FILE_INDEX_RETRY: Duration = Duration::from_millis(500); const RESULTS_FADE_BAND: f32 = 18.0; pub(super) struct CommandPalette { @@ -495,15 +496,42 @@ impl Shell { }; let names = client.clone(); palette.files.task = Some(cx.spawn(async move |this, cx| { - let result = names.search(request).await.map(|found| (found, false)); - this.update(cx, |shell, cx| { - if let Some(palette) = shell.command_palette.as_mut() - && palette.files.finish(generation, result, "file") - { - cx.notify(); + loop { + 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. + let indexing = 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), + }; + names + .search(request.clone()) + .await + .map(|found| (found, indexing)) } - }) - .ok(); + .await; + let retry = this + .update(cx, |shell, cx| { + let Some(palette) = shell.command_palette.as_mut() else { + return false; + }; + 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; + } })); let generation = palette.content.begin(); @@ -1647,7 +1675,7 @@ mod tests { impl FakeEngine { /// Every request sent so far for `method`; other methods are - /// answered with an empty object. + /// 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 { @@ -1659,6 +1687,8 @@ mod tests { 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!({})); } @@ -1760,6 +1790,186 @@ mod tests { 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() + } + + #[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); From bc12118653afc0c0878dc7b17b13adb41530814c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 23:31:13 -0300 Subject: [PATCH 28/36] fix(ui): refresh home folder search while indexing --- crates/ui/src/shell/spaces.rs | 327 ++++++++++++++++++++++++++++++---- 1 file changed, 291 insertions(+), 36 deletions(-) diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 8fdf55f8a3..58b4ed24a8 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -2301,6 +2301,8 @@ pub(super) struct AddSpaceFlow { /// 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 @@ -2323,9 +2325,9 @@ struct HomeFolderSearch { warm_task: Option>, } -/// Send one `SearchHomeFolders`. `generation` identifies a search whose -/// answer fills the section; `None` is the warm-up, whose answer only tells -/// whether the host supports the method. +/// 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, @@ -2344,41 +2346,84 @@ fn request_home_folders( _ => return, }; add_space_target(&mut params, device_id.as_deref(), local.as_deref()); - let result = engine - .client() - .call( - methods::SEARCH_HOME_FOLDERS, - serde_json::Value::Object(params), - ) - .await; - this.update(cx, |shell, cx| { - let Some(search) = shell.add_space.as_mut().map(|flow| &mut flow.home_search) else { - return; - }; - if let Err(zeron_rpc::RpcError::UnknownMethod(_)) = &result { - search.unsupported = true; - search.started = false; - search.loading = false; - cx.notify(); - return; - } - if generation != Some(search.generation) { - return; + 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; } - search.loading = 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"), + // 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; } - cx.notify(); - }) - .ok(); + } }) } @@ -5767,6 +5812,7 @@ impl Shell { 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 { @@ -7721,6 +7767,215 @@ mod home_folder_search_tests { 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"); From b971da9f953d03d376d2eb4247f271f19fc964a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 23:48:10 -0300 Subject: [PATCH 29/36] fix: handle extended Windows and UNC paths in home folder picker --- Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/repos.rs | 45 ++++- crates/ui/src/pickers.rs | 310 ++++++++++++++++++++++++++++++---- crates/ui/src/shell/spaces.rs | 120 ++++++++++++- 5 files changed, 437 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d351570d9..3c4d4e04e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13532,6 +13532,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "dunce", "fff-search", "futures", "gethostname", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4bcca072a1..c8d60bacf6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,7 @@ 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/src/repos.rs b/crates/engine/src/repos.rs index abcf3d5a51..5db6193a79 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -1572,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, }) @@ -2013,7 +2013,7 @@ fn search_folders_under( .into_iter() .take(limit) .map(|(relative, is_repo)| HomeFolderMatch { - path: home.join(&relative).to_string_lossy().into_owned(), + path: folder_browser_path(&home.join(&relative)), relative, is_repo, }) @@ -2024,6 +2024,15 @@ fn search_folders_under( }) } +/// 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() +} + /// 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 @@ -2517,7 +2526,7 @@ tmpfs /run tmpfs rw 0 0 ); assert_eq!( result.matches[0].path, - home.join("work/clients/comet").to_string_lossy() + folder_browser_path(&home.join("work/clients/comet")) ); assert!( search_folders_under(&search, &home, "", 20) @@ -2527,6 +2536,36 @@ tmpfs /run tmpfs rw 0 0 ); } + #[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() diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 58ab0cbb71..7dd030090a 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -244,27 +244,156 @@ 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('/', "\\") + }; + if rest.is_empty() { + format!("{root}\\") + } else { + format!("{root}\\{rest}") + } +} + +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 +410,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 +469,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 +516,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())); } @@ -9317,6 +9466,105 @@ 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")); + // 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/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 58b4ed24a8..33f9bfd331 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -9,7 +9,10 @@ //! 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::{ @@ -2570,6 +2573,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 @@ -6077,6 +6082,7 @@ impl Shell { .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(); @@ -6090,7 +6096,7 @@ impl Shell { .map(FolderRow::Local) .chain( home.iter() - .filter(|found| !listed.contains(&found.path)) + .filter(|found| !listed.contains(&folder_path_key(&found.path))) .cloned() .map(FolderRow::Home), ) @@ -6354,7 +6360,7 @@ 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.close_add_space(cx); @@ -6440,7 +6446,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); @@ -6853,7 +6859,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(), @@ -6884,7 +6894,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), }); } @@ -7535,6 +7545,11 @@ 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"\\?\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] @@ -8012,6 +8027,99 @@ mod home_folder_search_tests { 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"); From b2573cea79d9d3143cc94ca2229c1731727fe48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 23:50:07 -0300 Subject: [PATCH 30/36] fix(ui): convert content search byte columns for editor navigation --- crates/ui/src/files/mod.rs | 11 ++- crates/ui/src/files/preview.rs | 116 +++++++++++++++++++++++-- crates/ui/src/shell.rs | 9 +- crates/ui/src/shell/command_palette.rs | 4 +- 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 2c3b03bd64..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<()>>, 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/shell.rs b/crates/ui/src/shell.rs index 7e6588dc92..e83d5ddc98 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::{self, AnimationExt as _, MotionSpec, RESIZE, SPLASH_OUT, TAB_SLIDE}; @@ -3731,7 +3733,7 @@ impl Shell { &mut self, owner: (String, Entity), path: String, - location: Option<(u32, Option)>, + location: Option<(u32, Option)>, window: &mut Window, cx: &mut Context, ) { @@ -3933,7 +3935,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, ); diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 040272f264..8ed9be39c0 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -798,7 +798,7 @@ impl Shell { } RowKind::Content(found) => { let line = u32::try_from(found.line).unwrap_or(u32::MAX); - let column = u32::try_from(found.column + 1).ok(); + let column = Some(FileColumn::Byte(found.column)); self.open_palette_path(found.path, false, Some((line, column)), window, cx) } } @@ -810,7 +810,7 @@ impl Shell { &mut self, path: String, is_dir: bool, - location: Option<(u32, Option)>, + location: Option<(u32, Option)>, window: &mut Window, cx: &mut Context, ) { From 4c7c7dc0fb1e8d38ce1b2c96bab60bef08059987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Fri, 9 Oct 2026 23:55:43 -0300 Subject: [PATCH 31/36] fix(engine): normalize Windows separators in name searches --- crates/engine/src/repos.rs | 51 ++++++++++++++++++++++- crates/engine/src/workspace_files.rs | 46 ++++++++++++++++++-- crates/engine/src/workspace_search.rs | 60 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index 5db6193a79..3a1bd5d520 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -25,7 +25,7 @@ use zeron_proto::{ use crate::EngineError; use crate::WorkspaceSearch; -use crate::workspace_search::NameKinds; +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. @@ -1988,6 +1988,8 @@ fn search_folders_under( query: &str, 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. @@ -2536,6 +2538,53 @@ tmpfs /run tmpfs rw 0 0 ); } + #[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() { diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 146a70d0c9..cc364c8993 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -26,7 +26,7 @@ use zeron_proto::{ }; use zeron_rpc::RpcError; -use crate::workspace_search::{IndexState, NameKinds}; +use crate::workspace_search::{IndexState, NameKinds, normalize_name_query}; use crate::{Repos, WorkspaceHost, WorkspaceSearch}; mod mutations; @@ -1349,7 +1349,7 @@ fn search_including_ignored( } } - let query = query.trim().to_lowercase(); + let query = normalize_name_query(query).trim().to_lowercase(); let mut builder = ignore::WalkBuilder::new(root); builder .follow_links(false) @@ -1393,7 +1393,10 @@ fn search_including_ignored( 2 } else if lower_name.contains(&query) { 1 - } else if relative.to_string_lossy().to_lowercase().contains(&query) { + } else if normalize_name_query(&relative.to_string_lossy()) + .to_lowercase() + .contains(&query) + { 0 } else { continue; @@ -2562,6 +2565,43 @@ mod tests { ); } + #[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(); diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index 345d82086a..b47b6272e4 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -10,6 +10,7 @@ //! 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}; @@ -38,6 +39,16 @@ 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}")] @@ -160,6 +171,8 @@ impl WorkspaceSearch { limit: usize, kinds: NameKinds, ) -> Result { + let query = normalize_name_query(query); + let query = query.as_ref(); let picker = self.acquire(root, false)?; let indexing = !picker.wait_for_scan(SCAN_WAIT); if limit == 0 { @@ -608,6 +621,53 @@ mod tests { ); } + #[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"")]); From 098d87811d0897c795b27f330175ed39e9f918d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 10 Oct 2026 00:09:14 -0300 Subject: [PATCH 32/36] fix(ui): back off palette content refreshes while indexing --- crates/ui/src/shell/command_palette.rs | 43 ++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 8ed9be39c0..27307e0fdf 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -21,8 +21,11 @@ const CONTENT_MATCHES_PER_FILE: usize = 3; 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. +/// 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; @@ -543,6 +546,7 @@ impl Shell { }; 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()) @@ -566,7 +570,8 @@ impl Shell { // 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(CONTENT_INDEX_RETRY).await; + cx.background_executor().timer(delay).await; + delay = (delay * 2).min(CONTENT_INDEX_RETRY_MAX); } })); } @@ -2202,7 +2207,7 @@ mod tests { }) .unwrap(); - cx.executor().advance_clock(CONTENT_INDEX_RETRY); + 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"]); @@ -2237,6 +2242,38 @@ mod tests { ); } + #[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); From 8aeb3345950fea7452a2bc8c9172f5062168acef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 10 Oct 2026 00:13:32 -0300 Subject: [PATCH 33/36] perf(ui): skip the palette's index probe once the workspace is ready --- crates/ui/src/shell/command_palette.rs | 122 +++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 27307e0fdf..fa4a2e340e 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -6,7 +6,7 @@ use crate::appearance::AppearanceMode; use crate::files::client::{FilesClientError, FilesRequestContext, WorkspaceFilesClient}; use zeron_proto::{ SearchWorkspaceContentRequest, SearchWorkspaceFilesRequest, WorkspaceContentMatch, - WorkspaceEntryKind, WorkspaceFileSearchMatch, WorkspaceSearchIndexState, + WorkspaceEntryKind, WorkspaceFileSearchMatch, WorkspaceSearchIndexState, WorkspaceTarget, }; /// Key context of the palette card: its mod-1…4 tab bindings live here. @@ -45,6 +45,10 @@ pub(super) struct CommandPalette { 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, @@ -448,6 +452,7 @@ impl Shell { focus_pending: true, scroll: gpui::ScrollHandle::new(), files: WorkspaceQuery::default(), + files_ready: None, content: WorkspaceQuery::default(), _search_events: events, }); @@ -498,29 +503,44 @@ impl Shell { limit: Some(FILES_TAB_LIMIT as u16), }; let names = client.clone(); + let target = client.target().clone(); + let mut probe = 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. - let indexing = 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), + // 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; } @@ -1821,6 +1841,96 @@ mod tests { 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 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); From e88194b1165ada9727e43f7e07958e6786b71088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 10 Oct 2026 00:19:34 -0300 Subject: [PATCH 34/36] fix(ui): match Windows folder paths regardless of drive letter case --- crates/ui/src/pickers.rs | 21 +++++++++++++++++++++ crates/ui/src/shell/spaces.rs | 2 ++ 2 files changed, 23 insertions(+) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 7dd030090a..3cb4c0b37a 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -374,6 +374,10 @@ pub fn folder_path_key(path: &str) -> String { } 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 { @@ -381,6 +385,17 @@ pub fn folder_path_key(path: &str) -> String { } } +/// `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) } @@ -9519,6 +9534,12 @@ mod tests { )); 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( diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 33f9bfd331..12b3a044e3 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -7545,6 +7545,8 @@ 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\")); From 508313bc7822182eb4c5ef0b705921ce9462fd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Sat, 10 Oct 2026 00:42:12 -0300 Subject: [PATCH 35/36] docs(ui): describe the home index release without pins --- crates/ui/src/shell/spaces.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 14eea6bcf8..713bd9195e 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -2308,7 +2308,7 @@ 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 -/// focused projectless chat pins it. +/// projectless chat warmed it. #[derive(Default)] struct HomeFolderSearch { /// Bumped per request; an answer to an older one is dropped. @@ -5919,7 +5919,7 @@ impl Shell { } /// Tell the host the picker is done with its home index (it stays when a - /// focused projectless chat pins it). + /// 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(); From 8e53b2976ac4856f2c389bc1ef4219de85018c9a Mon Sep 17 00:00:00 2001 From: Wing <44917547+wingleeio@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:42:11 -0700 Subject: [PATCH 36/36] Workspace search: review fixes and palette polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine - Drop content matches reached through a symlink, and name results through a symlinked folder (ReadWorkspaceFile's rule); fff's watcher follows links created after the first scan. - @ and file-tree name searches wait up to 2 s for a fresh index, since their answers can't report that it is still building. - Evict the LRU index only after its replacement is created, and report "indexing" (not a final empty answer) when an index is released mid-search. - File-name search accepts an empty query (not with "show ignored"), listing the workspace's files by the index's recency. - Home folder results are spelled from the session's home, not its canonical path, so a symlinked home doesn't duplicate projects. Palette - The Files tab and All's Recent Files list opened files, then the workspace's recency list, when there is no query. - Arrowing onto a section's first row scrolls its header into view. - Tabs use the shared tab chip; shortcut legends are plain sidebar-style text set in the system font (the UI font lacks ⇧ ↵); footer keys keep their caps, with icon glyphs for arrows and return. - Leaving for the PR board releases the new-project home index. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/repos.rs | 20 ++- crates/engine/src/workspace_files.rs | 37 +++++- crates/engine/src/workspace_search.rs | 85 ++++++++++++- crates/ui/src/popover.rs | 18 +-- crates/ui/src/shell.rs | 37 ++---- crates/ui/src/shell/command_palette.rs | 161 ++++++++++++++++++++----- 6 files changed, 285 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/repos.rs b/crates/engine/src/repos.rs index 3a1bd5d520..be5691f81c 100644 --- a/crates/engine/src/repos.rs +++ b/crates/engine/src/repos.rs @@ -1945,18 +1945,22 @@ fn search_files_blocking( } } let found = search - .search_names( + .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) { + 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 { @@ -1978,7 +1982,17 @@ fn search_home_folders_blocking( query: &str, limit: usize, ) -> Result { - search_folders_under(search, &home_search_root()?, query, limit) + 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) } /// `SearchHomeFolders` against an already-resolved (canonical) root. diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index cc364c8993..82edac4b5a 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -416,7 +416,7 @@ 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); @@ -1267,7 +1267,7 @@ fn search_workspace_blocking( 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()); } @@ -1275,7 +1275,13 @@ fn search_workspace_blocking( return search_including_ignored(root, query, limit, cancel); } let found = search - .search_names(root, query, limit, NameKinds::FilesAndDirectories) + .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())); @@ -1286,6 +1292,9 @@ fn search_workspace_blocking( .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)) @@ -1446,6 +1455,8 @@ fn search_content_blocking( .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, @@ -1460,6 +1471,16 @@ fn search_content_blocking( }) } +/// 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> { if query.trim().is_empty() { return Err(WorkspaceFilesError::BadParams( @@ -2527,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")); } } diff --git a/crates/engine/src/workspace_search.rs b/crates/engine/src/workspace_search.rs index b47b6272e4..368f09c158 100644 --- a/crates/engine/src/workspace_search.rs +++ b/crates/engine/src/workspace_search.rs @@ -33,6 +33,10 @@ 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); @@ -170,11 +174,25 @@ impl WorkspaceSearch { 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(SCAN_WAIT); + let indexing = !picker.wait_for_scan(wait); if limit == 0 { return Ok(NameSearch { matches: Vec::new(), @@ -183,7 +201,11 @@ impl WorkspaceSearch { } let guard = picker.read().map_err(unavailable)?; let Some(fff) = guard.as_ref() else { - return Ok(NameSearch::default()); + // 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 }, @@ -261,7 +283,11 @@ impl WorkspaceSearch { } let guard = picker.read().map_err(unavailable)?; let Some(fff) = guard.as_ref() else { - return Ok(ContentSearch::default()); + // 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, @@ -378,11 +404,13 @@ impl WorkspaceSearch { 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))); } - let picker = create_picker(root, self.inner.watch)?; entries.insert( root.to_path_buf(), Entry { @@ -529,6 +557,31 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { #[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); @@ -786,3 +839,27 @@ mod tests { ); } } + +/// 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/ui/src/popover.rs b/crates/ui/src/popover.rs index dcbe3bbe4a..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())) } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 871f642078..a8e072c6be 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -5328,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); @@ -8105,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() diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index fa4a2e340e..74a89412ee 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -85,6 +85,18 @@ impl WorkspaceQuery { 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 { @@ -385,6 +397,20 @@ fn child_index(sections: &[PaletteSection], row_ix: usize) -> usize { 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() @@ -456,6 +482,8 @@ impl Shell { content: WorkspaceQuery::default(), _search_events: events, }); + // Load the default Files list (recently changed files) right away. + self.search_palette_workspace(cx); cx.notify(); } @@ -488,7 +516,10 @@ impl Shell { return; }; let query = palette.search.read(cx).text().trim().to_owned(); - let Some(client) = workspace.filter(|_| query.chars().count() >= MIN_WORKSPACE_QUERY_CHARS) + // 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(); @@ -504,7 +535,9 @@ impl Shell { }; let names = client.clone(); let target = client.target().clone(); - let mut probe = palette.files_ready.as_ref() != Some(&target); + // 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; @@ -557,6 +590,10 @@ impl Shell { } })); + if query.is_empty() { + palette.content.clear(); + return; + } let generation = palette.content.begin(); let request = SearchWorkspaceContentRequest { target: client.target().clone(), @@ -627,16 +664,26 @@ impl Shell { .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, - self.recent_files(&self.active_chat) + opened + .chain(changed) + .filter(|(path, _)| seen.insert(path.clone())) .take(limit) - .map(|path| RowKind::File { - path: path.clone(), - is_dir: false, - }), + .map(|(path, is_dir)| RowKind::File { path, is_dir }), ) + .with_status(palette.files.progress()) }; let files = |limit| { PaletteSection::new( @@ -780,7 +827,15 @@ impl Shell { (current + count - 1) % count }; palette.set_active(&rows, next); - palette.scroll.scroll_to_item(child_index(§ions, 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(); } @@ -1157,25 +1212,17 @@ impl Shell { .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)); - popover::menu_row( - &theme, - tab == current_tab, - format!("command-tab-{}", tab.index()), - ) - .id(("command-tab", tab.index())) - .role(gpui::Role::Tab) - .aria_label(tab.label()) - .gap(px(6.0)) - .py(px(3.0)) - .on_click(cx.listener(move |this, _, _, cx| this.select_palette_tab(tab, cx))) - .child(tab.label()) - .child(popover::kbd_hint(&theme, &combo)) + // 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 tab_keys = format!( - "{}…{}", - crate::settings::badge_combo("mod-1"), - Tab::ALL.len() - ); let card = palette_card("command-palette", &focus, viewport, &theme) .key_context(KEY_CONTEXT) .on_action(cx.listener(|this, action: &SelectPaletteTab, _, cx| { @@ -1238,7 +1285,6 @@ impl Shell { palette_footer() .child(command_key_hint(&theme, "↑ ↓", "Navigate")) .child(command_key_hint(&theme, "↵", "Open")) - .child(command_key_hint(&theme, &tab_keys, "Tabs")) .child(command_key_hint(&theme, "Esc", "Close")), ); Some(palette_overlay(viewport, card)) @@ -1382,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)) @@ -1870,6 +1938,38 @@ mod tests { } } + #[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); @@ -2620,6 +2720,11 @@ mod tests { // 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]