diff --git a/Cargo.lock b/Cargo.lock index 34e3c50b01d..e0fbab41083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3571,7 +3571,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-channel 2.5.0", - "async-process", "async-trait", "base64 0.22.1", "collections", @@ -3960,36 +3959,36 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb1ffe339f197d6645b4d3037edf67c13cd3aa8871f29c2c9c046c729c1b9a17" +checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e81a21df73d1b12ed19eba481c08de8891e179e1870ed28d6e397f7746108f5" +checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf917d0180c15c945c13c8dde615d32a015769513b29158f728311d85a8f80d" +checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6f4e1af2df00798c2895d228bb53d65c5aa09acace8525096f0b53830ffe42c" +checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086" dependencies = [ "serde", "serde_derive", @@ -3997,9 +3996,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a5d7300e4b44933dcf2947399945abe3f30f92c789b496ad72949e3ee15a6" +checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -4027,9 +4026,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "becdb5c3111800d7f8e666fe5f35693bfc77de4401bfcaea19815caf7c482fb9" +checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -4040,24 +4039,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8fa77efffa12934971f757e154b16dd5e369a7f388a0f3adff74aadfd4c5a1d" +checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac" [[package]] name = "cranelift-control" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62441d3aae3372381e03a121880482158ce90ca3bc2a56607cc122ee07536fe4" +checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bdc9832a010e0d411439aa016e1664dd23ca5c8953bf26b90fe34ad4b76822d" +checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a" dependencies = [ "cranelift-bitset", "serde", @@ -4066,9 +4065,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9530b689b7c3accdbb32263ca318e19ab3bcf616d3a160c8456537c99b4c565b" +checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858" dependencies = [ "cranelift-codegen", "log", @@ -4078,15 +4077,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fcd3258a4d87376f2681c72269a42009286a3d3707b2af4024ba5b3750ad477" +checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a" [[package]] name = "cranelift-native" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "642c5703a22b58abccbf46f46c0dae65f0535bbe725beec70527a1ffcbbc1d34" +checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830" dependencies = [ "cranelift-codegen", "libc", @@ -4095,9 +4094,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.8" +version = "0.123.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d200dcd5a37de108ec1329e0ba924e2badd2c0ef2343c338310135159ae454e2" +checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40" [[package]] name = "crash-context" @@ -4942,6 +4941,7 @@ dependencies = [ "component", "ctor", "editor", + "futures-lite 1.13.0", "gpui", "indoc", "itertools 0.14.0", @@ -5021,7 +5021,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5397,8 +5397,8 @@ dependencies = [ name = "edit_prediction_metrics" version = "0.1.0" dependencies = [ + "imara-diff", "indoc", - "language", "pretty_assertions", "serde", "serde_json", @@ -5549,6 +5549,24 @@ dependencies = [ "ztracing", ] +[[package]] +name = "editor_benchmarks" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "gpui", + "gpui_platform", + "language", + "multi_buffer", + "project", + "release_channel", + "semver", + "settings", + "theme", + "workspace", +] + [[package]] name = "either" version = "1.15.0" @@ -5811,7 +5829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7269,7 +7287,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8599,7 +8617,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -8617,7 +8635,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -11266,7 +11284,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -13881,9 +13899,9 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pulley-interpreter" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35eaba3163b9faf1d707f0704a7370bfdbe73622c766acdaf1fa4addb87510de" +checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683" dependencies = [ "cranelift-bitset", "log", @@ -13893,9 +13911,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac294897a29ce07919714f9f25c11a819d75759d47eb9f3273845ffea5a5760d" +checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42" dependencies = [ "proc-macro2", "quote", @@ -13998,7 +14016,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.40", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -14035,9 +14053,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -14919,9 +14937,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.3.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" dependencies = [ "async-trait", "base64 0.22.1", @@ -14941,9 +14959,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.3.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -15016,13 +15034,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rpassword" -version = "7.4.0" +version = "7.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -15253,7 +15271,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -16314,6 +16332,7 @@ dependencies = [ "git", "gpui", "http_client", + "itertools 0.14.0", "language", "language_model", "log", @@ -17741,7 +17760,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -19936,9 +19955,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2060d93be880840d764ab537464b916e22c07758ac5d43e5f07cc86fec6d1bec" +checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492" dependencies = [ "addr2line", "anyhow", @@ -19997,9 +20016,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "902f991ca8c2e5abc03119eb5d7f7f57da1b7c2123addb8214b49c188737711e" +checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f" dependencies = [ "anyhow", "cpp_demangle", @@ -20024,9 +20043,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-asm-macros" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b02cec619b54ce7652d1d7676718a42ccf5f16b2fb23c27cd6e3c307bc93907a" +checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93" dependencies = [ "cfg-if", ] @@ -20043,9 +20062,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad82a87bc24b6014c5271e1558e466fd029dcc80896f143b3693394a162f3be" +checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0" dependencies = [ "anyhow", "proc-macro2", @@ -20058,15 +20077,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bc24aba0bfd3d39fa8f0012835bc4d4efc75b1350b5e519181319eb8bb306b2" +checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e" [[package]] name = "wasmtime-internal-cranelift" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54eb7fc20c8692dc96148365d7a00a1b79fee810833c75bdf8ec073a46e4721a" +checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca" dependencies = [ "anyhow", "cfg-if", @@ -20091,9 +20110,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30708e122dcc1e175c66345c209c01752ca0cd20c9021721b6f56968342e9dbe" +checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f" dependencies = [ "anyhow", "cc", @@ -20107,9 +20126,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eeaab071a646d9ae205266adf186c63fa6d077d36b0b33628dd6c3d321d3195" +checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -20117,9 +20136,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09979561e6e4a17bf55722463b066ccb968f010ac6ec5d647e4dff19eddbb19e" +checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028" dependencies = [ "anyhow", "cfg-if", @@ -20129,24 +20148,24 @@ dependencies = [ [[package]] name = "wasmtime-internal-math" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9193eb852e5c68aeb95a5ea7538c2bec503023169a0b24430224b4f1ded24988" +checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480" dependencies = [ "libm", ] [[package]] name = "wasmtime-internal-slab" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "289bfa4fbb43f406f36166737f1f25522c215ef2ef11f98423089a6a7590a3d1" +checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183" [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e748c970993865d9bf474465c3f10f96e541c472bc8f7ec0b031779f4ac29c6" +checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639" dependencies = [ "anyhow", "cfg-if", @@ -20157,9 +20176,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97e07438cb8b50df3bc9659c56757830a15235c94268dbbd54186524fd4ed84" +checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a" dependencies = [ "proc-macro2", "quote", @@ -20168,9 +20187,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107aa0c3f71cc590c786d6d6e09893558b383f4d78107b864a9fd978929d0244" +checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54" dependencies = [ "anyhow", "cranelift-codegen", @@ -20185,9 +20204,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb3d8e4efdaae10aa01264e9946bba507e53707125dd0aa8584b5e13229a3c0" +checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339" dependencies = [ "anyhow", "bitflags 2.10.0", @@ -20198,9 +20217,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fffc455304d2750ea2456394cdf6513d8771eb5b256876685b8bb9413bfb0e" +checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0" dependencies = [ "anyhow", "async-trait", @@ -20229,9 +20248,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi-io" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5666a220e8318309225b54a55b270e1b506385adcce10bf5698380441afa0df3" +checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029" dependencies = [ "anyhow", "async-trait", @@ -20696,9 +20715,9 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e176546937d1311c7608276c8511d3ea9b8e7b916e89b720e12c4d4bbae067c" +checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792" dependencies = [ "anyhow", "async-trait", @@ -20711,9 +20730,9 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3f012ad76133d9ac70633c7f954e289fb4c21986059f324fec3c476664ab643" +checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f" dependencies = [ "anyhow", "heck 0.5.0", @@ -20725,9 +20744,9 @@ dependencies = [ [[package]] name = "wiggle-macro" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4301e6203d3d13eef139fa3aca5f04e9156b4a5f7636ca965b2c10bce410b3d2" +checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735" dependencies = [ "proc-macro2", "quote", @@ -20757,7 +20776,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -20768,9 +20787,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "36.0.8" +version = "36.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "646e2d01f59d7006e24a370762abfb63d5918696ff02197e027efd15252a1f79" +checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e" dependencies = [ "anyhow", "cranelift-assembler-x64", @@ -22379,7 +22398,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.2.0" +version = "1.3.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/Cargo.toml b/Cargo.toml index 72356e7ea35..47b8eddf148 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "crates/edit_prediction_types", "crates/edit_prediction_ui", "crates/editor", + "crates/editor_benchmarks", "crates/encoding_selector", "crates/env_var", "crates/etw_tracing", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 66f527ef024..cd1aee29c7c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1247,6 +1247,12 @@ "ctrl->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel && Terminal", + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "ZedPredictModal", "bindings": { @@ -1499,6 +1505,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", "ctrl-shift-i": "branch_picker::FilterRemotes", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d73c6d7a8b6..bf96104f65e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1316,6 +1316,13 @@ "cmd->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "agent::NewThread", + }, + }, { "context": "RatePredictionsModal", "use_key_equivalents": true, @@ -1552,6 +1559,7 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "branch_picker::DeleteBranch", + "cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch", "cmd-shift-i": "branch_picker::FilterRemotes", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index fc1d78b39f2..ce293452d2d 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1262,6 +1262,13 @@ "ctrl-shift-.": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "Terminal && selection", "bindings": { @@ -1479,6 +1486,7 @@ "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch", "ctrl-shift-i": "branch_picker::FilterRemotes", }, }, diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 88e8e352040..396c6e40852 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -527,6 +527,7 @@ "space w d": "pane::SplitDown", // not a helix default // Space mode + "space b": "tab_switcher::ToggleAll", "space f": "file_finder::Toggle", "space k": "editor::Hover", "space s": "outline::Toggle", diff --git a/assets/settings/default.json b/assets/settings/default.json index 624dcc0f012..7c74715b122 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1110,6 +1110,7 @@ "diagnostics": true, "apply_code_action": true, "edit_file": true, + "write_file": true, "fetch": true, "find_path": true, "find_references": true, @@ -1121,8 +1122,6 @@ "now": true, "rename_symbol": true, "read_file": true, - "restore_file_from_disk": true, - "save_file": true, "open": true, "grep": true, "spawn_agent": true, @@ -2514,6 +2513,9 @@ "gdefault": false, "highlight_on_yank_duration": 200, "custom_digraphs": {}, + // When enabled, edit predictions are shown in Vim normal mode. + // By default, edit predictions are only shown in insert and replace modes. + "show_edit_predictions_in_normal_mode": false, // Cursor shape for each mode. // The shape can be one of the following: "block", "bar", "underline", "hollow". "cursor_shape": { diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2c448d34307..598e3e9683d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -570,6 +570,22 @@ impl From for acp::RequestPermissionOutcome { } } +/// What a `WaitingForConfirmation` prompt represents semantically. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationKind { + /// The user is granting or denying permission for the tool call to + /// proceed. The selected `PermissionOptionKind` determines whether the + /// tool call transitions to `InProgress` (allow) or `Rejected` (reject). + /// This is the default for tool authorization prompts. + PermissionGrant, + /// The user is choosing between actions for the tool to take next + /// (for example, "Save" vs "Discard" before editing a dirty buffer). + /// The tool call always transitions to `InProgress` regardless of the + /// selected `PermissionOptionKind`; the caller interprets the chosen + /// `option_id` to decide what to do. + ActionChoice, +} + #[derive(Debug)] pub enum ToolCallStatus { /// The tool call hasn't started running yet, but we start showing it to @@ -579,6 +595,7 @@ pub enum ToolCallStatus { WaitingForConfirmation { options: PermissionOptions, respond_tx: oneshot::Sender, + kind: AuthorizationKind, }, /// The tool call is currently running. InProgress, @@ -2080,6 +2097,7 @@ impl AcpThread { &mut self, tool_call: acp::ToolCallUpdate, options: PermissionOptions, + kind: AuthorizationKind, cx: &mut Context, ) -> Result> { let (tx, rx) = oneshot::channel(); @@ -2087,6 +2105,7 @@ impl AcpThread { let status = ToolCallStatus::WaitingForConfirmation { options, respond_tx: tx, + kind, }; let tool_call_id = tool_call.tool_call_id.clone(); @@ -2118,15 +2137,25 @@ impl AcpThread { return; }; - let new_status = match outcome.option_kind { - acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => { - ToolCallStatus::Rejected + let is_action_choice = matches!( + call.status, + ToolCallStatus::WaitingForConfirmation { + kind: AuthorizationKind::ActionChoice, + .. } - acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => { + ); + let new_status = + if is_action_choice { ToolCallStatus::InProgress - } - _ => ToolCallStatus::InProgress, - }; + } else { + match outcome.option_kind { + acp::PermissionOptionKind::RejectOnce + | acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected, + acp::PermissionOptionKind::AllowOnce + | acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress, + _ => ToolCallStatus::InProgress, + } + }; let curr_status = mem::replace(&mut call.status, new_status); @@ -2294,10 +2323,6 @@ impl AcpThread { this.project .update(cx, |project, cx| project.set_agent_location(None, cx)); } - let Ok(response) = response else { - // tx dropped, just return - return Ok(None); - }; let is_same_turn = this .running_turn @@ -2306,11 +2331,18 @@ impl AcpThread { // If the user submitted a follow up message, running_turn might // already point to a different turn. Therefore we only want to - // take the task if it's the same turn. + // take the task if it's the same turn. We do this before the + // dropped-tx guard below so the panel exits its generating + // state even when the send_task is cancelled before tx.send(). if is_same_turn { this.running_turn.take(); } + let Ok(response) = response else { + // tx dropped, just return + return Ok(None); + }; + match response { Ok(r) => { Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); @@ -5517,4 +5549,63 @@ mod tests { ); }); } + + /// Regression test: if the inner send_task is cancelled before it can + /// fire `tx.send(...)` (e.g. because the underlying future was dropped), + /// the outer task observes `rx.await` returning `Err(Cancelled)` and + /// must still clear `running_turn` so the panel transitions out of + /// `Generating`. Without this, the agent thread is wedged in the + /// loading state until Zed restarts. + #[gpui::test] + async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + // Handler hangs forever so the spawn at run_turn is parked inside + // `f(this, cx).await` with `tx` still alive but unsent. + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + |_params, _thread, _cx| { + async move { futures::future::pending::>().await } + .boxed_local() + }, + )); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let request = thread.update(cx, |thread, cx| thread.send_raw("hello", cx)); + cx.run_until_parked(); + + assert_eq!( + thread.read_with(cx, |t, _| t.status()), + ThreadStatus::Generating, + "thread should be generating while the handler is parked" + ); + + // Replace the in-flight send_task with a no-op. Dropping the original + // Task cancels its inner future, which drops `tx` without ever calling + // `tx.send(...)`. This mirrors the production scenario where the + // send_task future is cancelled before completion. + thread.update(cx, |thread, _| { + thread.running_turn.as_mut().unwrap().send_task = Task::ready(()); + }); + + let result = request.await; + assert!( + matches!(result, Ok(None)), + "outer task should resolve to Ok(None) on dropped tx, got {result:?}" + ); + + assert_eq!( + thread.read_with(cx, |t, _| t.status()), + ThreadStatus::Idle, + "running_turn must be cleared even when tx was dropped without send" + ); + } } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index bbb967530e3..41cdd1250b3 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -49,6 +49,10 @@ pub trait AgentConnection { fn telemetry_id(&self) -> SharedString; + fn agent_version(&self) -> Option { + None + } + fn new_session( self: Rc, project: Entity, @@ -637,6 +641,8 @@ mod test_support { use gpui::{AppContext as _, WeakEntity}; use parking_lot::Mutex; + use crate::AuthorizationKind; + use super::*; /// Creates a PNG image encoded as base64 for testing. @@ -911,6 +917,7 @@ mod test_support { thread.request_tool_call_authorization( tool_call.clone().into(), options.clone(), + AuthorizationKind::PermissionGrant, cx, ) })?? diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 0abb0622f9e..dccf7cf6f31 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -817,4 +817,9 @@ impl StatusItemView for ActivityIndicator { _: &mut Context, ) { } + + fn hide_setting(&self, _: &App) -> Option { + // Activity indicator auto-hides when there's no work to display. + None + } } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 1a7aaffb580..cd45c97f582 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -37,7 +37,7 @@ use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task, - WeakEntity, + TaskExt, WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree}; @@ -1298,9 +1298,12 @@ impl NativeAgentConnection { options, response, context: _, + kind, }) => { let outcome_task = acp_thread.update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call, options, cx) + thread.request_tool_call_authorization( + tool_call, options, kind, cx, + ) })??; cx.background_spawn(async move { if let acp_thread::RequestPermissionOutcome::Selected(outcome) = diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 2a4e9c255fb..511986ff004 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -5545,6 +5545,105 @@ async fn test_max_subagent_depth_prevents_tool_registration(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_lsp_tools_gated_by_feature_flag(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let project_context = cx.new(|_cx| ProjectContext::default()); + let context_server_store = project.read_with(cx, |project, _| project.context_server_store()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let environment = Rc::new(cx.update(|cx| { + FakeThreadEnvironment::default().with_terminal(FakeTerminalHandle::new_never_exits(cx)) + })); + + let thread = cx.new(|cx| { + let mut thread = Thread::new( + project, + project_context, + context_server_registry, + Templates::new(), + Some(model.clone() as Arc), + cx, + ); + thread.add_default_tools(environment, cx); + thread + }); + + let lsp_tool_names = [ + FindReferencesTool::NAME, + GetCodeActionsTool::NAME, + ApplyCodeActionTool::NAME, + GoToDefinitionTool::NAME, + RenameTool::NAME, + ]; + + // All LSP tools should be registered on the thread regardless of the flag, + // since the feature flag now only controls exposure to the model rather + // than registration. + thread.read_with(cx, |thread, _| { + for name in &lsp_tool_names { + assert!( + thread.has_registered_tool(name), + "expected LSP tool {name} to be registered" + ); + } + }); + + // Without the `lsp-tool` flag, sending a message should produce a + // completion request whose tool list excludes the LSP tools. + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &lsp_tool_names { + assert!( + !tool_names.iter().any(|t| t == name), + "expected LSP tool {name} to be hidden without the lsp-tool flag, \ + but completion tools were: {tool_names:?}" + ); + } + // Sanity check: a non-LSP default tool should still be exposed. + assert!( + tool_names.iter().any(|t| t == ReadFileTool::NAME), + "expected non-LSP tools to still be exposed, got: {tool_names:?}" + ); + model.end_last_completion_stream(); + cx.run_until_parked(); + + // Enable the `lsp-tool` flag and send another message; the LSP tools + // should now appear in the completion request. + cx.update(|cx| { + cx.update_flags(false, vec!["lsp-tool".to_string()]); + }); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello again"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let completion = model.pending_completions().pop().unwrap(); + let tool_names = tool_names_for_completion(&completion); + for name in &lsp_tool_names { + assert!( + tool_names.iter().any(|t| t == name), + "expected LSP tool {name} to be exposed when lsp-tool flag is on, \ + but completion tools were: {tool_names:?}" + ); + } +} + #[gpui::test] async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) { init_test(cx); @@ -6062,9 +6161,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) { tool.run( ToolInput::resolved(crate::EditFileToolInput { path: "root/sensitive_config.txt".into(), - mode: crate::EditFileMode::Edit, - content: None, - edits: Some(vec![]), + edits: vec![], }), event_stream, cx, @@ -6294,112 +6391,6 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "normal.txt": "normal content", - "readonly": { - "config.txt": "readonly content" - } - }), - ) - .await; - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.tools.insert( - SaveFileTool::NAME.into(), - agent_settings::ToolRules { - default: Some(settings::ToolPermissionMode::Allow), - always_allow: vec![], - always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()], - always_confirm: vec![], - invalid_patterns: vec![], - }, - ); - agent_settings::AgentSettings::override_global(settings, cx); - }); - - #[allow(clippy::arc_with_non_send_sync)] - let tool = Arc::new(crate::SaveFileTool::new(project)); - let (event_stream, _rx) = crate::ToolCallEventStream::test(); - - let task = cx.update(|cx| { - tool.run( - ToolInput::resolved(crate::SaveFileToolInput { - paths: vec![ - std::path::PathBuf::from("root/normal.txt"), - std::path::PathBuf::from("root/readonly/config.txt"), - ], - }), - event_stream, - cx, - ) - }); - - let result = task.await; - assert!( - result.is_err(), - "expected save to be blocked due to denied path" - ); - assert!( - result.unwrap_err().contains("blocked"), - "error should mention the save was blocked" - ); -} - -#[gpui::test] -async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"config.secret": "secret config"})) - .await; - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.tools.insert( - SaveFileTool::NAME.into(), - agent_settings::ToolRules { - default: Some(settings::ToolPermissionMode::Allow), - always_allow: vec![], - always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()], - always_confirm: vec![], - invalid_patterns: vec![], - }, - ); - agent_settings::AgentSettings::override_global(settings, cx); - }); - - #[allow(clippy::arc_with_non_send_sync)] - let tool = Arc::new(crate::SaveFileTool::new(project)); - let (event_stream, _rx) = crate::ToolCallEventStream::test(); - - let task = cx.update(|cx| { - tool.run( - ToolInput::resolved(crate::SaveFileToolInput { - paths: vec![std::path::PathBuf::from("root/config.secret")], - }), - event_stream, - cx, - ) - }); - - let result = task.await; - assert!(result.is_err(), "expected save to be blocked"); - assert!( - result.unwrap_err().contains("blocked"), - "error should mention the save was blocked" - ); -} - #[gpui::test] async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) { init_test(cx); @@ -6496,9 +6487,7 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte tool.run( ToolInput::resolved(crate::EditFileToolInput { path: "root/README.md".into(), - mode: crate::EditFileMode::Edit, - content: None, - edits: Some(vec![]), + edits: vec![], }), event_stream, cx, @@ -6568,9 +6557,7 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes tool.run( ToolInput::resolved(crate::EditFileToolInput { path: "root/.zed/settings.json".into(), - mode: crate::EditFileMode::Edit, - content: None, - edits: Some(vec![]), + edits: vec![], }), event_stream, cx, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c6979391673..8e5c9edd7f8 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3,8 +3,8 @@ use crate::{ DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool, - RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template, - Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, + SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, + ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; @@ -822,9 +822,9 @@ impl ToolPermissionContext { } else if tool_name == CopyPathTool::NAME || tool_name == MovePathTool::NAME || tool_name == EditFileTool::NAME + || tool_name == WriteFileTool::NAME || tool_name == DeletePathTool::NAME || tool_name == CreateDirectoryTool::NAME - || tool_name == SaveFileTool::NAME { ( extract_path_pattern(value), @@ -924,6 +924,7 @@ pub struct ToolCallAuthorization { pub options: acp_thread::PermissionOptions, pub response: oneshot::Sender, pub context: Option, + pub kind: acp_thread::AuthorizationKind, } #[derive(Debug, thiserror::Error)] @@ -1544,6 +1545,12 @@ impl Thread { self.action_log.clone(), )); self.add_tool(EditFileTool::new( + self.project.clone(), + cx.weak_entity(), + self.action_log.clone(), + language_registry.clone(), + )); + self.add_tool(WriteFileTool::new( self.project.clone(), cx.weak_entity(), self.action_log.clone(), @@ -1564,26 +1571,23 @@ impl Thread { self.action_log.clone(), update_agent_location, )); - self.add_tool(SaveFileTool::new(self.project.clone())); - self.add_tool(RestoreFileFromDiskTool::new(self.project.clone())); self.add_tool(TerminalTool::new(self.project.clone(), environment.clone())); self.add_tool(WebSearchTool); self.add_tool(DiagnosticsTool::new(self.project.clone())); - if cx.has_flag::() { - let code_action_store: CodeActionStore = cx.new(|_cx| None); - self.add_tool(FindReferencesTool::new(self.project.clone())); - self.add_tool(GetCodeActionsTool::new( - self.project.clone(), - code_action_store.clone(), - )); - self.add_tool(ApplyCodeActionTool::new( - self.project.clone(), - code_action_store, - )); - self.add_tool(GoToDefinitionTool::new(self.project.clone())); - self.add_tool(RenameTool::new(self.project.clone())); - } + + let code_action_store: CodeActionStore = cx.new(|_cx| None); + self.add_tool(FindReferencesTool::new(self.project.clone())); + self.add_tool(GetCodeActionsTool::new( + self.project.clone(), + code_action_store.clone(), + )); + self.add_tool(ApplyCodeActionTool::new( + self.project.clone(), + code_action_store, + )); + self.add_tool(GoToDefinitionTool::new(self.project.clone())); + self.add_tool(RenameTool::new(self.project.clone())); if self.depth() < MAX_SUBAGENT_DEPTH { self.add_tool(SpawnAgentTool::new(environment)); @@ -2887,6 +2891,17 @@ impl Thread { None } }) + .filter(|(tool_name, _)| { + cx.has_flag::() + || !matches!( + tool_name.as_ref(), + FindReferencesTool::NAME + | GetCodeActionsTool::NAME + | ApplyCodeActionTool::NAME + | GoToDefinitionTool::NAME + | RenameTool::NAME + ) + }) .collect::>(); let mut context_server_tools = Vec::new(); @@ -2950,10 +2965,6 @@ impl Thread { self.tools.contains_key(name) } - pub fn registered_tool_names(&self) -> Vec { - self.tools.keys().cloned().collect() - } - pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity) { self.running_subagents.push(subagent); } @@ -3865,6 +3876,57 @@ impl ToolCallEventStream { self.run_authorization_loop(title, options, Some(context), None, cx) } + /// Prompts the user to choose between an explicit set of actions and + /// returns the chosen `option_id`. + /// + /// Unlike [`Self::authorize`] / [`Self::authorize_always_prompt`], this + /// does not interpret the user's choice as a permission grant — callers + /// are responsible for handling each `option_id` explicitly. Use this + /// when a tool needs the user to pick between several side-effecting + /// actions (for example, "Save" vs "Discard" for a dirty buffer). + pub fn prompt_for_decision( + &self, + title: Option, + message: Option, + options: Vec, + cx: &mut App, + ) -> Task> { + let options = acp_thread::PermissionOptions::Flat(options); + let stream = self.stream.clone(); + let tool_use_id = self.tool_use_id.clone(); + cx.spawn(async move |_cx| { + let mut fields = acp::ToolCallUpdateFields::new(); + if let Some(title) = title { + fields = fields.title(title); + } + if let Some(message) = message { + fields = fields.content(vec![acp::ToolCallContent::from(message)]); + } + + let (response_tx, response_rx) = oneshot::channel(); + if let Err(error) = stream + .0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( + ToolCallAuthorization { + tool_call: acp::ToolCallUpdate::new(tool_use_id.to_string(), fields), + options, + response: response_tx, + context: None, + kind: acp_thread::AuthorizationKind::ActionChoice, + }, + ))) + { + log::error!("Failed to send tool call decision prompt: {error}"); + return Err(anyhow!("Failed to send tool call decision prompt: {error}")); + } + + let outcome = response_rx + .await + .map_err(|_| anyhow!("authorization channel closed"))?; + Ok(outcome.option_id) + }) + } + /// Prompts the user for authorization. /// /// When `check_settings` is `Some`, this gate is settings-driven: the @@ -3912,6 +3974,7 @@ impl ToolCallEventStream { options, response: response_tx, context, + kind: acp_thread::AuthorizationKind::PermissionGrant, }, ))) { diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 71ee0b2ba17..77b840a47ad 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -5,6 +5,7 @@ mod create_directory_tool; mod delete_path_tool; mod diagnostics_tool; mod edit_file_tool; +mod edit_session; #[cfg(all(test, feature = "unit-eval"))] mod evals; mod fetch_tool; @@ -19,14 +20,13 @@ mod now_tool; mod open_tool; mod read_file_tool; mod rename_tool; -mod restore_file_from_disk_tool; -mod save_file_tool; mod spawn_agent_tool; mod symbol_locator; mod terminal_tool; mod tool_permissions; mod update_plan_tool; mod web_search_tool; +mod write_file_tool; use crate::AgentTool; use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; @@ -77,14 +77,13 @@ pub use now_tool::*; pub use open_tool::*; pub use read_file_tool::*; pub use rename_tool::*; -pub use restore_file_from_disk_tool::*; -pub use save_file_tool::*; pub use spawn_agent_tool::*; pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; pub use update_plan_tool::*; pub use web_search_tool::*; +pub use write_file_tool::*; macro_rules! tools { ($($tool:ty),* $(,)?) => { @@ -173,10 +172,9 @@ tools! { OpenTool, ReadFileTool, RenameTool, - RestoreFileFromDiskTool, - SaveFileTool, SpawnAgentTool, TerminalTool, UpdatePlanTool, WebSearchTool, + WriteFileTool, } diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index da2b33fa5f9..4f0ae7b511c 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -54,7 +54,7 @@ impl AgentTool for CreateDirectoryTool { const NAME: &'static str = "create_directory"; fn kind() -> acp::ToolKind { - acp::ToolKind::Read + acp::ToolKind::Edit } fn initial_title( diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 69f7be4662a..d439791970b 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1,53 +1,36 @@ -mod reindent; -mod streaming_fuzzy_matcher; -mod streaming_parser; - use super::deserialize_maybe_stringified; -use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; -use super::save_file_tool::SaveFileTool; -use crate::ToolInputPayload; -use crate::tools::edit_file_tool::{ - reindent::{Reindenter, compute_indent_delta}, - streaming_fuzzy_matcher::StreamingFuzzyMatcher, - streaming_parser::{EditEvent, StreamingParser, WriteEvent}, +pub(crate) use super::edit_session::PartialEdit; +pub use super::edit_session::{Edit, EditSessionOutput as EditFileToolOutput}; +use super::edit_session::{ + EditSession, EditSessionContext, EditSessionMode, EditSessionResult, + initial_title_from_partial_path, run_session, }; -use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput}; -use acp_thread::Diff; +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; use action_log::ActionLog; -use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use agent_client_protocol::schema as acp; use anyhow::Result; -use collections::HashSet; use futures::FutureExt as _; -use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; -use language::language_settings::{self, FormatOnSave}; -use language::{Buffer, LanguageRegistry}; -use language_model::LanguageModelToolResultContent; -use project::lsp_store::{FormatTrigger, LspFormatTarget}; -use project::{AgentLocation, Project, ProjectPath}; +use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; +use language::LanguageRegistry; +use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; -use streaming_diff::{CharOperation, StreamingDiff}; -use text::ToOffset; use ui::SharedString; -use util::rel_path::RelPath; -use util::{Deferred, ResultExt}; const DEFAULT_UI_TEXT: &str = "Editing file"; -/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead. +/// This is a tool for applying edits to an existing file. /// /// Before using this tool: /// /// 1. Use the `read_file` tool to understand the file's contents and context /// -/// 2. Verify the directory path is correct (only applicable when creating new files): -/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location +/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct EditFileToolInput { - /// The full path of the file to create or modify in the project. + /// The full path of the file to edit in the project. /// /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. /// @@ -66,50 +49,10 @@ pub struct EditFileToolInput { /// pub path: PathBuf, - /// The mode of operation on the file. Possible values: - /// - 'write': Replace the entire contents of the file. If the file doesn't exist, it will be created. Requires 'content' field. - /// - 'edit': Make granular edits to an existing file. Requires 'edits' field. - /// - /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch. - #[serde(deserialize_with = "deserialize_maybe_stringified")] - pub mode: EditFileMode, - - /// The complete content for the new file (required for 'write' mode). - /// This field should contain the entire file content. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub content: Option, - - /// List of edit operations to apply sequentially (required for 'edit' mode). + /// List of edit operations to apply sequentially. /// Each edit finds `old_text` in the file and replaces it with `new_text`. - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_maybe_stringified" - )] - pub edits: Option>, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum EditFileMode { - Write, - Edit, -} - -/// A single edit operation that replaces old text with new text -/// Properly escape all text fields as valid JSON strings. -/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct Edit { - /// The exact text to find in the file. This will be matched using fuzzy matching - /// to handle minor differences in whitespace or formatting. - /// - /// Be minimal with replacements: - /// - For unique lines, include only those lines - /// - For non-unique lines, include enough context to identify them - pub old_text: String, - /// The text to replace it with - pub new_text: String, + #[serde(deserialize_with = "deserialize_maybe_stringified")] + pub edits: Vec, } #[derive(Clone, Default, Debug, Deserialize)] @@ -117,108 +60,11 @@ struct EditFileToolPartialInput { #[serde(default)] path: Option, #[serde(default, deserialize_with = "deserialize_maybe_stringified")] - mode: Option, - #[serde(default)] - content: Option, - #[serde(default, deserialize_with = "deserialize_maybe_stringified")] edits: Option>, } -#[derive(Clone, Default, Debug, Deserialize)] -pub struct PartialEdit { - #[serde(default)] - pub old_text: Option, - #[serde(default)] - pub new_text: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum EditFileToolOutput { - Success { - #[serde(alias = "original_path")] - input_path: PathBuf, - new_text: String, - old_text: Arc, - #[serde(default)] - diff: String, - }, - Error { - error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - input_path: Option, - #[serde(default, skip_serializing_if = "String::is_empty")] - diff: String, - }, -} - -impl EditFileToolOutput { - pub fn error(error: impl Into) -> Self { - Self::Error { - error: error.into(), - input_path: None, - diff: String::new(), - } - } -} - -impl std::fmt::Display for EditFileToolOutput { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EditFileToolOutput::Success { - diff, input_path, .. - } => { - if diff.is_empty() { - write!(f, "No edits were made.") - } else { - write!( - f, - "Edited {}:\n\n```diff\n{diff}\n```", - input_path.display() - ) - } - } - EditFileToolOutput::Error { - error, - diff, - input_path, - } => { - write!(f, "{error}\n")?; - if let Some(input_path) = input_path - && !diff.is_empty() - { - write!( - f, - "Edited {}:\n\n```diff\n{diff}\n```", - input_path.display() - ) - } else { - write!(f, "No edits were made.") - } - } - } - } -} - -impl From for LanguageModelToolResultContent { - fn from(output: EditFileToolOutput) -> Self { - output.to_string().into() - } -} - pub struct EditFileTool { - project: Entity, - thread: WeakEntity, - action_log: Entity, - language_registry: Arc, -} - -enum EditSessionResult { - Completed(EditSession), - Failed { - error: String, - session: Option, - }, + session_context: Arc, } impl EditFileTool { @@ -229,69 +75,24 @@ impl EditFileTool { language_registry: Arc, ) -> Self { Self { - project, - thread, - action_log, - language_registry, + session_context: Arc::new(EditSessionContext::new( + project, + thread, + action_log, + language_registry, + )), } } + #[cfg(test)] fn authorize( &self, path: &PathBuf, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task> { - super::tool_permissions::authorize_file_edit( - EditFileTool::NAME, - path, - &self.thread, - event_stream, - cx, - ) - } - - fn set_agent_location(&self, buffer: WeakEntity, position: text::Anchor, cx: &mut App) { - let should_update_agent_location = self - .thread - .read_with(cx, |thread, _cx| !thread.is_subagent()) - .unwrap_or_default(); - if should_update_agent_location { - self.project.update(cx, |project, cx| { - project.set_agent_location(Some(AgentLocation { buffer, position }), cx); - }); - } - } - - async fn ensure_buffer_saved(&self, buffer: &Entity, cx: &mut AsyncApp) { - let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); - settings.format_on_save != FormatOnSave::Off - }); - - if format_on_save_enabled { - self.project - .update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, - FormatTrigger::Save, - cx, - ) - }) - .await - .log_err(); - } - - self.project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .await - .log_err(); - - self.action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); + self.session_context + .authorize(Self::NAME, path, event_stream, cx) } async fn process_streaming_edits( @@ -301,7 +102,7 @@ impl EditFileTool { cx: &mut AsyncApp, ) -> EditSessionResult { let mut session: Option = None; - let mut last_partial: Option = None; + let mut last_path: Option = None; loop { futures::select! { @@ -311,22 +112,19 @@ impl EditFileTool { ToolInputPayload::Partial(partial) => { if let Ok(parsed) = serde_json::from_value::(partial) { let path_complete = parsed.path.is_some() - && parsed.path.as_ref() == last_partial.as_ref().and_then(|partial| partial.path.as_ref()); + && parsed.path.as_ref() == last_path.as_ref(); - last_partial = Some(parsed.clone()); + last_path = parsed.path.clone(); if session.is_none() && path_complete - && let EditFileToolPartialInput { - path: Some(path), - mode: Some(mode), - .. - } = &parsed + && let Some(path) = parsed.path.as_ref() { match EditSession::new( PathBuf::from(path), - *mode, - self, + EditSessionMode::Edit, + Self::NAME, + self.session_context.clone(), event_stream, cx, ) @@ -344,7 +142,7 @@ impl EditFileTool { } if let Some(current_session) = &mut session - && let Err(error) = current_session.process(parsed, self, event_stream, cx) + && let Err(error) = current_session.process_edit(parsed.edits.as_deref(), event_stream, cx) { log::error!("Failed to process edit: {}", error); return EditSessionResult::Failed { error, session }; @@ -357,8 +155,9 @@ impl EditFileTool { } else { match EditSession::new( full_input.path.clone(), - full_input.mode, - self, + EditSessionMode::Edit, + Self::NAME, + self.session_context.clone(), event_stream, cx, ) @@ -375,7 +174,7 @@ impl EditFileTool { } }; - return match session.finalize(full_input, self, event_stream, cx).await { + return match session.finalize_edit(full_input.edits, event_stream, cx).await { Ok(()) => EditSessionResult::Completed(session), Err(error) => { log::error!("Failed to finalize edit: {}", error); @@ -433,38 +232,17 @@ impl AgentTool for EditFileTool { cx: &mut App, ) -> SharedString { match input { - Ok(input) => self - .project - .read(cx) - .find_project_path(&input.path, cx) - .and_then(|project_path| { - self.project - .read(cx) - .short_full_path_for_project_path(&project_path, cx) - }) - .unwrap_or(input.path.to_string_lossy().into_owned()) - .into(), - Err(raw_input) => { - if let Ok(input) = serde_json::from_value::(raw_input) { - let path = input.path.unwrap_or_default(); - let path = path.trim(); - if !path.is_empty() { - return self - .project - .read(cx) - .find_project_path(&path, cx) - .and_then(|project_path| { - self.project - .read(cx) - .short_full_path_for_project_path(&project_path, cx) - }) - .unwrap_or_else(|| path.to_string()) - .into(); - } - } - - DEFAULT_UI_TEXT.into() + Ok(input) => { + self.session_context + .initial_title_from_path(&input.path, DEFAULT_UI_TEXT, cx) } + Err(raw_input) => initial_title_from_partial_path::( + &self.session_context, + raw_input, + |partial| partial.path.clone(), + DEFAULT_UI_TEXT, + cx, + ), } } @@ -475,41 +253,12 @@ impl AgentTool for EditFileTool { cx: &mut App, ) -> Task> { cx.spawn(async move |cx: &mut AsyncApp| { - match self - .process_streaming_edits(&mut input, &event_stream, cx) - .await - { - EditSessionResult::Completed(session) => { - self.ensure_buffer_saved(&session.buffer, cx).await; - let (new_text, diff) = session.compute_new_text_and_diff(cx).await; - Ok(EditFileToolOutput::Success { - old_text: session.old_text.clone(), - new_text, - input_path: session.input_path, - diff, - }) - } - EditSessionResult::Failed { - error, - session: Some(session), - } => { - self.ensure_buffer_saved(&session.buffer, cx).await; - let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; - Err(EditFileToolOutput::Error { - error, - input_path: Some(session.input_path), - diff, - }) - } - EditSessionResult::Failed { - error, - session: None, - } => Err(EditFileToolOutput::Error { - error, - input_path: None, - diff: String::new(), - }), - } + run_session( + self.process_streaming_edits(&mut input, &event_stream, cx) + .await, + cx, + ) + .await }) } @@ -520,706 +269,7 @@ impl AgentTool for EditFileTool { event_stream: ToolCallEventStream, cx: &mut App, ) -> Result<()> { - match output { - EditFileToolOutput::Success { - input_path, - old_text, - new_text, - .. - } => { - event_stream.update_diff(cx.new(|cx| { - Diff::finalized( - input_path.to_string_lossy().into_owned(), - Some(old_text.to_string()), - new_text, - self.language_registry.clone(), - cx, - ) - })); - Ok(()) - } - EditFileToolOutput::Error { .. } => Ok(()), - } - } -} - -pub struct EditSession { - abs_path: PathBuf, - input_path: PathBuf, - buffer: Entity, - old_text: Arc, - diff: Entity, - parser: StreamingParser, - pipeline: Pipeline, - _finalize_diff_guard: Deferred>, -} - -enum Pipeline { - Write(WritePipeline), - Edit(EditPipeline), -} - -struct WritePipeline { - content_written: bool, -} - -struct EditPipeline { - current_edit: Option, - file_changed_since_last_read: bool, -} - -enum EditPipelineEntry { - ResolvingOldText { - matcher: StreamingFuzzyMatcher, - }, - StreamingNewText { - streaming_diff: StreamingDiff, - edit_cursor: usize, - reindenter: Reindenter, - original_snapshot: text::BufferSnapshot, - }, -} - -impl Pipeline { - fn new(mode: EditFileMode, file_changed_since_last_read: bool) -> Self { - match mode { - EditFileMode::Write => Self::Write(WritePipeline { - content_written: false, - }), - EditFileMode::Edit => Self::Edit(EditPipeline { - current_edit: None, - file_changed_since_last_read, - }), - } - } -} - -impl WritePipeline { - fn process_event( - &mut self, - event: &WriteEvent, - buffer: &Entity, - tool: &EditFileTool, - cx: &mut AsyncApp, - ) { - let WriteEvent::ContentChunk { chunk } = event; - - let (buffer_id, buffer_len) = - buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); - let edit_range = if self.content_written { - buffer_len..buffer_len - } else { - 0..buffer_len - }; - - agent_edit_buffer(buffer, [(edit_range, chunk.as_str())], &tool.action_log, cx); - cx.update(|cx| { - tool.set_agent_location( - buffer.downgrade(), - text::Anchor::max_for_buffer(buffer_id), - cx, - ); - }); - self.content_written = true; - } -} - -impl EditPipeline { - fn ensure_resolving_old_text(&mut self, buffer: &Entity, cx: &mut AsyncApp) { - if self.current_edit.is_none() { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); - self.current_edit = Some(EditPipelineEntry::ResolvingOldText { - matcher: StreamingFuzzyMatcher::new(snapshot), - }); - } - } - - fn process_event( - &mut self, - event: &EditEvent, - buffer: &Entity, - diff: &Entity, - abs_path: &PathBuf, - tool: &EditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - match event { - EditEvent::OldTextChunk { - chunk, done: false, .. - } => { - log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); - self.ensure_resolving_old_text(buffer, cx); - - if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = - &mut self.current_edit - && !chunk.is_empty() - { - if let Some(match_range) = matcher.push(chunk, None) { - let anchor_range = buffer.read_with(cx, |buffer, _cx| { - buffer.anchor_range_outside(match_range.clone()) - }); - diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); - - cx.update(|cx| { - let position = buffer.read(cx).anchor_before(match_range.end); - tool.set_agent_location(buffer.downgrade(), position, cx); - }); - } - } - } - EditEvent::OldTextChunk { - edit_index, - chunk, - done: true, - } => { - log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); - - self.ensure_resolving_old_text(buffer, cx); - - let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit - else { - return Ok(()); - }; - - if !chunk.is_empty() { - matcher.push(chunk, None); - } - let range = extract_match( - matcher.finish(), - buffer, - edit_index, - self.file_changed_since_last_read, - cx, - )?; - - let anchor_range = - buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone())); - diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - let line = snapshot.offset_to_point(range.start).row; - event_stream.update_fields( - ToolCallUpdateFields::new() - .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), - ); - - let buffer_indent = snapshot.line_indent_for_row(line); - let query_indent = text::LineIndent::from_iter( - matcher - .query_lines() - .first() - .map(|s| s.as_str()) - .unwrap_or("") - .chars(), - ); - let indent_delta = compute_indent_delta(buffer_indent, query_indent); - - let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); - - log::debug!( - "edit[{}] old_text matched at {}..{}: {:?}", - edit_index, - range.start, - range.end, - old_text_in_buffer, - ); - - let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); - self.current_edit = Some(EditPipelineEntry::StreamingNewText { - streaming_diff: StreamingDiff::new(old_text_in_buffer), - edit_cursor: range.start, - reindenter: Reindenter::new(indent_delta), - original_snapshot: text_snapshot, - }); - - cx.update(|cx| { - let position = buffer.read(cx).anchor_before(range.end); - tool.set_agent_location(buffer.downgrade(), position, cx); - }); - } - EditEvent::NewTextChunk { - chunk, done: false, .. - } => { - log::debug!("new_text_chunk: done=false, chunk='{}'", chunk); - - let Some(EditPipelineEntry::StreamingNewText { - streaming_diff, - edit_cursor, - reindenter, - original_snapshot, - .. - }) = &mut self.current_edit - else { - return Ok(()); - }; - - let reindented = reindenter.push(chunk); - if reindented.is_empty() { - return Ok(()); - } - - let char_ops = streaming_diff.push_new(&reindented); - apply_char_operations( - &char_ops, - buffer, - original_snapshot, - edit_cursor, - &tool.action_log, - cx, - ); - - let position = original_snapshot.anchor_before(*edit_cursor); - cx.update(|cx| { - tool.set_agent_location(buffer.downgrade(), position, cx); - }); - } - EditEvent::NewTextChunk { - chunk, done: true, .. - } => { - log::debug!("new_text_chunk: done=true, chunk='{}'", chunk); - - let Some(EditPipelineEntry::StreamingNewText { - mut streaming_diff, - mut edit_cursor, - mut reindenter, - original_snapshot, - }) = self.current_edit.take() - else { - return Ok(()); - }; - - // Flush any remaining reindent buffer + final chunk. - let mut final_text = reindenter.push(chunk); - final_text.push_str(&reindenter.finish()); - - log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); - - if !final_text.is_empty() { - let char_ops = streaming_diff.push_new(&final_text); - apply_char_operations( - &char_ops, - buffer, - &original_snapshot, - &mut edit_cursor, - &tool.action_log, - cx, - ); - } - - let remaining_ops = streaming_diff.finish(); - apply_char_operations( - &remaining_ops, - buffer, - &original_snapshot, - &mut edit_cursor, - &tool.action_log, - cx, - ); - - let position = original_snapshot.anchor_before(edit_cursor); - cx.update(|cx| { - tool.set_agent_location(buffer.downgrade(), position, cx); - }); - } - } - Ok(()) - } -} - -impl EditSession { - async fn new( - path: PathBuf, - mode: EditFileMode, - tool: &EditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result { - let project_path = cx.update(|cx| resolve_path(mode, &path, &tool.project, cx))?; - - let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) - else { - return Err(format!( - "Worktree at '{}' does not exist", - path.to_string_lossy() - )); - }; - - event_stream.update_fields( - ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), - ); - - cx.update(|cx| tool.authorize(&path, event_stream, cx)) - .await - .map_err(|e| e.to_string())?; - - let buffer = tool - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .map_err(|e| e.to_string())?; - - let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; - - let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); - event_stream.update_diff(diff.clone()); - let finalize_diff_guard = util::defer(Box::new({ - let diff = diff.downgrade(); - let mut cx = cx.clone(); - move || { - diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); - } - }) as Box); - - tool.action_log.update(cx, |log, cx| match mode { - EditFileMode::Write => log.buffer_created(buffer.clone(), cx), - EditFileMode::Edit => log.buffer_read(buffer.clone(), cx), - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - Ok(Self { - abs_path, - input_path: path, - buffer, - old_text, - diff, - parser: StreamingParser::default(), - pipeline: Pipeline::new(mode, file_changed_since_last_read), - _finalize_diff_guard: finalize_diff_guard, - }) - } - - async fn finalize( - &mut self, - input: EditFileToolInput, - tool: &EditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - let Self { - abs_path, - buffer, - diff, - parser, - pipeline, - .. - } = self; - match pipeline { - Pipeline::Write(write) => { - let content = input - .content - .ok_or_else(|| "'content' field is required for write mode".to_string())?; - - for event in &parser.finalize_content(&content) { - write.process_event(event, buffer, tool, cx); - } - } - Pipeline::Edit(edit_pipeline) => { - let edits = input - .edits - .ok_or_else(|| "'edits' field is required for edit mode".to_string())?; - for event in &parser.finalize_edits(&edits) { - edit_pipeline.process_event( - event, - buffer, - diff, - abs_path, - tool, - event_stream, - cx, - )?; - } - - if log::log_enabled!(log::Level::Debug) { - log::debug!("Got edits:"); - for edit in &edits { - log::debug!( - " old_text: '{}', new_text: '{}'", - edit.old_text.replace('\n', "\\n"), - edit.new_text.replace('\n', "\\n") - ); - } - } - } - } - Ok(()) - } - - async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) { - let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let (new_text, unified_diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = self.old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - (new_text, diff) - } - }) - .await; - (new_text, unified_diff) - } - - fn process( - &mut self, - partial: EditFileToolPartialInput, - tool: &EditFileTool, - event_stream: &ToolCallEventStream, - cx: &mut AsyncApp, - ) -> Result<(), String> { - let Self { - abs_path, - buffer, - diff, - parser, - pipeline, - .. - } = self; - match pipeline { - Pipeline::Write(write) => { - if let Some(content) = &partial.content { - for event in &parser.push_content(content) { - write.process_event(event, buffer, tool, cx); - } - } - } - Pipeline::Edit(edit_pipeline) => { - if let Some(edits) = partial.edits { - for event in &parser.push_edits(&edits) { - edit_pipeline.process_event( - event, - buffer, - diff, - abs_path, - tool, - event_stream, - cx, - )?; - } - } - } - } - Ok(()) - } -} - -fn apply_char_operations( - ops: &[CharOperation], - buffer: &Entity, - snapshot: &text::BufferSnapshot, - edit_cursor: &mut usize, - action_log: &Entity, - cx: &mut AsyncApp, -) { - for op in ops { - match op { - CharOperation::Insert { text } => { - let anchor = snapshot.anchor_after(*edit_cursor); - agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); - } - CharOperation::Delete { bytes } => { - let delete_end = *edit_cursor + bytes; - let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); - agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); - *edit_cursor = delete_end; - } - CharOperation::Keep { bytes } => { - *edit_cursor += bytes; - } - } - } -} - -fn extract_match( - matches: Vec>, - buffer: &Entity, - edit_index: &usize, - file_changed_since_last_read: bool, - cx: &mut AsyncApp, -) -> Result, String> { - let file_changed_since_last_read_message = if file_changed_since_last_read { - " The file has changed on disk since you last read it." - } else { - "" - }; - - match matches.len() { - 0 => Err(format!( - "Could not find matching text for edit at index {}. \ - The old_text did not match any content in the file.{} \ - Please read the file again to get the current content.", - edit_index, file_changed_since_last_read_message, - )), - 1 => Ok(matches.into_iter().next().unwrap()), - _ => { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let lines = matches - .iter() - .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) - .collect::>() - .join(", "); - Err(format!( - "Edit {} matched multiple locations in the file at lines: {}. \ - Please provide more context in old_text to uniquely \ - identify the location.", - edit_index, lines - )) - } - } -} - -/// Edits a buffer and reports the edit to the action log in the same effect -/// cycle. This ensures the action log's subscription handler sees the version -/// already updated by `buffer_edited`, so it does not misattribute the agent's -/// edit as a user edit. -fn agent_edit_buffer( - buffer: &Entity, - edits: I, - action_log: &Entity, - cx: &mut AsyncApp, -) where - I: IntoIterator, T)>, - S: ToOffset, - T: Into>, -{ - cx.update(|cx| { - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - }); - action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - }); -} - -fn ensure_buffer_saved( - buffer: &Entity, - abs_path: &PathBuf, - tool: &EditFileTool, - cx: &mut AsyncApp, -) -> Result { - let last_read_mtime = tool - .action_log - .read_with(cx, |log, _| log.file_read_time(abs_path)); - let check_result = tool.thread.read_with(cx, |thread, cx| { - let current = buffer - .read(cx) - .file() - .and_then(|file| file.disk_state().mtime()); - let dirty = buffer.read(cx).is_dirty(); - let has_save = thread.has_tool(SaveFileTool::NAME); - let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (current, dirty, has_save, has_restore) - }); - - let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else { - return Ok(false); - }; - - if is_dirty { - let message = match (has_save_tool, has_restore_tool) { - (true, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (true, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." - } - (false, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (false, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ - then ask them to save or revert the file manually and inform you when it's ok to proceed." - } - }; - return Err(message.to_string()); - } - - if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) - && current != last_read - { - return Ok(true); - } - - Ok(false) -} - -fn resolve_path( - mode: EditFileMode, - path: &PathBuf, - project: &Entity, - cx: &mut App, -) -> Result { - let project = project.read(cx); - - match mode { - EditFileMode::Edit => { - let path = project - .find_project_path(&path, cx) - .ok_or_else(|| "Can't edit file: path not found".to_string())?; - - let entry = project - .entry_for_path(&path, cx) - .ok_or_else(|| "Can't edit file: path not found".to_string())?; - - if entry.is_file() { - Ok(path) - } else { - Err("Can't edit file: path is a directory".to_string()) - } - } - EditFileMode::Write => { - if let Some(path) = project.find_project_path(&path, cx) - && let Some(entry) = project.entry_for_path(&path, cx) - { - if entry.is_file() { - return Ok(path); - } else { - return Err("Can't write to file: path is a directory".to_string()); - } - } - - let parent_path = path - .parent() - .ok_or_else(|| "Can't create file: incorrect path".to_string())?; - - let parent_project_path = project.find_project_path(&parent_path, cx); - - let parent_entry = parent_project_path - .as_ref() - .and_then(|path| project.entry_for_path(path, cx)) - .ok_or_else(|| "Can't create file: parent directory doesn't exist")?; - - if !parent_entry.is_dir() { - return Err("Can't create file: parent is not a directory".to_string()); - } - - let file_name = path - .file_name() - .and_then(|file_name| file_name.to_str()) - .and_then(|file_name| RelPath::unix(file_name).ok()) - .ok_or_else(|| "Can't create file: invalid filename".to_string())?; - - let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: parent.path.join(file_name), - ..parent - }); - - new_file_path.ok_or_else(|| "Can't create file".to_string()) - } + self.session_context.replay_output(output, event_stream, cx) } } @@ -1228,85 +278,29 @@ mod tests { use super::*; use crate::{ContextServerRegistry, Templates, ToolInputSender}; use fs::Fs as _; - use futures::StreamExt as _; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::{AppContext as _, TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; + use project::ProjectPath; use prompt_store::ProjectContext; use serde_json::json; use settings::Settings; use settings::SettingsStore; use util::path; - use util::rel_path::rel_path; - - #[gpui::test] - async fn test_streaming_edit_create_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/dir/new_file.txt".into(), - mode: EditFileMode::Write, - content: Some("Hello, World!".into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let EditFileToolOutput::Success { new_text, diff, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "Hello, World!"); - assert!(!diff.is_empty()); - } - - #[gpui::test] - async fn test_streaming_edit_overwrite_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "old content"})).await; - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/file.txt".into(), - mode: EditFileMode::Write, - content: Some("new content".into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let EditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new content"); - assert_eq!(*old_text, "old content"); - } + use util::rel_path::{RelPath, rel_path}; #[gpui::test] async fn test_streaming_edit_granular_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "line 2".into(), new_text: "modified line 2".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -1322,19 +316,17 @@ mod tests { #[gpui::test] async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( cx, json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), ) .await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![ + edits: vec![ Edit { old_text: "line 5".into(), new_text: "modified line 5".into(), @@ -1343,7 +335,7 @@ mod tests { old_text: "line 1".into(), new_text: "modified line 1".into(), }, - ]), + ], }), ToolCallEventStream::test().0, cx, @@ -1362,19 +354,17 @@ mod tests { #[gpui::test] async fn test_streaming_edit_adjacent_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( cx, json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), ) .await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![ + edits: vec![ Edit { old_text: "line 2".into(), new_text: "modified line 2".into(), @@ -1383,7 +373,7 @@ mod tests { old_text: "line 3".into(), new_text: "modified line 3".into(), }, - ]), + ], }), ToolCallEventStream::test().0, cx, @@ -1402,19 +392,17 @@ mod tests { #[gpui::test] async fn test_streaming_edit_ascending_order_edits(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( cx, json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), ) .await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![ + edits: vec![ Edit { old_text: "line 1".into(), new_text: "modified line 1".into(), @@ -1423,7 +411,7 @@ mod tests { old_text: "line 5".into(), new_text: "modified line 5".into(), }, - ]), + ], }), ToolCallEventStream::test().0, cx, @@ -1442,18 +430,16 @@ mod tests { #[gpui::test] async fn test_streaming_edit_nonexistent_file(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/nonexistent_file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "foo".into(), new_text: "bar".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -1476,19 +462,17 @@ mod tests { #[gpui::test] async fn test_streaming_edit_failed_match(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello world"})).await; let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "nonexistent text that is not in the file".into(), new_text: "replacement".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -1507,11 +491,11 @@ mod tests { #[gpui::test] async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Send partials simulating LLM streaming: description first, then path, then mode sender.send_partial(json!({})); @@ -1525,14 +509,12 @@ mod tests { // Path is NOT yet complete because mode hasn't appeared — no buffer open yet sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); // Now send the final complete input sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] })); @@ -1543,49 +525,14 @@ mod tests { assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); } - #[gpui::test] - async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "hello world"})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Send partial with path but NO mode — path should NOT be treated as complete - sender.send_partial(json!({ - "path": "root/file" - })); - cx.run_until_parked(); - - // Now the path grows and mode appears - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Send final - sender.send_full(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new content" - })); - - let result = task.await; - let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "new content"); - } - #[gpui::test] async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello world"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver, mut cancellation_tx) = ToolCallEventStream::test_with_cancellation(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Send a partial sender.send_partial(json!({})); @@ -1611,14 +558,14 @@ mod tests { #[gpui::test] async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test( cx, json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), ) .await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Simulate fine-grained streaming of the JSON sender.send_partial(json!({})); @@ -1631,20 +578,17 @@ mod tests { sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 1"}] })); cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "modified line 1"}, {"old_text": "line 5"} @@ -1655,7 +599,6 @@ mod tests { // Send final complete input sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "modified line 1"}, {"old_text": "line 5", "new_text": "modified line 5"} @@ -1672,56 +615,17 @@ mod tests { ); } - #[gpui::test] - async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Stream partials for create mode - sender.send_partial(json!({})); - cx.run_until_parked(); - - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "Hello, " - })); - cx.run_until_parked(); - - // Final with full content - sender.send_full(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "Hello, World!" - })); - - let result = task.await; - let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "Hello, World!"); - } - #[gpui::test] async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Send final immediately with no partials (simulates non-streaming path) sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] })); @@ -1734,14 +638,14 @@ mod tests { #[gpui::test] async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test( + let (edit_tool, project, _action_log, _fs, _thread) = setup_test( cx, json!({"file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n"}), ) .await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Stream description, path, mode sender.send_partial(json!({})); @@ -1749,14 +653,12 @@ mod tests { sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); // First edit starts streaming (old_text only, still in progress) sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 1"}] })); cx.run_until_parked(); @@ -1782,7 +684,6 @@ mod tests { // should be applied immediately during streaming sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "MODIFIED 1"}, {"old_text": "line 5"} @@ -1808,7 +709,6 @@ mod tests { // Send final complete input sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "MODIFIED 1"}, {"old_text": "line 5", "new_text": "MODIFIED 5"} @@ -1831,28 +731,34 @@ mod tests { #[gpui::test] async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = + let (edit_tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Setup: description + path + mode sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); // Edit 1 in progress sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "aaa", "new_text": "AAA"}] })); cx.run_until_parked(); // Edit 2 appears — edit 1 is now complete and should be applied + sender.send_partial(json!({ + "path": "root/file.txt", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc"} + ] + })); + cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", "mode": "edit", @@ -1875,6 +781,15 @@ mod tests { assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCCccc\nddd\neee\n")); // Edit 3 appears — edit 2 is now complete and should be applied + sender.send_partial(json!({ + "path": "root/file.txt", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee"} + ] + })); + cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", "mode": "edit", @@ -1899,7 +814,6 @@ mod tests { // Send final sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"}, @@ -1916,23 +830,21 @@ mod tests { #[gpui::test] async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = + let (edit_tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Setup sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); // Edit 1 (valid) in progress — not yet complete (no second edit) sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "MODIFIED"} ] @@ -1943,7 +855,6 @@ mod tests { // Edit 1 should be applied. Edit 2 is still in-progress (last edit). sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "MODIFIED"}, {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"} @@ -1969,7 +880,6 @@ mod tests { // resolution which should fail (old_text doesn't exist in the file). sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "line 1", "new_text": "MODIFIED"}, {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}, @@ -2006,22 +916,26 @@ mod tests { #[gpui::test] async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = + let (edit_tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello world\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Setup + single edit that stays in-progress (no second edit to prove completion) sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", })); cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", + "edits": [{"old_text": "hello world"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "path": "root/file.txt", "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] })); cx.run_until_parked(); @@ -2045,7 +959,6 @@ mod tests { // Send final — the edit is applied during finalization sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] })); @@ -2058,12 +971,12 @@ mod tests { #[gpui::test] async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; let (mut sender, input): (ToolInputSender, ToolInput) = ToolInput::test(); let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Send progressively more complete partial snapshots, as the LLM would sender.send_partial(json!({})); @@ -2071,13 +984,11 @@ mod tests { sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] })); cx.run_until_parked(); @@ -2085,7 +996,6 @@ mod tests { // Send the final complete input sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] })); @@ -2098,12 +1008,12 @@ mod tests { #[gpui::test] async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello world\n"})).await; let (mut sender, input): (ToolInputSender, ToolInput) = ToolInput::test(); let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Send a partial then drop the sender without sending final sender.send_partial(json!({})); @@ -2118,69 +1028,9 @@ mod tests { ); } - #[gpui::test] - async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - // Create a channel and send multiple partials before a final, then use - // ToolInput::resolved-style immediate delivery to confirm recv() works - // when partials are already buffered. - let (mut sender, input): (ToolInputSender, ToolInput) = - ToolInput::test(); - let (event_stream, _event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Buffer several partials before sending the final - sender.send_partial(json!({})); - sender.send_partial(json!({"path": "root/dir/new.txt"})); - sender.send_partial(json!({ - "path": "root/dir/new.txt", - "mode": "write" - })); - sender.send_full(json!({ - "path": "root/dir/new.txt", - "mode": "write", - "content": "streamed content" - })); - - let result = task.await; - let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "streamed content"); - } - - #[gpui::test] - async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = EditFileMode::Write; - - let result = test_resolve_path(&mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(&mode, "new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("new.txt")); - - let result = test_resolve_path(&mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); - - let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); - assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt")); - - let result = test_resolve_path(&mode, "root/dir/subdir", cx); - assert_eq!( - result.await.unwrap_err(), - "Can't write to file: path is a directory" - ); - - let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); - assert_eq!( - result.await.unwrap_err(), - "Can't create file: parent directory doesn't exist" - ); - } - #[gpui::test] async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = EditFileMode::Edit; + let mode = EditSessionMode::Edit; let path_with_root = "root/dir/subdir/existing.txt"; let path_without_root = "dir/subdir/existing.txt"; @@ -2201,7 +1051,7 @@ mod tests { } async fn test_resolve_path( - mode: &EditFileMode, + mode: &EditSessionMode, path: &str, cx: &mut TestAppContext, ) -> Result { @@ -2221,7 +1071,7 @@ mod tests { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), &project, cx)) + crate::tools::edit_session::test_resolve_path(mode, path, &project, cx).await } #[track_caller] @@ -2230,290 +1080,14 @@ mod tests { assert_eq!(actual.as_ref(), expected); } - #[gpui::test] - async fn test_streaming_format_on_save(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - let (tool, project, action_log, fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - - let rust_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Rust", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/src/main.rs"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\ -"; - const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\ -"; - - // Get the fake language server and set up formatting handler - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::({ - |_, _| async move { - Ok(Some(vec![lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - new_text: FORMATTED_CONTENT.to_string(), - }])) - } - }); - - // Test with format_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); - settings.project.all_languages.defaults.formatter = - Some(language::language_settings::FormatterList::default()); - }); - }); - }); - - // Use streaming pattern so executor can pump the LSP request/response - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - sender.send_partial(json!({ - "path": "root/src/main.rs", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_full(json!({ - "path": "root/src/main.rs", - "mode": "write", - "content": UNFORMATTED_CONTENT - })); - - let result = task.await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - new_content.replace("\r\n", "\n"), - FORMATTED_CONTENT, - "Code should be formatted when format_on_save is enabled" - ); - - let stale_buffer_count = thread - .read_with(cx, |thread, _cx| thread.action_log.clone()) - .read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - assert_eq!( - stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.", - stale_buffer_count - ); - - // Test with format_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.project.all_languages.defaults.format_on_save = - Some(FormatOnSave::Off); - }); - }); - }); - - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - - let tool2 = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - action_log.clone(), - language_registry, - )); - - let task = cx.update(|cx| tool2.run(input, event_stream, cx)); - - sender.send_partial(json!({ - "path": "root/src/main.rs", - "mode": "write" - })); - cx.run_until_parked(); - - sender.send_full(json!({ - "path": "root/src/main.rs", - "mode": "write", - "content": UNFORMATTED_CONTENT - })); - - let result = task.await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - new_content.replace("\r\n", "\n"), - UNFORMATTED_CONTENT, - "Code should not be formatted when format_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - ) - .await - .unwrap(); - let (tool, project, action_log, fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; - let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); - - // Test with remove_trailing_whitespace_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(true); - }); - }); - }); - - const CONTENT_WITH_TRAILING_WHITESPACE: &str = - "fn main() { \n println!(\"Hello!\"); \n}\n"; - - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/src/main.rs".into(), - mode: EditFileMode::Write, - content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - assert_eq!( - fs.load(path!("/root/src/main.rs").as_ref()) - .await - .unwrap() - .replace("\r\n", "\n"), - "fn main() {\n println!(\"Hello!\");\n}\n", - "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - ); - - // Test with remove_trailing_whitespace_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings - .project - .all_languages - .defaults - .remove_trailing_whitespace_on_save = Some(false); - }); - }); - }); - - let tool2 = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - action_log.clone(), - language_registry, - )); - - let result = cx - .update(|cx| { - tool2.run( - ToolInput::resolved(EditFileToolInput { - path: "root/src/main.rs".into(), - mode: EditFileMode::Write, - content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), - edits: None, - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - assert!(result.is_ok()); - - cx.executor().run_until_parked(); - - let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - final_content.replace("\r\n", "\n"), - CONTENT_WITH_TRAILING_WHITESPACE, - "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - ); - } - #[gpui::test] async fn test_streaming_authorize(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({})).await; // Test 1: Path with .zed component should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = - cx.update(|cx| tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx)); + let _auth = cx + .update(|cx| edit_tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!( @@ -2523,7 +1097,8 @@ mod tests { // Test 2: Path outside project should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)); + let _auth = + cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!( @@ -2533,15 +1108,16 @@ mod tests { // Test 3: Relative path without .zed should not require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx)) + cx.update(|cx| edit_tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx)) .await .unwrap(); assert!(stream_rx.try_recv().is_err()); // Test 4: Path with .zed in the middle should require confirmation let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = - cx.update(|cx| tool.authorize(&PathBuf::from("root/.zed/tasks.json"), &stream_tx, cx)); + let _auth = cx.update(|cx| { + edit_tool.authorize(&PathBuf::from("root/.zed/tasks.json"), &stream_tx, cx) + }); let event = stream_rx.expect_authorization().await; assert_eq!( event.tool_call.fields.title, @@ -2558,8 +1134,8 @@ mod tests { // 5.1: .zed/settings.json is a sensitive path — still prompts let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = - cx.update(|cx| tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx)); + let _auth = cx + .update(|cx| edit_tool.authorize(&PathBuf::from(".zed/settings.json"), &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!( event.tool_call.fields.title, @@ -2568,14 +1144,14 @@ mod tests { // 5.2: /etc/hosts is outside the project, but Allow auto-approves let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)) + cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)) .await .unwrap(); assert!(stream_rx.try_recv().is_err()); // 5.3: Normal in-project path with allow — no confirmation needed let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx)) + cx.update(|cx| edit_tool.authorize(&PathBuf::from("root/src/main.rs"), &stream_tx, cx)) .await .unwrap(); assert!(stream_rx.try_recv().is_err()); @@ -2588,7 +1164,8 @@ mod tests { }); let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)); + let _auth = + cx.update(|cx| edit_tool.authorize(&PathBuf::from("/etc/hosts"), &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert_eq!( @@ -2606,7 +1183,7 @@ mod tests { fs.insert_tree("/outside", json!({})).await; fs.insert_symlink("/root/link", PathBuf::from("/outside")) .await; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; cx.update(|cx| { @@ -2617,7 +1194,7 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let authorize_task = - cx.update(|cx| tool.authorize(&PathBuf::from("link/new.txt"), &stream_tx, cx)); + cx.update(|cx| edit_tool.authorize(&PathBuf::from("link/new.txt"), &stream_tx, cx)); let event = stream_rx.expect_authorization().await; assert!( @@ -2667,12 +1244,12 @@ mod tests { ) .await .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _authorize_task = cx.update(|cx| { - tool.authorize( + edit_tool.authorize( &PathBuf::from("link_to_external/config.txt"), &stream_tx, cx, @@ -2712,12 +1289,12 @@ mod tests { ) .await .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let authorize_task = cx.update(|cx| { - tool.authorize( + edit_tool.authorize( &PathBuf::from("link_to_external/config.txt"), &stream_tx, cx, @@ -2767,13 +1344,13 @@ mod tests { ) .await .unwrap(); - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let result = cx .update(|cx| { - tool.authorize( + edit_tool.authorize( &PathBuf::from("link_to_external/config.txt"), &stream_tx, cx, @@ -2796,7 +1373,7 @@ mod tests { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; let test_cases = vec![ @@ -2819,7 +1396,7 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| tool.authorize(&PathBuf::from(path), &stream_tx, cx)); + let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx)); if should_confirm { stream_rx.expect_authorization().await; @@ -2866,7 +1443,7 @@ mod tests { }), ) .await; - let (tool, _project, _action_log, _fs, _thread) = setup_test_with_fs( + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs( cx, fs, &[ @@ -2895,7 +1472,7 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| tool.authorize(&PathBuf::from(path), &stream_tx, cx)); + let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx)); if should_confirm { stream_rx.expect_authorization().await; @@ -2929,7 +1506,7 @@ mod tests { }), ) .await; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; let test_cases = vec![ @@ -2953,7 +1530,7 @@ mod tests { for (path, should_confirm, description) in test_cases { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let auth = cx.update(|cx| tool.authorize(&PathBuf::from(path), &stream_tx, cx)); + let auth = cx.update(|cx| edit_tool.authorize(&PathBuf::from(path), &stream_tx, cx)); cx.run_until_parked(); @@ -2985,32 +1562,35 @@ mod tests { }), ) .await; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; - let modes = vec![EditFileMode::Edit, EditFileMode::Write]; + let modes = vec![EditSessionMode::Edit, EditSessionMode::Write]; for _mode in modes { // Test .zed path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let _auth = cx.update(|cx| { - tool.authorize(&PathBuf::from("project/.zed/settings.json"), &stream_tx, cx) + edit_tool.authorize(&PathBuf::from("project/.zed/settings.json"), &stream_tx, cx) }); stream_rx.expect_authorization().await; // Test outside path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let _auth = - cx.update(|cx| tool.authorize(&PathBuf::from("/outside/file.txt"), &stream_tx, cx)); + let _auth = cx.update(|cx| { + edit_tool.authorize(&PathBuf::from("/outside/file.txt"), &stream_tx, cx) + }); stream_rx.expect_authorization().await; // Test normal path with different modes let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - cx.update(|cx| tool.authorize(&PathBuf::from("project/normal.txt"), &stream_tx, cx)) - .await - .unwrap(); + cx.update(|cx| { + edit_tool.authorize(&PathBuf::from("project/normal.txt"), &stream_tx, cx) + }) + .await + .unwrap(); assert!(stream_rx.try_recv().is_err()); } } @@ -3020,12 +1600,12 @@ mod tests { init_test(cx); let fs = project::FakeFs::new(cx.executor()); fs.insert_tree("/project", json!({})).await; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test_with_fs(cx, fs, &[path!("/project").as_ref()]).await; cx.update(|cx| { assert_eq!( - tool.initial_title( + edit_tool.initial_title( Err(json!({ "path": "src/main.rs", })), @@ -3034,7 +1614,7 @@ mod tests { "src/main.rs" ); assert_eq!( - tool.initial_title( + edit_tool.initial_title( Err(json!({ "path": "", })), @@ -3043,77 +1623,15 @@ mod tests { DEFAULT_UI_TEXT ); assert_eq!( - tool.initial_title(Err(serde_json::Value::Null), cx), + edit_tool.initial_title(Err(serde_json::Value::Null), cx), DEFAULT_UI_TEXT ); }); } - #[gpui::test] - async fn test_streaming_diff_finalization(cx: &mut TestAppContext) { - init_test(cx); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/", json!({"main.rs": ""})).await; - let (tool, project, action_log, _fs, thread) = - setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await; - let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); - - // Ensure the diff is finalized after the edit completes. - { - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let edit = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: path!("/main.rs").into(), - mode: EditFileMode::Write, - content: Some("new content".into()), - edits: None, - }), - stream_tx, - cx, - ) - }); - stream_rx.expect_update_fields().await; - let diff = stream_rx.expect_diff().await; - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); - cx.run_until_parked(); - edit.await.unwrap(); - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - - // Ensure the diff is finalized if the tool call gets dropped. - { - let tool = Arc::new(EditFileTool::new( - project.clone(), - thread.downgrade(), - action_log, - language_registry, - )); - let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); - let edit = cx.update(|cx| { - tool.run( - ToolInput::resolved(EditFileToolInput { - path: path!("/main.rs").into(), - mode: EditFileMode::Write, - content: Some("dropped content".into()), - edits: None, - }), - stream_tx, - cx, - ) - }); - stream_rx.expect_update_fields().await; - let diff = stream_rx.expect_diff().await; - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); - drop(edit); - cx.run_until_parked(); - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - } - #[gpui::test] async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) { - let (tool, project, action_log, _fs, _thread) = + let (edit_tool, project, action_log, _fs, _thread) = setup_test(cx, json!({"test.txt": "original content"})).await; let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), @@ -3139,15 +1657,13 @@ mod tests { // First edit should work let edit_result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/test.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "original content".into(), new_text: "modified content".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -3163,15 +1679,13 @@ mod tests { // Second edit should also work because the edit updated the recorded read time let edit_result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/test.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "modified content".into(), new_text: "further modified content".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -3187,7 +1701,7 @@ mod tests { #[gpui::test] async fn test_streaming_external_modification_matching_edit_succeeds(cx: &mut TestAppContext) { - let (tool, project, action_log, fs, _thread) = + let (edit_tool, project, action_log, fs, _thread) = setup_test(cx, json!({"test.txt": "original content"})).await; let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), @@ -3240,15 +1754,13 @@ mod tests { let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/test.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "externally modified content".into(), new_text: "new content".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -3274,7 +1786,7 @@ mod tests { async fn test_streaming_external_modification_mentioned_when_match_fails( cx: &mut TestAppContext, ) { - let (tool, project, action_log, fs, _thread) = + let (edit_tool, project, action_log, fs, _thread) = setup_test(cx, json!({"test.txt": "original content"})).await; let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), @@ -3324,15 +1836,13 @@ mod tests { let result = cx .update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/test.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "original content".into(), new_text: "new content".into(), - }]), + }], }), ToolCallEventStream::test().0, cx, @@ -3361,9 +1871,12 @@ mod tests { assert_eq!(input_path, Some(PathBuf::from("root/test.txt"))); } + /// When the buffer has unsaved changes and the user picks "Save", the + /// pending edits are flushed to disk and the agent's edit then proceeds + /// against the just-saved content. #[gpui::test] - async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) { - let (tool, project, action_log, _fs, _thread) = + async fn test_streaming_dirty_buffer_save(cx: &mut TestAppContext) { + let (edit_tool, project, action_log, fs, _thread) = setup_test(cx, json!({"test.txt": "original content"})).await; let read_tool = Arc::new(crate::ReadFileTool::new( project.clone(), @@ -3371,7 +1884,6 @@ mod tests { true, )); - // Read the file first cx.update(|cx| { read_tool.clone().run( ToolInput::resolved(crate::ReadFileToolInput { @@ -3386,7 +1898,6 @@ mod tests { .await .unwrap(); - // Open the buffer and make it dirty let project_path = project .read_with(cx, |project, cx| { project.find_project_path("root/test.txt", cx) @@ -3399,56 +1910,219 @@ mod tests { buffer.update(cx, |buffer, cx| { let end_point = buffer.max_point(); - buffer.edit([(end_point..end_point, " added text")], None, cx); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/test.txt".into(), + edits: vec![Edit { + old_text: "original content plus user edit".into(), + new_text: "replaced content".into(), + }], + }), + stream_tx, + cx, + ) }); - let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); - assert!(is_dirty, "Buffer should be dirty after in-memory edit"); - - // Try to edit - should fail because buffer has unsaved changes - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/test.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { - old_text: "original content".into(), - new_text: "new content".into(), - }]), - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await; - - let EditFileToolOutput::Error { - error, - diff, - input_path, - } = result.unwrap_err() + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]); + let acp::ToolCallContent::Content(text) = content.first().expect("expected message body") else { - panic!("expected error"); + panic!("expected text body, got: {:?}", content.first()); + }; + let acp::ContentBlock::Text(text) = &text.content else { + panic!("expected text body, got: {:?}", text.content); }; assert!( - error.contains("This file has unsaved changes."), - "Error should mention unsaved changes, got: {}", - error + text.text.contains("unsaved changes") + && text.text.contains("save") + && text.text.contains("discard"), + "unexpected message body: {:?}", + text.text, ); - assert!( - error.contains("keep or discard"), - "Error should ask whether to keep or discard changes, got: {}", - error - ); - assert!( - error.contains("save or revert the file manually"), - "Error should ask user to manually save or revert when tools aren't available, got: {}", - error - ); - assert!(diff.is_empty()); - assert!(input_path.is_none()); + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("save"), + acp::PermissionOptionKind::AllowOnce, + )) + .unwrap(); + + let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "replaced content"); + assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "replaced content"); + } + + /// When the buffer has unsaved changes and the user picks "Discard", the + /// pending edits are reverted to match disk and the agent's edit then + /// proceeds against the on-disk content. + #[gpui::test] + async fn test_streaming_dirty_buffer_discard(cx: &mut TestAppContext) { + let (edit_tool, project, action_log, fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; + let read_tool = Arc::new(crate::ReadFileTool::new( + project.clone(), + action_log.clone(), + true, + )); + + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/test.txt".into(), + // Match the on-disk content, not the dirty in-memory content. + edits: vec![Edit { + old_text: "original content".into(), + new_text: "replaced content".into(), + }], + }), + stream_tx, + cx, + ) + }); + + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("discard"), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + + let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "replaced content"); + assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "replaced content"); + } + + /// When the buffer is dirty and the user resolves it manually — e.g. + /// pressing `cmd-s` while the prompt is visible — the prompt is + /// dismissed automatically and the edit proceeds against the saved + /// content. The user shouldn't have to also click a button. + #[gpui::test] + async fn test_streaming_dirty_buffer_resolved_externally(cx: &mut TestAppContext) { + let (edit_tool, project, action_log, fs, _thread) = + setup_test(cx, json!({"test.txt": "original content"})).await; + let read_tool = Arc::new(crate::ReadFileTool::new( + project.clone(), + action_log.clone(), + true, + )); + + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(EditFileToolInput { + path: "root/test.txt".into(), + edits: vec![Edit { + old_text: "original content plus user edit".into(), + new_text: "replaced content".into(), + }], + }), + stream_tx, + cx, + ) + }); + + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + + // Simulate the user saving the buffer manually (e.g. cmd-s) while + // the prompt is visible. The tool should detect the buffer became + // clean and proceed without the user clicking anything. + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + // The prompt's response channel should drop without a click; the + // tool dismisses the prompt by transitioning the tool call status + // to `InProgress`. + let dismiss = stream_rx.expect_update_fields().await; + assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress)); + drop(auth); + + let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "replaced content"); + assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let on_disk = fs.load(path!("/root/test.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "replaced content"); } #[gpui::test] @@ -3457,16 +2131,15 @@ mod tests { // old_text as a substring. Because edits resolve sequentially // against the current buffer, edit 2 finds a unique match in // the modified buffer and succeeds. - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "aaa\nbbb\nccc\nddd\neee\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); // Setup: resolve the buffer sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); @@ -3477,7 +2150,6 @@ mod tests { // Edit 3 exists only to mark edit 2 as "complete" during streaming. sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, {"old_text": "ccc\nddd", "new_text": "ZZZ"}, @@ -3489,7 +2161,6 @@ mod tests { // Send the final input with all three edits. sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "bbb\nccc", "new_text": "XXX\nccc\nddd"}, {"old_text": "ccc\nddd", "new_text": "ZZZ"}, @@ -3504,218 +2175,16 @@ mod tests { assert_eq!(new_text, "aaa\nXXX\nZZZ\nddd\nDUMMY\n"); } - #[gpui::test] - async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Stream content incrementally - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\n" - })); - cx.run_until_parked(); - - // Verify buffer has partial content - let buffer = project.update(cx, |project, cx| { - let path = project - .find_project_path("root/dir/new_file.txt", cx) - .unwrap(); - project.get_open_buffer(&path, cx).unwrap() - }); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n"); - - // Stream more content - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\n" - })); - cx.run_until_parked(); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n"); - - // Stream final chunk - sender.send_partial(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\nline 3\n" - })); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "line 1\nline 2\nline 3\n" - ); - - // Send final input - sender.send_full(json!({ - "path": "root/dir/new_file.txt", - "mode": "write", - "content": "line 1\nline 2\nline 3\n" - })); - - let result = task.await; - let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { - panic!("expected success"); - }; - assert_eq!(new_text, "line 1\nline 2\nline 3\n"); - } - - #[gpui::test] - async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, mut receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "path": "root/file.txt", - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Get the diff entity from the event stream - receiver.expect_update_fields().await; - let diff = receiver.expect_diff().await; - - // Diff starts pending with no revealed ranges - diff.read_with(cx, |diff, cx| { - assert!(matches!(diff, Diff::Pending(_))); - assert!(!diff.has_revealed_range(cx)); - }); - - // Stream first content chunk - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\n" - })); - cx.run_until_parked(); - - // Diff should now have revealed ranges showing the new content - diff.read_with(cx, |diff, cx| { - assert!(diff.has_revealed_range(cx)); - }); - - // Send final input - sender.send_full(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\n" - })); - - let result = task.await; - let EditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new line 1\nnew line 2\n"); - assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); - - // Diff is finalized after completion - diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); - } - - #[gpui::test] - async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) { - let (tool, project, _action_log, _fs, _thread) = setup_test( - cx, - json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), - ) - .await; - let (mut sender, input) = ToolInput::::test(); - let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); - - // Transition to BufferResolved - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write" - })); - cx.run_until_parked(); - - // Verify buffer still has old content (no content partial yet) - let buffer = project.update(cx, |project, cx| { - let path = project.find_project_path("root/file.txt", cx).unwrap(); - project.open_buffer(path, cx) - }); - let buffer = buffer.await.unwrap(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "old line 1\nold line 2\nold line 3\n" - ); - - // First content partial replaces old content - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\n" - })); - cx.run_until_parked(); - assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n"); - - // Subsequent content partials append - sender.send_partial(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\n" - })); - cx.run_until_parked(); - assert_eq!( - buffer.read_with(cx, |b, _| b.text()), - "new line 1\nnew line 2\n" - ); - - // Send final input with complete content - sender.send_full(json!({ - "path": "root/file.txt", - "mode": "write", - "content": "new line 1\nnew line 2\nnew line 3\n" - })); - - let result = task.await; - let EditFileToolOutput::Success { - new_text, old_text, .. - } = result.unwrap() - else { - panic!("expected success"); - }; - assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n"); - assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); - } - #[gpui::test] async fn test_streaming_edit_json_fixer_escape_corruption(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello\nworld\nfoo\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); @@ -3726,7 +2195,6 @@ mod tests { // partial 2: old_text = "hello\nworld" (fixer corrected the escape) sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "hello\\"}] })); cx.run_until_parked(); @@ -3734,7 +2202,6 @@ mod tests { // Now the fixer corrects it to the real newline. sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "hello\nworld"}] })); cx.run_until_parked(); @@ -3742,7 +2209,6 @@ mod tests { // Send final. sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [{"old_text": "hello\nworld", "new_text": "HELLO\nWORLD"}] })); @@ -3755,21 +2221,19 @@ mod tests { #[gpui::test] async fn test_streaming_final_input_stringified_edits_succeeds(cx: &mut TestAppContext) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "hello\nworld\n"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit" })); cx.run_until_parked(); sender.send_full(json!({ "path": "root/file.txt", - "mode": "edit", "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" })); @@ -3784,7 +2248,7 @@ mod tests { // reports changed buffers so that the Accept All / Reject All review UI appears. #[gpui::test] async fn test_streaming_edit_file_tool_registers_changed_buffers(cx: &mut TestAppContext) { - let (tool, _project, action_log, _fs, _thread) = + let (edit_tool, _project, action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "line 1\nline 2\nline 3\n"})).await; cx.update(|cx| { let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); @@ -3794,15 +2258,13 @@ mod tests { let (event_stream, _rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { - tool.clone().run( + edit_tool.clone().run( ToolInput::resolved(EditFileToolInput { path: "root/file.txt".into(), - mode: EditFileMode::Edit, - content: None, - edits: Some(vec![Edit { + edits: vec![Edit { old_text: "line 2".into(), new_text: "modified line 2".into(), - }]), + }], }), event_stream, cx, @@ -3823,79 +2285,39 @@ mod tests { } // Same test but for Write mode (overwrite entire file). - #[gpui::test] - async fn test_streaming_edit_file_tool_write_mode_registers_changed_buffers( - cx: &mut TestAppContext, - ) { - let (tool, _project, action_log, _fs, _thread) = - setup_test(cx, json!({"file.txt": "original content"})).await; - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - let (event_stream, _rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/file.txt".into(), - mode: EditFileMode::Write, - content: Some("completely new content".into()), - edits: None, - }), - event_stream, - cx, - ) - }); - - let result = task.await; - assert!(result.is_ok(), "write should succeed: {:?}", result.err()); - - cx.run_until_parked(); - - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); - assert!( - !changed.is_empty(), - "action_log.changed_buffers() should be non-empty after streaming write, \ - but no changed buffers were found \u{2014} Accept All / Reject All will not appear" - ); - } #[gpui::test] - async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( + async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( cx: &mut TestAppContext, ) { - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.txt": "old_content"})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); sender.send_partial(json!({ - "mode": "write" + "edits": [{"old_text": "old_content"}] })); cx.run_until_parked(); sender.send_partial(json!({ - "mode": "write", - "content": "new_content" + "edits": [{"old_text": "old_content", "new_text": "new_content"}] })); cx.run_until_parked(); sender.send_partial(json!({ - "mode": "write", - "content": "new_content", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], "path": "root" })); cx.run_until_parked(); // Send final. sender.send_full(json!({ - "mode": "write", - "content": "new_content", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], "path": "root/file.txt" })); + cx.run_until_parked(); let result = task.await; let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { @@ -3905,7 +2327,7 @@ mod tests { } #[gpui::test] - async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( + async fn test_streaming_edit_file_tool_new_and_old_text_appear_together( cx: &mut TestAppContext, ) { let (tool, _project, _action_log, _fs, _thread) = @@ -3915,37 +2337,81 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); sender.send_partial(json!({ - "mode": "edit" + "mode": "edit", + "path": "root/file.txt" })); cx.run_until_parked(); sender.send_partial(json!({ "mode": "edit", - "edits": [{"old_text": "old_content"}] + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old"}] })); cx.run_until_parked(); sender.send_partial(json!({ "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}] + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] })); cx.run_until_parked(); - sender.send_partial(json!({ - "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}], - "path": "root" - })); - cx.run_until_parked(); - - // Send final. sender.send_full(json!({ "mode": "edit", - "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + + let result = task.await; + let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_new_text_before_old_text(cx: &mut TestAppContext) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "mode": "edit", "path": "root/file.txt" })); cx.run_until_parked(); + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": ""}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old"}] + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + cx.run_until_parked(); + let result = task.await; let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else { panic!("expected success"); @@ -3968,7 +2434,7 @@ mod tests { "#} .to_string(); - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.rs": file_content})).await; // The model sends old_text with a PARTIAL last line. @@ -3977,11 +2443,10 @@ mod tests { let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); sender.send_full(json!({ "path": "root/file.rs", - "mode": "edit", "edits": [{"old_text": old_text, "new_text": new_text}] })); @@ -4013,15 +2478,14 @@ mod tests { let new_text = "one\ntwo\ntarget\n"; let expected = "before\none\ntwo\ntarget\n\nafter\n"; - let (tool, _project, _action_log, _fs, _thread) = + let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(cx, json!({"file.rs": file_content})).await; let (mut sender, input) = ToolInput::::test(); let (event_stream, _receiver) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let task = cx.update(|cx| edit_tool.clone().run(input, event_stream, cx)); sender.send_full(json!({ "path": "root/file.rs", - "mode": "edit", "edits": [{"old_text": old_text, "new_text": new_text}] })); @@ -4042,94 +2506,24 @@ mod tests { ); } - #[gpui::test] - async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { - let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await; - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Create a new file via the streaming edit file tool - let (event_stream, _rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/dir/new_file.txt".into(), - mode: EditFileMode::Write, - content: Some("Hello, World!".into()), - edits: None, - }), - event_stream, - cx, - ) - }); - let result = task.await; - assert!(result.is_ok(), "create should succeed: {:?}", result.err()); - cx.run_until_parked(); - - assert!( - fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, - "file should exist after creation" - ); - - // Reject all edits — this should delete the newly created file - let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); - assert!( - !changed.is_empty(), - "action_log should track the created file as changed" - ); - - action_log - .update(cx, |log, cx| log.reject_all_edits(None, cx)) - .await; - cx.run_until_parked(); - - assert!( - !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, - "file should be deleted after rejecting creation, but an empty file was left behind" - ); - } - #[test] fn test_input_deserializes_double_encoded_fields() { let input = serde_json::from_value::(json!({ "path": "root/file.txt", - "mode": "\"edit\"", "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" })) .expect("input should deserialize"); - assert!(matches!(input.mode, EditFileMode::Edit)); - let edits = input.edits.expect("edits should deserialize"); - assert_eq!(edits.len(), 1); - assert_eq!(edits[0].old_text, "hello\nworld"); - assert_eq!(edits[0].new_text, "HELLO\nWORLD"); - - let input = serde_json::from_value::(json!({ - "path": "root/file.txt", - "mode": "\"edit\"" - })) - .expect("input should deserialize"); - assert!(input.edits.is_none()); - - let input = serde_json::from_value::(json!({ - "path": "root/file.txt", - "mode": "\"edit\"", - "edits": null - })) - .expect("input should deserialize"); - assert!(input.edits.is_none()); + assert_eq!(input.edits.len(), 1); + assert_eq!(input.edits[0].old_text, "hello\nworld"); + assert_eq!(input.edits[0].new_text, "HELLO\nWORLD"); let input = serde_json::from_value::(json!({ "path": "root/file.txt", - "mode": "\"edit\"", "edits": "[{\"old_text\": \"hello\\nworld\", \"new_text\": \"HELLO\\nWORLD\"}]" })) .expect("input should deserialize"); - assert!(matches!(input.mode, Some(EditFileMode::Edit))); let edits = input.edits.expect("edits should deserialize"); assert_eq!(edits.len(), 1); assert_eq!(edits[0].old_text.as_deref(), Some("hello\nworld")); @@ -4139,16 +2533,13 @@ mod tests { "path": "root/file.txt" })) .expect("input should deserialize"); - assert!(input.mode.is_none()); assert!(input.edits.is_none()); let input = serde_json::from_value::(json!({ "path": "root/file.txt", - "mode": null, "edits": null })) .expect("input should deserialize"); - assert!(input.mode.is_none()); assert!(input.edits.is_none()); } @@ -4179,13 +2570,13 @@ mod tests { ) }); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let tool = Arc::new(EditFileTool::new( + let edit_tool = Arc::new(EditFileTool::new( project.clone(), thread.downgrade(), action_log.clone(), language_registry, )); - (tool, project, action_log, fs, thread) + (edit_tool, project, action_log, fs, thread) } async fn setup_test( diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs new file mode 100644 index 00000000000..7955144f8ee --- /dev/null +++ b/crates/agent/src/tools/edit_session.rs @@ -0,0 +1,1132 @@ +mod reindent; +mod streaming_fuzzy_matcher; +mod streaming_parser; + +use crate::{Thread, ToolCallEventStream}; +use acp_thread::Diff; +use action_log::ActionLog; +use agent_client_protocol::schema::{self as acp, ToolCallLocation, ToolCallUpdateFields}; +use anyhow::Result; +use collections::HashSet; +use futures::{FutureExt, channel::oneshot}; +use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; +use language::language_settings::{self, FormatOnSave}; +use language::{Buffer, BufferEvent, LanguageRegistry}; +use language_model::LanguageModelToolResultContent; +use project::lsp_store::{FormatTrigger, LspFormatTarget}; +use project::{AgentLocation, Project, ProjectPath}; +use reindent::{Reindenter, compute_indent_delta}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::ops::Range; +use std::path::PathBuf; +use std::sync::Arc; +use streaming_diff::{CharOperation, StreamingDiff}; +use streaming_fuzzy_matcher::StreamingFuzzyMatcher; +use streaming_parser::{EditEvent, StreamingParser, WriteEvent}; +use text::ToOffset; +use ui::SharedString; +use util::rel_path::RelPath; +use util::{Deferred, ResultExt}; + +/// Operating mode used internally by `EditSession`/`Pipeline` to choose between +/// applying granular edits (the `edit_file` tool) or replacing/creating the +/// entire file content (the `write_file` tool). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EditSessionMode { + Write, + Edit, +} + +/// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct Edit { + /// The exact text to find in the file. This will be matched using fuzzy matching + /// to handle minor differences in whitespace or formatting. + /// + /// Be minimal with replacements: + /// - For unique lines, include only those lines + /// - For non-unique lines, include enough context to identify them + pub old_text: String, + /// The text to replace it with + pub new_text: String, +} + +#[derive(Clone, Default, Debug, Deserialize)] +pub struct PartialEdit { + #[serde(default)] + pub old_text: Option, + #[serde(default)] + pub new_text: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum EditSessionOutput { + Success { + #[serde(alias = "original_path")] + input_path: PathBuf, + new_text: String, + old_text: Arc, + #[serde(default)] + diff: String, + }, + Error { + error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + input_path: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + diff: String, + }, +} + +impl EditSessionOutput { + pub fn error(error: impl Into) -> Self { + Self::Error { + error: error.into(), + input_path: None, + diff: String::new(), + } + } +} + +impl std::fmt::Display for EditSessionOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditSessionOutput::Success { + diff, input_path, .. + } => { + if diff.is_empty() { + write!(f, "No edits were made.") + } else { + write!( + f, + "Edited {}:\n\n```diff\n{diff}\n```", + input_path.display() + ) + } + } + EditSessionOutput::Error { + error, + diff, + input_path, + } => { + write!(f, "{error}\n")?; + if let Some(input_path) = input_path + && !diff.is_empty() + { + write!( + f, + "Edited {}:\n\n```diff\n{diff}\n```", + input_path.display() + ) + } else { + write!(f, "No edits were made.") + } + } + } + } +} + +impl From for LanguageModelToolResultContent { + fn from(output: EditSessionOutput) -> Self { + output.to_string().into() + } +} + +pub(crate) struct EditSessionContext { + project: Entity, + thread: WeakEntity, + action_log: Entity, + language_registry: Arc, +} + +impl EditSessionContext { + pub(crate) fn new( + project: Entity, + thread: WeakEntity, + action_log: Entity, + language_registry: Arc, + ) -> Self { + Self { + project, + thread, + action_log, + language_registry, + } + } + + pub(crate) fn authorize( + &self, + tool_name: &str, + path: &PathBuf, + event_stream: &ToolCallEventStream, + cx: &mut App, + ) -> Task> { + super::tool_permissions::authorize_file_edit( + tool_name, + path, + &self.thread, + event_stream, + cx, + ) + } + + fn set_agent_location(&self, buffer: WeakEntity, position: text::Anchor, cx: &mut App) { + let should_update_agent_location = self + .thread + .read_with(cx, |thread, _cx| !thread.is_subagent()) + .unwrap_or_default(); + if should_update_agent_location { + self.project.update(cx, |project, cx| { + project.set_agent_location(Some(AgentLocation { buffer, position }), cx); + }); + } + } + + async fn ensure_buffer_saved(&self, buffer: &Entity, cx: &mut AsyncApp) { + let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { + let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); + settings.format_on_save != FormatOnSave::Off + }); + + if format_on_save_enabled { + self.project + .update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, + FormatTrigger::Save, + cx, + ) + }) + .await + .log_err(); + } + + self.project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .log_err(); + + self.action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + } + + pub(crate) fn initial_title_from_path( + &self, + path: &std::path::Path, + default: &str, + cx: &App, + ) -> SharedString { + let project = self.project.read(cx); + if let Some(project_path) = project.find_project_path(path, cx) + && let Some(short) = project.short_full_path_for_project_path(&project_path, cx) + { + return short.into(); + } + + let display = path.to_string_lossy(); + if display.is_empty() { + default.into() + } else { + display.into_owned().into() + } + } + + pub(crate) fn replay_output( + &self, + output: EditSessionOutput, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Result<()> { + match output { + EditSessionOutput::Success { + input_path, + old_text, + new_text, + .. + } => { + event_stream.update_diff(cx.new(|cx| { + Diff::finalized( + input_path.to_string_lossy().into_owned(), + Some(old_text.to_string()), + new_text, + self.language_registry.clone(), + cx, + ) + })); + Ok(()) + } + EditSessionOutput::Error { .. } => Ok(()), + } + } +} + +pub(crate) enum EditSessionResult { + Completed(EditSession), + Failed { + error: String, + session: Option, + }, +} + +pub(crate) async fn run_session( + result: EditSessionResult, + cx: &mut AsyncApp, +) -> Result { + match result { + EditSessionResult::Completed(session) => { + session + .context + .ensure_buffer_saved(&session.buffer, cx) + .await; + let (new_text, diff) = session.compute_new_text_and_diff(cx).await; + Ok(EditSessionOutput::Success { + old_text: session.old_text.clone(), + new_text, + input_path: session.input_path, + diff, + }) + } + EditSessionResult::Failed { + error, + session: Some(session), + } => { + session + .context + .ensure_buffer_saved(&session.buffer, cx) + .await; + let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; + Err(EditSessionOutput::Error { + error, + input_path: Some(session.input_path), + diff, + }) + } + EditSessionResult::Failed { + error, + session: None, + } => Err(EditSessionOutput::Error { + error, + input_path: None, + diff: String::new(), + }), + } +} + +pub(crate) fn initial_title_from_partial_path

( + context: &EditSessionContext, + raw_input: serde_json::Value, + extract_path: impl FnOnce(&P) -> Option, + default: &str, + cx: &App, +) -> SharedString +where + P: DeserializeOwned, +{ + if let Ok(partial) = serde_json::from_value::

(raw_input) + && let Some(raw_path) = extract_path(&partial) + { + let trimmed = raw_path.trim(); + if !trimmed.is_empty() { + return context.initial_title_from_path(std::path::Path::new(trimmed), default, cx); + } + } + default.into() +} + +pub(crate) struct EditSession { + abs_path: PathBuf, + pub(crate) input_path: PathBuf, + pub(crate) buffer: Entity, + pub(crate) old_text: Arc, + diff: Entity, + parser: StreamingParser, + pipeline: Pipeline, + context: Arc, + _finalize_diff_guard: Deferred>, +} + +enum Pipeline { + Write(WritePipeline), + Edit(EditPipeline), +} + +struct WritePipeline { + content_written: bool, +} + +struct EditPipeline { + current_edit: Option, + file_changed_since_last_read: bool, +} + +enum EditPipelineEntry { + ResolvingOldText { + matcher: StreamingFuzzyMatcher, + }, + StreamingNewText { + streaming_diff: StreamingDiff, + edit_cursor: usize, + reindenter: Reindenter, + original_snapshot: text::BufferSnapshot, + }, +} + +impl Pipeline { + fn new(mode: EditSessionMode, file_changed_since_last_read: bool) -> Self { + match mode { + EditSessionMode::Write => Self::Write(WritePipeline { + content_written: false, + }), + EditSessionMode::Edit => Self::Edit(EditPipeline { + current_edit: None, + file_changed_since_last_read, + }), + } + } +} + +impl WritePipeline { + fn process_event( + &mut self, + event: &WriteEvent, + buffer: &Entity, + context: &EditSessionContext, + cx: &mut AsyncApp, + ) { + let WriteEvent::ContentChunk { chunk } = event; + + let (buffer_id, buffer_len) = + buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); + let edit_range = if self.content_written { + buffer_len..buffer_len + } else { + 0..buffer_len + }; + + agent_edit_buffer( + buffer, + [(edit_range, chunk.as_str())], + &context.action_log, + cx, + ); + cx.update(|cx| { + context.set_agent_location( + buffer.downgrade(), + text::Anchor::max_for_buffer(buffer_id), + cx, + ); + }); + self.content_written = true; + } +} + +impl EditPipeline { + fn ensure_resolving_old_text(&mut self, buffer: &Entity, cx: &mut AsyncApp) { + if self.current_edit.is_none() { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); + self.current_edit = Some(EditPipelineEntry::ResolvingOldText { + matcher: StreamingFuzzyMatcher::new(snapshot), + }); + } + } + + fn process_event( + &mut self, + event: &EditEvent, + buffer: &Entity, + diff: &Entity, + abs_path: &PathBuf, + context: &EditSessionContext, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + match event { + EditEvent::OldTextChunk { + chunk, done: false, .. + } => { + log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); + self.ensure_resolving_old_text(buffer, cx); + + if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = + &mut self.current_edit + && !chunk.is_empty() + { + if let Some(match_range) = matcher.push(chunk, None) { + let anchor_range = buffer.read_with(cx, |buffer, _cx| { + buffer.anchor_range_outside(match_range.clone()) + }); + diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); + + cx.update(|cx| { + let position = buffer.read(cx).anchor_before(match_range.end); + context.set_agent_location(buffer.downgrade(), position, cx); + }); + } + } + } + EditEvent::OldTextChunk { + edit_index, + chunk, + done: true, + } => { + log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); + + self.ensure_resolving_old_text(buffer, cx); + + let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit + else { + return Ok(()); + }; + + if !chunk.is_empty() { + matcher.push(chunk, None); + } + let range = extract_match( + matcher.finish(), + buffer, + edit_index, + self.file_changed_since_last_read, + cx, + )?; + + let anchor_range = + buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone())); + diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + + let line = snapshot.offset_to_point(range.start).row; + event_stream.update_fields( + ToolCallUpdateFields::new() + .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), + ); + + let buffer_indent = snapshot.line_indent_for_row(line); + let query_indent = text::LineIndent::from_iter( + matcher + .query_lines() + .first() + .map(|s| s.as_str()) + .unwrap_or("") + .chars(), + ); + let indent_delta = compute_indent_delta(buffer_indent, query_indent); + + let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::(); + + log::debug!( + "edit[{}] old_text matched at {}..{}: {:?}", + edit_index, + range.start, + range.end, + old_text_in_buffer, + ); + + let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); + self.current_edit = Some(EditPipelineEntry::StreamingNewText { + streaming_diff: StreamingDiff::new(old_text_in_buffer), + edit_cursor: range.start, + reindenter: Reindenter::new(indent_delta), + original_snapshot: text_snapshot, + }); + + cx.update(|cx| { + let position = buffer.read(cx).anchor_before(range.end); + context.set_agent_location(buffer.downgrade(), position, cx); + }); + } + EditEvent::NewTextChunk { + chunk, done: false, .. + } => { + log::debug!("new_text_chunk: done=false, chunk='{}'", chunk); + + let Some(EditPipelineEntry::StreamingNewText { + streaming_diff, + edit_cursor, + reindenter, + original_snapshot, + .. + }) = &mut self.current_edit + else { + return Ok(()); + }; + + let reindented = reindenter.push(chunk); + if reindented.is_empty() { + return Ok(()); + } + + let char_ops = streaming_diff.push_new(&reindented); + apply_char_operations( + &char_ops, + buffer, + original_snapshot, + edit_cursor, + &context.action_log, + cx, + ); + + let position = original_snapshot.anchor_before(*edit_cursor); + cx.update(|cx| { + context.set_agent_location(buffer.downgrade(), position, cx); + }); + } + EditEvent::NewTextChunk { + chunk, done: true, .. + } => { + log::debug!("new_text_chunk: done=true, chunk='{}'", chunk); + + let Some(EditPipelineEntry::StreamingNewText { + mut streaming_diff, + mut edit_cursor, + mut reindenter, + original_snapshot, + }) = self.current_edit.take() + else { + return Ok(()); + }; + + let mut final_text = reindenter.push(chunk); + final_text.push_str(&reindenter.finish()); + + log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); + + if !final_text.is_empty() { + let char_ops = streaming_diff.push_new(&final_text); + apply_char_operations( + &char_ops, + buffer, + &original_snapshot, + &mut edit_cursor, + &context.action_log, + cx, + ); + } + + let remaining_ops = streaming_diff.finish(); + apply_char_operations( + &remaining_ops, + buffer, + &original_snapshot, + &mut edit_cursor, + &context.action_log, + cx, + ); + + let position = original_snapshot.anchor_before(edit_cursor); + cx.update(|cx| { + context.set_agent_location(buffer.downgrade(), position, cx); + }); + } + } + Ok(()) + } +} + +impl EditSession { + pub(crate) async fn new( + path: PathBuf, + mode: EditSessionMode, + tool_name: &str, + context: Arc, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result { + let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?; + + let Some(abs_path) = + cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx)) + else { + return Err(format!( + "Worktree at '{}' does not exist", + path.to_string_lossy() + )); + }; + + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), + ); + + cx.update(|cx| context.authorize(tool_name, &path, event_stream, cx)) + .await + .map_err(|e| e.to_string())?; + + let buffer = context + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .map_err(|e| e.to_string())?; + + let file_changed_since_last_read = + ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?; + + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); + event_stream.update_diff(diff.clone()); + let finalize_diff_guard = util::defer(Box::new({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }) as Box); + + context.action_log.update(cx, |log, cx| match mode { + EditSessionMode::Write => log.buffer_created(buffer.clone(), cx), + EditSessionMode::Edit => log.buffer_read(buffer.clone(), cx), + }); + + let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let old_text = cx + .background_spawn({ + let old_snapshot = old_snapshot.clone(); + async move { Arc::new(old_snapshot.text()) } + }) + .await; + + Ok(Self { + abs_path, + input_path: path, + buffer, + old_text, + diff, + parser: StreamingParser::default(), + pipeline: Pipeline::new(mode, file_changed_since_last_read), + context, + _finalize_diff_guard: finalize_diff_guard, + }) + } + + pub(crate) async fn finalize_edit( + &mut self, + edits: Vec, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + let Self { + abs_path, + buffer, + diff, + parser, + pipeline, + context, + .. + } = self; + let Pipeline::Edit(edit_pipeline) = pipeline else { + return Err("Cannot finalize edits on a write session".to_string()); + }; + + for event in &parser.finalize_edits(&edits) { + edit_pipeline.process_event( + event, + buffer, + diff, + abs_path, + context, + event_stream, + cx, + )?; + } + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Got edits:"); + for edit in &edits { + log::debug!( + " old_text: '{}', new_text: '{}'", + edit.old_text.replace('\n', "\\n"), + edit.new_text.replace('\n', "\\n") + ); + } + } + Ok(()) + } + + pub(crate) async fn finalize_write( + &mut self, + content: &str, + cx: &mut AsyncApp, + ) -> Result<(), String> { + let Self { + buffer, + parser, + pipeline, + context, + .. + } = self; + let Pipeline::Write(write) = pipeline else { + return Err("Cannot finalize a write on an edit session".to_string()); + }; + + for event in &parser.finalize_content(content) { + write.process_event(event, buffer, context, cx); + } + Ok(()) + } + + async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) { + let new_snapshot = self.buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let (new_text, unified_diff) = cx + .background_spawn({ + let new_snapshot = new_snapshot.clone(); + let old_text = self.old_text.clone(); + async move { + let new_text = new_snapshot.text(); + let diff = language::unified_diff(&old_text, &new_text); + (new_text, diff) + } + }) + .await; + (new_text, unified_diff) + } + + pub(crate) fn process_edit( + &mut self, + edits: Option<&[PartialEdit]>, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), String> { + let Self { + abs_path, + buffer, + diff, + parser, + pipeline, + context, + .. + } = self; + let Pipeline::Edit(edit_pipeline) = pipeline else { + return Err("Cannot apply partial edits on a write session".to_string()); + }; + let Some(edits) = edits else { + return Ok(()); + }; + for event in &parser.push_edits(edits) { + edit_pipeline.process_event( + event, + buffer, + diff, + abs_path, + context, + event_stream, + cx, + )?; + } + Ok(()) + } + + pub(crate) fn process_write( + &mut self, + content: Option<&str>, + cx: &mut AsyncApp, + ) -> Result<(), String> { + let Self { + buffer, + parser, + pipeline, + context, + .. + } = self; + let Pipeline::Write(write) = pipeline else { + return Err("Cannot apply partial content on an edit session".to_string()); + }; + let Some(content) = content else { + return Ok(()); + }; + for event in &parser.push_content(content) { + write.process_event(event, buffer, context, cx); + } + Ok(()) + } +} + +fn apply_char_operations( + ops: &[CharOperation], + buffer: &Entity, + snapshot: &text::BufferSnapshot, + edit_cursor: &mut usize, + action_log: &Entity, + cx: &mut AsyncApp, +) { + for op in ops { + match op { + CharOperation::Insert { text } => { + let anchor = snapshot.anchor_after(*edit_cursor); + agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); + } + CharOperation::Delete { bytes } => { + let delete_end = *edit_cursor + bytes; + let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); + agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); + *edit_cursor = delete_end; + } + CharOperation::Keep { bytes } => { + *edit_cursor += bytes; + } + } + } +} + +fn extract_match( + matches: Vec>, + buffer: &Entity, + edit_index: &usize, + file_changed_since_last_read: bool, + cx: &mut AsyncApp, +) -> Result, String> { + let file_changed_since_last_read_message = if file_changed_since_last_read { + " The file has changed on disk since you last read it." + } else { + "" + }; + + match matches.len() { + 0 => Err(format!( + "Could not find matching text for edit at index {}. \ + The old_text did not match any content in the file.{} \ + Please read the file again to get the current content.", + edit_index, file_changed_since_last_read_message, + )), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let lines = matches + .iter() + .map(|range| (snapshot.offset_to_point(range.start).row + 1).to_string()) + .collect::>() + .join(", "); + Err(format!( + "Edit {} matched multiple locations in the file at lines: {}. \ + Please provide more context in old_text to uniquely \ + identify the location.", + edit_index, lines + )) + } + } +} + +/// Edits a buffer and reports the edit to the action log in the same effect +/// cycle. This ensures the action log's subscription handler sees the version +/// already updated by `buffer_edited`, so it does not misattribute the agent's +/// edit as a user edit. +fn agent_edit_buffer( + buffer: &Entity, + edits: I, + action_log: &Entity, + cx: &mut AsyncApp, +) where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, +{ + cx.update(|cx| { + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); +} + +async fn ensure_buffer_saved( + buffer: &Entity, + abs_path: &PathBuf, + mode: EditSessionMode, + context: &EditSessionContext, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result { + let last_read_mtime = context + .action_log + .read_with(cx, |log, _| log.file_read_time(abs_path)); + let (current_mtime, is_dirty) = buffer.read_with(cx, |buffer, _cx| { + let current = buffer.file().and_then(|file| file.disk_state().mtime()); + let dirty = buffer.is_dirty(); + (current, dirty) + }); + + if is_dirty { + resolve_dirty_buffer(buffer, mode, context, event_stream, cx).await?; + } + + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) + && current != last_read + { + return Ok(true); + } + + Ok(false) +} + +/// Prompts the user about how to handle a dirty buffer that the agent +/// wants to edit (`EditSessionMode::Edit`) or overwrite +/// (`EditSessionMode::Write`), and performs the chosen action so the +/// edit session can proceed (or returns `Err` to cancel). +/// +/// If the user resolves the dirty state externally (e.g. cmd-s or +/// reload) while the prompt is visible, the prompt is dismissed +/// automatically. +async fn resolve_dirty_buffer( + buffer: &Entity, + mode: EditSessionMode, + context: &EditSessionContext, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result<(), String> { + let (manual_resolve_tx, manual_resolve_rx) = oneshot::channel::<()>(); + let _buffer_subscription = cx.update(|cx| { + let mut tx = Some(manual_resolve_tx); + cx.subscribe(buffer, move |buffer, event: &BufferEvent, cx| { + if matches!( + event, + BufferEvent::Saved | BufferEvent::Reloaded | BufferEvent::DirtyChanged + ) && !buffer.read(cx).is_dirty() + && let Some(tx) = tx.take() + { + tx.send(()).ok(); + } + }) + }); + + let prompt_kind = match mode { + EditSessionMode::Edit => super::tool_permissions::DirtyBufferPromptKind::Edit, + EditSessionMode::Write => super::tool_permissions::DirtyBufferPromptKind::Overwrite, + }; + let prompt = cx.update(|cx| { + super::tool_permissions::authorize_dirty_buffer(prompt_kind, event_stream, cx) + }); + + let decision = futures::select_biased! { + _ = manual_resolve_rx.fuse() => { + None + } + decision = prompt.fuse() => { + Some(decision.map_err(|e| e.to_string())?) + } + }; + + let Some(decision) = decision else { + event_stream.update_fields( + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ); + return match mode { + EditSessionMode::Edit => Ok(()), + EditSessionMode::Write => Err( + "The user saved their unsaved changes while the prompt was visible; \ + the file overwrite was cancelled to preserve them. Ask the user how \ + they'd like to proceed before retrying." + .to_string(), + ), + }; + }; + + match decision { + super::tool_permissions::DirtyBufferDecision::Save => { + context + .project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .map_err(|e| format!("Failed to save buffer: {e}"))?; + } + super::tool_permissions::DirtyBufferDecision::Discard => { + context + .project + .update(cx, |project, cx| { + project.reload_buffers(HashSet::from_iter([buffer.clone()]), false, cx) + }) + .await + .map_err(|e| format!("Failed to discard unsaved changes: {e}"))?; + } + super::tool_permissions::DirtyBufferDecision::Keep => { + let error = "The user chose to keep their unsaved changes; the file overwrite \ + was cancelled. Ask the user how they'd like to proceed before \ + retrying." + .to_string(); + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]), + ); + return Err(error); + } + } + Ok(()) +} + +fn resolve_path( + mode: EditSessionMode, + path: &PathBuf, + project: &Entity, + cx: &mut App, +) -> Result { + let project = project.read(cx); + + match mode { + EditSessionMode::Edit => { + let path = project + .find_project_path(&path, cx) + .ok_or_else(|| "Can't edit file: path not found".to_string())?; + + let entry = project + .entry_for_path(&path, cx) + .ok_or_else(|| "Can't edit file: path not found".to_string())?; + + if entry.is_file() { + Ok(path) + } else { + Err("Can't edit file: path is a directory".to_string()) + } + } + EditSessionMode::Write => { + if let Some(path) = project.find_project_path(&path, cx) + && let Some(entry) = project.entry_for_path(&path, cx) + { + if entry.is_file() { + return Ok(path); + } else { + return Err("Can't write to file: path is a directory".to_string()); + } + } + + let parent_path = path + .parent() + .ok_or_else(|| "Can't create file: incorrect path".to_string())?; + + let parent_project_path = project.find_project_path(&parent_path, cx); + + let parent_entry = parent_project_path + .as_ref() + .and_then(|path| project.entry_for_path(path, cx)) + .ok_or_else(|| "Can't create file: parent directory doesn't exist")?; + + if !parent_entry.is_dir() { + return Err("Can't create file: parent is not a directory".to_string()); + } + + let file_name = path + .file_name() + .and_then(|file_name| file_name.to_str()) + .and_then(|file_name| RelPath::unix(file_name).ok()) + .ok_or_else(|| "Can't create file: invalid filename".to_string())?; + + let new_file_path = parent_project_path.map(|parent| ProjectPath { + path: parent.path.join(file_name), + ..parent + }); + + new_file_path.ok_or_else(|| "Can't create file".to_string()) + } + } +} + +#[cfg(test)] +pub(crate) async fn test_resolve_path( + mode: &EditSessionMode, + path: &str, + project: &Entity, + cx: &mut gpui::TestAppContext, +) -> Result { + cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), project, cx)) +} diff --git a/crates/agent/src/tools/edit_file_tool/reindent.rs b/crates/agent/src/tools/edit_session/reindent.rs similarity index 100% rename from crates/agent/src/tools/edit_file_tool/reindent.rs rename to crates/agent/src/tools/edit_session/reindent.rs diff --git a/crates/agent/src/tools/edit_file_tool/streaming_fuzzy_matcher.rs b/crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs similarity index 100% rename from crates/agent/src/tools/edit_file_tool/streaming_fuzzy_matcher.rs rename to crates/agent/src/tools/edit_session/streaming_fuzzy_matcher.rs diff --git a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs similarity index 83% rename from crates/agent/src/tools/edit_file_tool/streaming_parser.rs rename to crates/agent/src/tools/edit_session/streaming_parser.rs index 6a44959a141..3961edf564c 100644 --- a/crates/agent/src/tools/edit_file_tool/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -1,6 +1,6 @@ use smallvec::SmallVec; -use crate::{Edit, PartialEdit}; +use super::{Edit, PartialEdit}; /// Events emitted by `StreamingParser` for edit-mode input. #[derive(Debug, PartialEq, Eq)] @@ -33,6 +33,8 @@ struct EditStreamState { old_text_done: bool, new_text_emitted_len: usize, new_text_done: bool, + hold_until_complete: bool, + buffer_new_text_until_old_text_done: bool, } /// Converts incrementally-growing tool call JSON into a stream of chunk events. @@ -68,7 +70,15 @@ impl StreamingParser { for (index, partial) in edits.iter().enumerate() { if index >= self.edit_states.len() { // A new edit appeared — finalize the previous one if there was one. - if let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.old_text.as_deref()), + edits + .get(index.saturating_sub(1)) + .and_then(|edit| edit.new_text.as_deref()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); @@ -76,12 +86,33 @@ impl StreamingParser { let state = &mut self.edit_states[index]; + if state.old_text_emitted_len == 0 + && state.new_text_emitted_len == 0 + && !state.old_text_done + && partial.new_text.is_some() + && !state.buffer_new_text_until_old_text_done + { + if partial + .old_text + .as_ref() + .is_some_and(|old_text| !old_text.is_empty()) + { + state.hold_until_complete = true; + } else { + state.buffer_new_text_until_old_text_done = true; + } + } + + if state.hold_until_complete { + continue; + } + // Process old_text changes. if let Some(old_text) = &partial.old_text && !state.old_text_done { - if partial.new_text.is_some() { - // new_text appeared, so old_text is done — emit everything. + if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done { + // new_text appeared after old_text, so old_text is done — emit everything. let start = state.old_text_emitted_len.min(old_text.len()); let chunk = normalize_done_chunk(old_text[start..].to_string()); state.old_text_done = true; @@ -108,6 +139,7 @@ impl StreamingParser { // Process new_text changes. if let Some(new_text) = &partial.new_text + && state.old_text_done && !state.new_text_done { let safe_end = safe_emit_end_for_edit_text(new_text); @@ -157,7 +189,15 @@ impl StreamingParser { for (index, edit) in edits.iter().enumerate() { if index >= self.edit_states.len() { // This edit was never seen in partials — emit it fully. - if let Some(previous) = self.finalize_previous_edit(index) { + if let Some(previous) = self.finalize_previous_edit( + index, + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.old_text.as_str()), + edits + .get(index.saturating_sub(1)) + .map(|edit| edit.new_text.as_str()), + ) { events.extend(previous); } self.edit_states.push(EditStreamState::default()); @@ -165,6 +205,26 @@ impl StreamingParser { let state = &mut self.edit_states[index]; + if state.hold_until_complete { + state.old_text_done = true; + state.old_text_emitted_len = edit.old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = edit.new_text.len(); + state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; + events.push(EditEvent::OldTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.old_text.clone()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: index, + chunk: normalize_done_chunk(edit.new_text.clone()), + done: true, + }); + continue; + } + if !state.old_text_done { let start = state.old_text_emitted_len.min(edit.old_text.len()); let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); @@ -209,7 +269,12 @@ impl StreamingParser { /// When a new edit appears at `index`, finalize the edit at `index - 1` /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized. - fn finalize_previous_edit(&mut self, new_index: usize) -> Option> { + fn finalize_previous_edit( + &mut self, + new_index: usize, + old_text: Option<&str>, + new_text: Option<&str>, + ) -> Option> { if new_index == 0 || self.edit_states.is_empty() { return None; } @@ -222,22 +287,49 @@ impl StreamingParser { let state = &mut self.edit_states[previous_index]; let mut events = SmallVec::new(); - // If old_text was never finalized, finalize it now with an empty done chunk. - if !state.old_text_done { + if state.hold_until_complete { + let old_text = old_text.unwrap_or_default(); + let new_text = new_text.unwrap_or_default(); state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); + state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.hold_until_complete = false; + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::OldTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(old_text.to_string()), + done: true, + }); + events.push(EditEvent::NewTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(new_text.to_string()), + done: true, + }); + return Some(events); + } + + if !state.old_text_done { + let old_text = old_text.unwrap_or_default(); + let start = state.old_text_emitted_len.min(old_text.len()); + state.old_text_done = true; + state.old_text_emitted_len = old_text.len(); + events.push(EditEvent::OldTextChunk { + edit_index: previous_index, + chunk: normalize_done_chunk(old_text[start..].to_string()), done: true, }); } - // Emit a done event for new_text if not already finalized. if !state.new_text_done { + let new_text = new_text.unwrap_or_default(); + let start = state.new_text_emitted_len.min(new_text.len()); state.new_text_done = true; + state.new_text_emitted_len = new_text.len(); + state.buffer_new_text_until_old_text_done = false; events.push(EditEvent::NewTextChunk { edit_index: previous_index, - chunk: String::new(), + chunk: normalize_done_chunk(new_text[start..].to_string()), done: true, }); } @@ -279,6 +371,43 @@ fn normalize_done_chunk(mut chunk: String) -> String { mod tests { use super::*; + #[test] + fn test_first_edit_with_new_text_in_first_chunk_is_held_until_finalize() { + let mut parser = StreamingParser::default(); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old text".into()), + new_text: Some("new text".into()), + }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "old text".into(), + new_text: "new text".into(), + }]); + assert_eq!( + events.as_slice(), + &[ + EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old text".into(), + done: true, + }, + EditEvent::NewTextChunk { + edit_index: 0, + chunk: "new text".into(), + done: true, + }, + ] + ); + } + #[test] fn test_single_edit_streamed_incrementally() { let mut parser = StreamingParser::default(); @@ -393,6 +522,12 @@ mod tests { old_text: Some("before\n".into()), new_text: Some("after\n".into()), }]); + assert!(events.is_empty()); + + let events = parser.finalize_edits(&[Edit { + old_text: "before\n".into(), + new_text: "after\n".into(), + }]); assert_eq!( events.as_slice(), &[ @@ -404,23 +539,10 @@ mod tests { EditEvent::NewTextChunk { edit_index: 0, chunk: "after".into(), - done: false, + done: true, }, ] ); - - let events = parser.finalize_edits(&[Edit { - old_text: "before\n".into(), - new_text: "after\n".into(), - }]); - assert_eq!( - events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: "".into(), - done: true, - }] - ); } #[test] @@ -731,13 +853,31 @@ mod tests { } #[test] - fn test_empty_old_text_with_new_text() { + fn test_new_text_before_old_text_buffers_new_text_but_streams_old_text() { let mut parser = StreamingParser::default(); - // old_text is empty, new_text appears immediately let events = parser.push_edits(&[PartialEdit { - old_text: Some("".into()), - new_text: Some("inserted".into()), + old_text: None, + new_text: Some("new".into()), + }]); + assert!(events.is_empty()); + + let events = parser.push_edits(&[PartialEdit { + old_text: Some("old".into()), + new_text: Some("new".into()), + }]); + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "old".into(), + done: false, + }] + ); + + let events = parser.finalize_edits(&[Edit { + old_text: "old".into(), + new_text: "new".into(), }]); assert_eq!( events.as_slice(), @@ -749,8 +889,8 @@ mod tests { }, EditEvent::NewTextChunk { edit_index: 0, - chunk: "inserted".into(), - done: false, + chunk: "new".into(), + done: true, }, ] ); @@ -794,13 +934,17 @@ mod tests { }, ]); - // Should finalize edit 1 (index=1) and start edit 2 (index=2) assert_eq!( events.as_slice(), &[ + EditEvent::OldTextChunk { + edit_index: 1, + chunk: "b".into(), + done: true, + }, EditEvent::NewTextChunk { edit_index: 1, - chunk: "".into(), + chunk: "B".into(), done: true, }, EditEvent::OldTextChunk { @@ -874,50 +1018,34 @@ mod tests { ); } - #[test] - fn test_finalize_with_partially_seen_new_text() { - let mut parser = StreamingParser::default(); - - parser.push_edits(&[PartialEdit { - old_text: Some("old".into()), - new_text: Some("partial".into()), - }]); - - let events = parser.finalize_edits(&[Edit { - old_text: "old".into(), - new_text: "partial new text".into(), - }]); - assert_eq!( - events.as_slice(), - &[EditEvent::NewTextChunk { - edit_index: 0, - chunk: " new text".into(), - done: true, - }] - ); - } - #[test] fn test_repeated_pushes_with_no_change() { let mut parser = StreamingParser::default(); let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); - assert_eq!(events.len(), 2); // old done + new chunk + assert_eq!( + events.as_slice(), + &[EditEvent::OldTextChunk { + edit_index: 0, + chunk: "stable".into(), + done: false, + }] + ); // Push the exact same data again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); // And again let events = parser.push_edits(&[PartialEdit { old_text: Some("stable".into()), - new_text: Some("also stable".into()), + new_text: None, }]); assert!(events.is_empty()); } diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs index b5d9f47ea5d..ac11ffe74a0 100644 --- a/crates/agent/src/tools/evals.rs +++ b/crates/agent/src/tools/evals.rs @@ -1,2 +1,52 @@ +#[cfg(all(test, feature = "unit-eval"))] +use futures::future::LocalBoxFuture; +#[cfg(all(test, feature = "unit-eval"))] +use gpui::TestAppContext; +#[cfg(all(test, feature = "unit-eval"))] +use std::fmt::Display; + #[cfg(all(test, feature = "unit-eval"))] mod edit_file; +#[cfg(all(test, feature = "unit-eval"))] +mod terminal_tool; +#[cfg(all(test, feature = "unit-eval"))] +mod write_file; + +#[cfg(all(test, feature = "unit-eval"))] +fn run_gpui_eval( + eval: impl for<'a> FnOnce(&'a mut TestAppContext) -> LocalBoxFuture<'a, anyhow::Result>, + outcome: impl FnOnce(&T) -> eval_utils::OutcomeKind, +) -> eval_utils::EvalOutput<()> +where + T: Display, +{ + let dispatcher = gpui::TestDispatcher::new(rand::random()); + let mut cx = TestAppContext::build(dispatcher.clone(), None); + let entity_refcounts = cx.app.borrow().ref_counts_drop_handle(); + let foreground_executor = cx.foreground_executor().clone(); + let result = foreground_executor.block_test(eval(&mut cx)); + + cx.run_until_parked(); + cx.update(|cx| { + cx.background_executor().forbid_parking(); + cx.quit(); + }); + cx.run_until_parked(); + drop(cx); + dispatcher.drain_tasks(); + drop(dispatcher); + drop(entity_refcounts); + + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: outcome(&output), + metadata: (), + }, + Err(err) => eval_utils::EvalOutput { + data: format!("{err:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: (), + }, + } +} diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index cce9f41c6ef..79c5a7c2689 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -1,8 +1,7 @@ use crate::tools::edit_file_tool::*; use crate::{ - AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool, - ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, Template, Templates, Thread, - ToolCallEventStream, ToolInput, + AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ReadFileTool, + ReadFileToolInput, Template, Templates, Thread, ToolCallEventStream, ToolInput, }; use Role::*; use anyhow::{Context as _, Result}; @@ -124,20 +123,6 @@ impl EvalAssertion { EvalAssertion(Arc::new(f)) } - fn assert_eq(expected: impl Into) -> Self { - let expected = expected.into(); - Self::new(async move |sample, _judge, _cx| { - Ok(EvalAssertionOutcome { - score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) { - 100 - } else { - 0 - }, - message: None, - }) - }) - } - fn assert_diff_any(expected_diffs: Vec>) -> Self { let expected_diffs: Vec = expected_diffs.into_iter().map(Into::into).collect(); Self::new(async move |sample, _judge, _cx| { @@ -562,33 +547,25 @@ impl EditToolTest { } fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(rand::random()); - let mut cx = TestAppContext::build(dispatcher, None); - let foreground_executor = cx.foreground_executor().clone(); - let result = foreground_executor.block_test(async { - let test = EditToolTest::new(&mut cx).await; - let result = test.eval(eval, &mut cx).await; - drop(test); - cx.run_until_parked(); - result - }); - cx.quit(); - match result { - Ok(output) => eval_utils::EvalOutput { - data: output.to_string(), - outcome: if output.assertion.score < 80 { + super::run_gpui_eval( + |cx| { + async move { + let test = EditToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() + }, + |output| { + if output.assertion.score < 80 { eval_utils::OutcomeKind::Failed } else { eval_utils::OutcomeKind::Passed - }, - metadata: (), + } }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), - }, - } + ) } fn message( @@ -1499,46 +1476,3 @@ fn eval_add_overwrite_test() { )) }); } - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_create_empty_file() { - let input_file_path = "root/TODO3"; - let input_file_content = None; - let expected_output_content = String::new(); - - eval_utils::eval(100, 0.99, eval_utils::NoProcessor, move || { - run_eval(EvalInput::new( - vec![ - message(User, [text("Create a second empty todo file ")]), - message( - Assistant, - [ - text(indoc::formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), - tool_use( - "toolu_01GAF8TtsgpjKxCr8fgQLDgR", - ListDirectoryTool::NAME, - ListDirectoryToolInput { - path: "root".to_string(), - }, - ), - ], - ), - message( - User, - [tool_result( - "toolu_01GAF8TtsgpjKxCr8fgQLDgR", - ListDirectoryTool::NAME, - "root/TODO\nroot/TODO2\nroot/new.txt\n", - )], - ), - ], - input_file_path, - input_file_content.clone(), - EvalAssertion::assert_eq(expected_output_content.clone()), - )) - }); -} diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs new file mode 100644 index 00000000000..92ebd61d162 --- /dev/null +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -0,0 +1,520 @@ +use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput}; +use Role::*; +use anyhow::{Context as _, Result}; +use client::{Client, RefreshLlmTokenListener, UserStore}; +use futures::{FutureExt as _, StreamExt}; +use gpui::{AppContext as _, AsyncApp, TestAppContext}; +use http_client::StatusCode; +use language_model::{ + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, + SelectedModel, +}; +use prompt_store::{ProjectContext, WorktreeContext}; +use rand::prelude::*; +use reqwest_client::ReqwestClient; +use settings::SettingsStore; +use std::{ + fmt::{self, Display}, + path::Path, + str::FromStr, + sync::Arc, + time::Duration, +}; + +#[derive(Clone)] +struct EvalInput { + conversation: Vec, + assertion: CommandAssertion, +} + +impl EvalInput { + fn new(conversation: Vec, assertion: CommandAssertion) -> Self { + Self { + conversation, + assertion, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +struct EvalAssertionOutcome { + score: usize, + message: Option, +} + +type AssertionFn = Arc EvalAssertionOutcome + Send + Sync + 'static>; + +#[derive(Clone)] +struct CommandAssertion { + description: &'static str, + check: AssertionFn, +} + +impl CommandAssertion { + fn new( + description: &'static str, + check: impl Fn(&TerminalToolInput) -> EvalAssertionOutcome + Send + Sync + 'static, + ) -> Self { + Self { + description, + check: Arc::new(check), + } + } + + /// Passes when the command is a git command and every git subcommand that + /// could block on a pty (pager or editor) is guarded with the appropriate + /// environment variable or flag. + /// + /// This is intentionally permissive about *which* git subcommand the model + /// chooses — for an indirect prompt like "combine my last 3 commits", the + /// model is free to first investigate with `git log` or jump straight to + /// `git rebase -i`. Either is fine, as long as whatever it picks won't + /// hang on a pager or editor. + fn git_pty_safe(description: &'static str) -> Self { + Self::new(description, |input| { + let cmd = input.command.as_str(); + let words: Vec<&str> = cmd.split_whitespace().collect(); + + if !words.contains(&"git") { + return EvalAssertionOutcome { + score: 0, + message: Some(format!("Expected a `git` command, got: {cmd}")), + }; + } + + // Subcommands that pipe their output through a pager by default, + // and so will hang on `less` unless one of these escape hatches is + // present somewhere in the command: + const PAGER_SUBCMDS: &[&str] = &["log", "diff", "show", "blame"]; + const PAGER_GUARDS: &[&str] = &["--no-pager", "GIT_PAGER=cat", "PAGER=cat"]; + + // Subcommands that may invoke an interactive editor and so will + // hang unless one of these escape hatches is present: + const EDITOR_SUBCMDS: &[&str] = &["rebase", "commit", "merge", "tag"]; + const EDITOR_GUARDS: &[&str] = + &["GIT_EDITOR=true", "GIT_EDITOR=:", "EDITOR=true", "EDITOR=:"]; + + let has_pager_guard = PAGER_GUARDS.iter().any(|guard| cmd.contains(guard)); + let has_editor_guard = EDITOR_GUARDS.iter().any(|guard| cmd.contains(guard)); + + for subcmd in PAGER_SUBCMDS { + if words.contains(subcmd) && !has_pager_guard { + return EvalAssertionOutcome { + score: 0, + message: Some(format!( + "`git {subcmd}` is missing a pager guard \ + (one of {PAGER_GUARDS:?}). Command: {cmd}" + )), + }; + } + } + + for subcmd in EDITOR_SUBCMDS { + if words.contains(subcmd) && !has_editor_guard { + return EvalAssertionOutcome { + score: 0, + message: Some(format!( + "`git {subcmd}` is missing an editor guard \ + (one of {EDITOR_GUARDS:?}). Command: {cmd}" + )), + }; + } + } + + EvalAssertionOutcome { + score: 100, + message: None, + } + }) + } +} + +struct EvalOutput { + tool_input: TerminalToolInput, + assertion: EvalAssertionOutcome, + assertion_description: &'static str, +} + +impl Display for EvalOutput { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "Score: {}", self.assertion.score)?; + writeln!(f, "Assertion: {}", self.assertion_description)?; + if let Some(message) = self.assertion.message.as_ref() { + writeln!(f, "Message: {}", message)?; + } + writeln!(f, "Tool input: {:#?}", self.tool_input)?; + Ok(()) + } +} + +struct TerminalToolTest { + model: Arc, + model_thinking_effort: Option, +} + +impl TerminalToolTest { + async fn new(cx: &mut TestAppContext) -> Self { + cx.executor().allow_parking(); + + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + + gpui_tokio::init(cx); + let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap()); + cx.set_http_client(http_client); + let client = Client::production(cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); + language_models::init(user_store, client, cx); + }); + + let agent_model = SelectedModel::from_str( + &std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-6-latest".into()), + ) + .unwrap(); + + let authenticate_provider_tasks = cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }) + }); + + let model = cx + .update(|cx| { + cx.spawn(async move |cx| { + futures::future::join_all(authenticate_provider_tasks).await; + load_model(&agent_model, cx).await.unwrap() + }) + }) + .await; + + let model_thinking_effort = model + .default_effort_level() + .map(|effort_level| effort_level.value.to_string()); + + Self { + model, + model_thinking_effort, + } + } + + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + eval.conversation + .last_mut() + .context("Conversation must not be empty")? + .cache = true; + + let tools = crate::built_in_tools().collect::>(); + + let system_prompt = { + let worktrees = vec![WorktreeContext { + root_name: "root".to_string(), + abs_path: Path::new("/path/to/root").into(), + rules_file: None, + }]; + let project_context = ProjectContext::new(worktrees, Vec::default()); + let tool_names = tools + .iter() + .map(|tool| tool.name.clone().into()) + .collect::>(); + let template = crate::SystemPromptTemplate { + project: &project_context, + available_tools: tool_names, + model_name: None, + }; + template.render(&Templates::new())? + }; + + let has_system_prompt = eval + .conversation + .first() + .is_some_and(|msg| msg.role == Role::System); + let messages = if has_system_prompt { + eval.conversation + } else { + [LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text(system_prompt)], + cache: true, + reasoning_details: None, + }] + .into_iter() + .chain(eval.conversation) + .collect::>() + }; + + let request = LanguageModelRequest { + messages, + tools, + thinking_allowed: true, + thinking_effort: self.model_thinking_effort.clone(), + ..Default::default() + }; + + let tool_input = + retry_on_rate_limit(async || extract_tool_use(&self.model, request.clone(), cx).await) + .await?; + + let assertion = (eval.assertion.check)(&tool_input); + Ok(EvalOutput { + tool_input, + assertion, + assertion_description: eval.assertion.description, + }) + } +} + +async fn load_model( + selected_model: &SelectedModel, + cx: &mut AsyncApp, +) -> Result> { + cx.update(|cx| { + let registry = LanguageModelRegistry::read_global(cx); + let provider = registry + .provider(&selected_model.provider) + .expect("Provider not found"); + provider.authenticate(cx) + }) + .await?; + Ok(cx.update(|cx| { + let models = LanguageModelRegistry::read_global(cx); + models + .available_models(cx) + .find(|model| { + model.provider_id() == selected_model.provider && model.id() == selected_model.model + }) + .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)) + })) +} + +/// Stream the model completion and extract the first complete tool use whose +/// name matches `TerminalTool::NAME`, parsed as `TerminalToolInput`. +async fn extract_tool_use( + model: &Arc, + request: LanguageModelRequest, + cx: &mut TestAppContext, +) -> Result { + let model = model.clone(); + let events = cx + .update(|cx| { + let async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { model.stream_completion(request, &async_cx).await }) + }) + .await + .map_err(|err| anyhow::anyhow!("completion error: {}", err))?; + + let mut streamed_text = String::new(); + let mut stop_reason = None; + let mut parse_errors = Vec::new(); + + let mut events = events.fuse(); + while let Some(event) = events.next().await { + match event { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) + if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME => + { + let input: TerminalToolInput = serde_json::from_value(tool_use.input) + .context("Failed to parse tool input as TerminalToolInput")?; + return Ok(input); + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + if streamed_text.len() < 2_000 { + streamed_text.push_str(&text); + } + } + Ok(LanguageModelCompletionEvent::Stop(reason)) => { + stop_reason = Some(reason); + } + Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + tool_name, + raw_input, + json_parse_error, + .. + }) if tool_name.as_ref() == TerminalTool::NAME => { + parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}")); + } + Err(err) => { + return Err(anyhow::anyhow!("completion error: {}", err)); + } + _ => {} + } + } + + let streamed_text = streamed_text.trim(); + let streamed_text_suffix = if streamed_text.is_empty() { + String::new() + } else { + format!("\nStreamed text:\n{streamed_text}") + }; + let stop_reason_suffix = stop_reason + .map(|reason| format!("\nStop reason: {reason:?}")) + .unwrap_or_default(); + let parse_errors_suffix = if parse_errors.is_empty() { + String::new() + } else { + format!("\nTool parse errors:\n{}", parse_errors.join("\n")) + }; + + anyhow::bail!( + "Stream ended without a terminal tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}" + ) +} + +async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> Result { + const MAX_RETRIES: usize = 20; + let mut attempt = 0; + + loop { + attempt += 1; + let response = request().await; + + if attempt >= MAX_RETRIES { + return response; + } + + let retry_delay = match &response { + Ok(_) => None, + Err(err) => match err.downcast_ref::() { + Some(err) => match &err { + LanguageModelCompletionError::RateLimitExceeded { retry_after, .. } + | LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } + LanguageModelCompletionError::UpstreamProviderError { + status, + retry_after, + .. + } => { + let should_retry = matches!( + *status, + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE + ) || status.as_u16() == 529; + + if should_retry { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } else { + None + } + } + LanguageModelCompletionError::ApiReadResponseError { .. } + | LanguageModelCompletionError::ApiInternalServerError { .. } + | LanguageModelCompletionError::HttpSend { .. } => { + Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30))) + } + _ => None, + }, + _ => None, + }, + }; + + if let Some(retry_after) = retry_delay { + let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0)); + eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}"); + #[allow(clippy::disallowed_methods)] + async_io::Timer::after(retry_after + jitter).await; + } else { + return response; + } + } +} + +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { + super::run_gpui_eval( + |cx| { + async move { + let test = TerminalToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() + }, + |output| { + if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + } + }, + ) +} + +fn message( + role: Role, + contents: impl IntoIterator, +) -> LanguageModelRequestMessage { + LanguageModelRequestMessage { + role, + content: contents.into_iter().collect(), + cache: false, + reasoning_details: None, + } +} + +fn text(text: impl Into) -> MessageContent { + MessageContent::Text(text.into()) +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_git_log_uses_no_pager() { + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![message( + User, + [text(indoc::indoc! {" + Use the terminal tool to show me the most recent 3 commits + on the current branch (subject lines only is fine). + "})], + )], + CommandAssertion::git_pty_safe( + "`git log`-style prompt produces a pty-safe git command", + ), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_git_rebase_sets_git_editor() { + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![message( + User, + [text(indoc::indoc! {" + Use the terminal tool to rebase the current branch onto + `origin/main`. + "})], + )], + CommandAssertion::git_pty_safe("`git rebase` prompt produces a pty-safe git command"), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_git_rebase_implied_sets_git_editor() { + eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![message( + User, + [text(indoc::indoc! {" + My branch has 3 small commits that I'd like to combine + into a single clean commit before merging. Help me do + that with the terminal tool. + "})], + )], + CommandAssertion::git_pty_safe("indirect prompt produces a pty-safe git command"), + )) + }); +} diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs new file mode 100644 index 00000000000..60eda3ab3e6 --- /dev/null +++ b/crates/agent/src/tools/evals/write_file.rs @@ -0,0 +1,551 @@ +use crate::{ + AgentTool, ContextServerRegistry, ListDirectoryTool, ListDirectoryToolInput, Template, + Templates, Thread, ToolCallEventStream, ToolInput, WriteFileTool, WriteFileToolInput, +}; +use Role::*; +use anyhow::{Context as _, Result}; +use client::{Client, RefreshLlmTokenListener, UserStore}; +use fs::FakeFs; +use futures::{FutureExt as _, StreamExt}; +use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; +use http_client::StatusCode; +use language::language_settings::FormatOnSave; +use language_model::{ + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, Role, SelectedModel, +}; +use project::Project; +use prompt_store::{ProjectContext, WorktreeContext}; +use rand::prelude::*; +use reqwest_client::ReqwestClient; +use serde::Serialize; +use settings::SettingsStore; +use std::{ + fmt::{self, Display}, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, + time::Duration, +}; +use util::path; + +#[derive(Clone)] +struct EvalInput { + conversation: Vec, + input_file_path: PathBuf, + input_content: Option, + expected_output_content: String, +} + +impl EvalInput { + fn new( + conversation: Vec, + input_file_path: impl Into, + input_content: Option, + expected_output_content: String, + ) -> Self { + Self { + conversation, + input_file_path: input_file_path.into(), + input_content, + expected_output_content, + } + } +} + +struct WriteEvalOutput { + tool_input: WriteFileToolInput, + text_after: String, +} + +impl Display for WriteEvalOutput { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "Tool Input:\n{:#?}", self.tool_input)?; + writeln!(f, "Text After:\n{}", self.text_after)?; + Ok(()) + } +} + +struct WriteToolTest { + fs: Arc, + project: Entity, + model: Arc, + model_thinking_effort: Option, +} + +impl WriteToolTest { + async fn new(cx: &mut TestAppContext) -> Self { + cx.executor().allow_parking(); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + settings.project.all_languages.defaults.format_on_save = + Some(FormatOnSave::Off); + }); + }); + + gpui_tokio::init(cx); + let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap()); + cx.set_http_client(http_client); + let client = Client::production(cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(cx); + RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx); + language_models::init(user_store, client, cx); + }); + + fs.insert_tree("/root", serde_json::json!({})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let agent_model = SelectedModel::from_str( + &std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-6-latest".into()), + ) + .unwrap(); + + let authenticate_provider_tasks = cx.update(|cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry + .providers() + .iter() + .map(|p| p.authenticate(cx)) + .collect::>() + }) + }); + let model = cx + .update(|cx| { + cx.spawn(async move |cx| { + futures::future::join_all(authenticate_provider_tasks).await; + Self::load_model(&agent_model, cx).await.unwrap() + }) + }) + .await; + + let model_thinking_effort = model + .default_effort_level() + .map(|effort_level| effort_level.value.to_string()); + + Self { + fs, + project, + model, + model_thinking_effort, + } + } + + async fn load_model( + selected_model: &SelectedModel, + cx: &mut AsyncApp, + ) -> Result> { + cx.update(|cx| { + let registry = LanguageModelRegistry::read_global(cx); + let provider = registry + .provider(&selected_model.provider) + .expect("Provider not found"); + provider.authenticate(cx) + }) + .await?; + Ok(cx.update(|cx| { + let models = LanguageModelRegistry::read_global(cx); + models + .available_models(cx) + .find(|model| { + model.provider_id() == selected_model.provider + && model.id() == selected_model.model + }) + .unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0)) + })) + } + + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + eval.conversation + .last_mut() + .context("Conversation must not be empty")? + .cache = true; + + if let Some(input_content) = eval.input_content.as_deref() { + let abs_path = Path::new("/root").join( + eval.input_file_path + .strip_prefix("root") + .unwrap_or(&eval.input_file_path), + ); + self.fs.insert_file(&abs_path, input_content.into()).await; + cx.run_until_parked(); + } + + let tools = crate::built_in_tools().collect::>(); + + let system_prompt = { + let worktrees = vec![WorktreeContext { + root_name: "root".to_string(), + abs_path: Path::new("/path/to/root").into(), + rules_file: None, + }]; + let project_context = ProjectContext::new(worktrees, Vec::default()); + let tool_names = tools + .iter() + .map(|tool| tool.name.clone().into()) + .collect::>(); + let template = crate::SystemPromptTemplate { + project: &project_context, + available_tools: tool_names, + model_name: None, + }; + let templates = Templates::new(); + template.render(&templates)? + }; + + let messages = [LanguageModelRequestMessage { + role: Role::System, + content: vec![MessageContent::Text(system_prompt)], + cache: true, + reasoning_details: None, + }] + .into_iter() + .chain(eval.conversation) + .collect::>(); + + let request = LanguageModelRequest { + messages, + tools, + thinking_allowed: true, + thinking_effort: self.model_thinking_effort.clone(), + ..Default::default() + }; + + let tool_input = + retry_on_rate_limit(async || self.extract_tool_use(request.clone(), cx).await).await?; + + let language_registry = self + .project + .read_with(cx, |project, _cx| project.languages().clone()); + + let context_server_registry = cx + .new(|cx| ContextServerRegistry::new(self.project.read(cx).context_server_store(), cx)); + let thread = cx.new(|cx| { + Thread::new( + self.project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(self.model.clone()), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let tool = Arc::new(WriteFileTool::new( + self.project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + + let result = cx + .update(|cx| { + tool.clone().run( + ToolInput::resolved(tool_input.clone()), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let output = match result { + Ok(output) => output, + Err(output) => anyhow::bail!("Tool returned error: {}", output), + }; + + let crate::EditFileToolOutput::Success { new_text, .. } = &output else { + anyhow::bail!("Tool returned error output: {}", output); + }; + + if tool_input.path != eval.input_file_path { + anyhow::bail!( + "Tool path mismatch. Expected {:?}, got {:?}", + eval.input_file_path, + tool_input.path, + ); + } + + if new_text != &eval.expected_output_content { + anyhow::bail!( + "Output content mismatch. Expected {:?}, got {:?}", + eval.expected_output_content, + new_text, + ); + } + + Ok(WriteEvalOutput { + tool_input, + text_after: new_text.clone(), + }) + } + + async fn extract_tool_use( + &self, + request: LanguageModelRequest, + cx: &mut TestAppContext, + ) -> Result { + let model = self.model.clone(); + let events = cx + .update(|cx| { + let async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { model.stream_completion(request, &async_cx).await }) + }) + .await + .map_err(|err| anyhow::anyhow!("completion error: {}", err))?; + + let mut streamed_text = String::new(); + let mut stop_reason = None; + let mut parse_errors = Vec::new(); + + let mut events = events.fuse(); + while let Some(event) = events.next().await { + match event { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) + if tool_use.is_input_complete + && tool_use.name.as_ref() == WriteFileTool::NAME => + { + let input: WriteFileToolInput = serde_json::from_value(tool_use.input) + .context("Failed to parse tool input as WriteFileToolInput")?; + return Ok(input); + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + if streamed_text.len() < 2_000 { + streamed_text.push_str(&text); + } + } + Ok(LanguageModelCompletionEvent::Stop(reason)) => { + stop_reason = Some(reason); + } + Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { + tool_name, + raw_input, + json_parse_error, + .. + }) if tool_name.as_ref() == WriteFileTool::NAME => { + parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}")); + } + Err(err) => return Err(anyhow::anyhow!("completion error: {}", err)), + _ => {} + } + } + + let streamed_text = streamed_text.trim(); + let streamed_text_suffix = if streamed_text.is_empty() { + String::new() + } else { + format!("\nStreamed text:\n{streamed_text}") + }; + let stop_reason_suffix = stop_reason + .map(|reason| format!("\nStop reason: {reason:?}")) + .unwrap_or_default(); + let parse_errors_suffix = if parse_errors.is_empty() { + String::new() + } else { + format!("\nTool parse errors:\n{}", parse_errors.join("\n")) + }; + + anyhow::bail!( + "Stream ended without a write_file tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}" + ) + } +} + +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> { + super::run_gpui_eval( + |cx| { + async move { + let test = WriteToolTest::new(cx).await; + let result = test.eval(eval, cx).await; + drop(test); + cx.run_until_parked(); + result + } + .boxed_local() + }, + |_| eval_utils::OutcomeKind::Passed, + ) +} + +fn message( + role: Role, + content: impl IntoIterator, +) -> LanguageModelRequestMessage { + LanguageModelRequestMessage { + role, + content: content.into_iter().collect(), + cache: false, + reasoning_details: None, + } +} + +fn text(text: impl Into) -> MessageContent { + MessageContent::Text(text.into()) +} + +fn tool_use( + id: impl Into>, + name: impl Into>, + input: impl Serialize, +) -> MessageContent { + MessageContent::ToolUse(LanguageModelToolUse { + id: LanguageModelToolUseId::from(id.into()), + name: name.into(), + raw_input: serde_json::to_string_pretty(&input).unwrap(), + input: serde_json::to_value(input).unwrap(), + is_input_complete: true, + thought_signature: None, + }) +} + +fn tool_result( + id: impl Into>, + name: impl Into>, + result: impl Into>, +) -> MessageContent { + MessageContent::ToolResult(LanguageModelToolResult { + tool_use_id: LanguageModelToolUseId::from(id.into()), + tool_name: name.into(), + is_error: false, + content: vec![LanguageModelToolResultContent::Text(result.into())], + output: None, + }) +} + +async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> Result { + const MAX_RETRIES: usize = 20; + let mut attempt = 0; + + loop { + attempt += 1; + let response = request().await; + + if attempt >= MAX_RETRIES { + return response; + } + + let retry_delay = match &response { + Ok(_) => None, + Err(err) => match err.downcast_ref::() { + Some(err) => match &err { + LanguageModelCompletionError::RateLimitExceeded { retry_after, .. } + | LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } + LanguageModelCompletionError::UpstreamProviderError { + status, + retry_after, + .. + } => { + let should_retry = matches!( + *status, + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE + ) || status.as_u16() == 529; + + if should_retry { + Some(retry_after.unwrap_or(Duration::from_secs(5))) + } else { + None + } + } + LanguageModelCompletionError::ApiReadResponseError { .. } + | LanguageModelCompletionError::ApiInternalServerError { .. } + | LanguageModelCompletionError::HttpSend { .. } => { + Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30))) + } + _ => None, + }, + _ => None, + }, + }; + + if let Some(retry_after) = retry_delay { + let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0)); + eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}"); + #[allow(clippy::disallowed_methods)] + async_io::Timer::after(retry_after + jitter).await; + } else { + return response; + } + } +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_create_file() { + let input_file_path = "root/TODO3"; + let expected_output_content = "todo".to_string(); + + eval_utils::eval(100, 1., eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![ + message( + User, + [text("Create a third todo file. Write 'todo' inside it.")], + ), + message( + Assistant, + [ + text(indoc::formatdoc! {" + I'll help you create a third empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), + tool_use( + "toolu_01GAF8TtsgpjKxCr8fgQLDgR", + ListDirectoryTool::NAME, + ListDirectoryToolInput { + path: "root".to_string(), + }, + ), + ], + ), + message( + User, + [tool_result( + "toolu_01GAF8TtsgpjKxCr8fgQLDgR", + ListDirectoryTool::NAME, + "root/TODO\nroot/TODO2\nroot/new.txt\n", + )], + ), + ], + input_file_path, + None, + expected_output_content.clone(), + )) + }); +} + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_overwrite_file() { + let input_file_path = "root/notes.txt"; + let input_file_content = "old notes\nkeep nothing\n".to_string(); + let expected_output_content = "new notes".to_string(); + + eval_utils::eval(100, 1., eval_utils::NoProcessor, move || { + run_eval(EvalInput::new( + vec![message( + User, + [text(indoc::formatdoc! {" + Overwrite `{input_file_path}` so that its complete contents are exactly: 'new notes' + "})], + )], + input_file_path, + Some(input_file_content.clone()), + expected_output_content.clone(), + )) + }); +} diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 4fa27114c8e..da0cbddb86a 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -184,6 +184,13 @@ impl AgentTool for ReadFileTool { anyhow::Ok(()) }).map_err(tool_content_err)?; + if fs.is_dir(&abs_path).await { + return Err(tool_content_err(format!( + "{} is a directory, not a file. Use the list_directory tool to explore directory contents.", + &input.path + ))); + } + if let Some(canonical_target) = &symlink_canonical_target { let authorize = cx.update(|cx| { authorize_symlink_access( @@ -356,6 +363,39 @@ mod test { use std::sync::Arc; use util::path; + #[gpui::test] + async fn test_read_directory_path(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "some_dir": {} + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); + let (event_stream, _) = ToolCallEventStream::test(); + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/some_dir".to_string(), + start_line: None, + end_line: None, + }; + tool.run(ToolInput::resolved(input), event_stream, cx) + }) + .await; + assert_eq!( + error_text(result.unwrap_err()), + "root/some_dir is a directory, not a file. Use the list_directory tool to explore directory contents." + ); + } + #[gpui::test] async fn test_read_nonexistent_file(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs deleted file mode 100644 index 3b2c95596c3..00000000000 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ /dev/null @@ -1,673 +0,0 @@ -use super::tool_permissions::{ - ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, - path_has_symlink_escape, resolve_project_path, sensitive_settings_kind, -}; -use agent_client_protocol::schema as acp; -use agent_settings::AgentSettings; -use collections::FxHashSet; -use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task}; -use language::Buffer; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use util::markdown::MarkdownInlineCode; - -use crate::{ - AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, - authorize_with_sensitive_settings, decide_permission_for_path, -}; - -/// Discards unsaved changes in open buffers by reloading file contents from disk. -/// -/// Use this tool when: -/// - You attempted to edit files but they have unsaved changes the user does not want to keep. -/// - You want to reset files to the on-disk state before retrying an edit. -/// -/// Only use this tool after asking the user for permission, because it will discard unsaved changes. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct RestoreFileFromDiskToolInput { - /// The paths of the files to restore from disk. - pub paths: Vec, -} - -pub struct RestoreFileFromDiskTool { - project: Entity, -} - -impl RestoreFileFromDiskTool { - pub fn new(project: Entity) -> Self { - Self { project } - } -} - -impl AgentTool for RestoreFileFromDiskTool { - type Input = RestoreFileFromDiskToolInput; - type Output = String; - - const NAME: &'static str = "restore_file_from_disk"; - - fn kind() -> acp::ToolKind { - acp::ToolKind::Other - } - - fn initial_title( - &self, - input: Result, - _cx: &mut App, - ) -> SharedString { - match input { - Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(), - Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(), - Err(_) => "Restore files from disk".into(), - } - } - - fn run( - self: Arc, - input: ToolInput, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let project = self.project.clone(); - - cx.spawn(async move |cx| { - let input = input.recv().await.map_err(|e| e.to_string())?; - - // Check for any immediate deny before doing async work. - for path in &input.paths { - let path_str = path.to_string_lossy(); - let decision = cx.update(|cx| { - decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) - }); - if let ToolPermissionDecision::Deny(reason) = decision { - return Err(reason); - } - } - - let input_paths = input.paths; - - let fs = project.read_with(cx, |project, _cx| project.fs().clone()); - let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; - - let mut confirmation_paths: Vec = Vec::new(); - - for path in &input_paths { - let path_str = path.to_string_lossy(); - let decision = cx.update(|cx| { - decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) - }); - let symlink_escape = project.read_with(cx, |project, cx| { - path_has_symlink_escape(project, path, &canonical_roots, cx) - }); - - match decision { - ToolPermissionDecision::Allow => { - if !symlink_escape { - let is_sensitive = super::tool_permissions::is_sensitive_settings_path( - Path::new(&*path_str), - fs.as_ref(), - ) - .await; - if is_sensitive { - confirmation_paths.push(path_str.to_string()); - } - } - } - ToolPermissionDecision::Deny(reason) => { - return Err(reason); - } - ToolPermissionDecision::Confirm => { - if !symlink_escape { - confirmation_paths.push(path_str.to_string()); - } - } - } - } - - if !confirmation_paths.is_empty() { - let title = if confirmation_paths.len() == 1 { - format!( - "Restore {} from disk", - MarkdownInlineCode(&confirmation_paths[0]) - ) - } else { - let paths: Vec<_> = confirmation_paths - .iter() - .take(3) - .map(|p| p.as_str()) - .collect(); - if confirmation_paths.len() > 3 { - format!( - "Restore {}, and {} more from disk", - paths.join(", "), - confirmation_paths.len() - 3 - ) - } else { - format!("Restore {} from disk", paths.join(", ")) - } - }; - - let mut settings_kind = None; - for p in &confirmation_paths { - if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await { - settings_kind = Some(kind); - break; - } - } - let context = crate::ToolPermissionContext::new(Self::NAME, confirmation_paths); - let authorize = cx.update(|cx| { - authorize_with_sensitive_settings( - settings_kind, - context, - &title, - &event_stream, - cx, - ) - }); - authorize.await.map_err(|e| e.to_string())?; - } - let mut buffers_to_reload: FxHashSet> = FxHashSet::default(); - - let mut restored_paths: Vec = Vec::new(); - let mut clean_paths: Vec = Vec::new(); - let mut not_found_paths: Vec = Vec::new(); - let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); - let dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); - let mut reload_errors: Vec = Vec::new(); - - for path in input_paths { - let project_path = match project.read_with(cx, |project, cx| { - resolve_project_path(project, &path, &canonical_roots, cx) - }) { - Ok(resolved) => { - let (project_path, symlink_canonical_target) = match resolved { - ResolvedProjectPath::Safe(path) => (path, None), - ResolvedProjectPath::SymlinkEscape { - project_path, - canonical_target, - } => (project_path, Some(canonical_target)), - }; - if let Some(canonical_target) = &symlink_canonical_target { - let path_str = path.to_string_lossy(); - let authorize_task = cx.update(|cx| { - authorize_symlink_access( - Self::NAME, - &path_str, - canonical_target, - &event_stream, - cx, - ) - }); - let result = authorize_task.await; - if let Err(err) = result { - reload_errors.push(format!("{}: {}", path.to_string_lossy(), err)); - continue; - } - } - project_path - } - Err(_) => { - not_found_paths.push(path); - continue; - } - }; - - let open_buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - - let buffer = futures::select! { - result = open_buffer_task.fuse() => { - match result { - Ok(buffer) => buffer, - Err(error) => { - open_errors.push((path, error.to_string())); - continue; - } - } - } - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Restore cancelled by user".to_string()); - } - }; - - let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); - - if is_dirty { - buffers_to_reload.insert(buffer); - restored_paths.push(path); - } else { - clean_paths.push(path); - } - } - - if !buffers_to_reload.is_empty() { - let reload_task = project.update(cx, |project, cx| { - project.reload_buffers(buffers_to_reload, true, cx) - }); - - let result = futures::select! { - result = reload_task.fuse() => result, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Restore cancelled by user".to_string()); - } - }; - if let Err(error) = result { - reload_errors.push(error.to_string()); - } - } - - let mut lines: Vec = Vec::new(); - - if !restored_paths.is_empty() { - lines.push(format!("Restored {} file(s).", restored_paths.len())); - } - if !clean_paths.is_empty() { - lines.push(format!("{} clean.", clean_paths.len())); - } - - if !not_found_paths.is_empty() { - lines.push(format!("Not found ({}):", not_found_paths.len())); - for path in ¬_found_paths { - lines.push(format!("- {}", path.display())); - } - } - if !open_errors.is_empty() { - lines.push(format!("Open failed ({}):", open_errors.len())); - for (path, error) in &open_errors { - lines.push(format!("- {}: {}", path.display(), error)); - } - } - if !dirty_check_errors.is_empty() { - lines.push(format!( - "Dirty check failed ({}):", - dirty_check_errors.len() - )); - for (path, error) in &dirty_check_errors { - lines.push(format!("- {}: {}", path.display(), error)); - } - } - if !reload_errors.is_empty() { - lines.push(format!("Reload failed ({}):", reload_errors.len())); - for error in &reload_errors { - lines.push(format!("- {}", error)); - } - } - - if lines.is_empty() { - Ok("No paths provided.".to_string()) - } else { - Ok(lines.join("\n")) - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::Fs as _; - use gpui::TestAppContext; - use language::LineEnding; - use project::FakeFs; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - AgentSettings::override_global(settings, cx); - }); - } - - #[gpui::test] - async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dirty.txt": "on disk: dirty\n", - "clean.txt": "on disk: clean\n", - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone())); - - // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk. - let dirty_project_path = project.read_with(cx, |project, cx| { - project - .find_project_path("root/dirty.txt", cx) - .expect("dirty.txt should exist in project") - }); - - let dirty_buffer = project - .update(cx, |project, cx| { - project.open_buffer(dirty_project_path, cx) - }) - .await - .unwrap(); - dirty_buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); - }); - assert!( - dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "dirty.txt buffer should be dirty before restore" - ); - - // Ensure clean.txt is opened but remains clean. - let clean_project_path = project.read_with(cx, |project, cx| { - project - .find_project_path("root/clean.txt", cx) - .expect("clean.txt should exist in project") - }); - - let clean_buffer = project - .update(cx, |project, cx| { - project.open_buffer(clean_project_path, cx) - }) - .await - .unwrap(); - assert!( - !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "clean.txt buffer should start clean" - ); - - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { - paths: vec![ - PathBuf::from("root/dirty.txt"), - PathBuf::from("root/clean.txt"), - ], - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Output should mention restored + clean. - assert!( - output.contains("Restored 1 file(s)."), - "expected restored count line, got:\n{output}" - ); - assert!( - output.contains("1 clean."), - "expected clean count line, got:\n{output}" - ); - - // Effect: dirty buffer should be restored back to disk content and become clean. - let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text()); - assert_eq!( - dirty_text, "on disk: dirty\n", - "dirty.txt buffer should be restored to disk contents" - ); - assert!( - !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "dirty.txt buffer should not be dirty after restore" - ); - - // Disk contents should be unchanged (restore-from-disk should not write). - let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); - assert_eq!(disk_dirty, "on disk: dirty\n"); - - // Sanity: clean buffer should remain clean and unchanged. - let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text()); - assert_eq!(clean_text, "on disk: clean\n"); - assert!( - !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "clean.txt buffer should remain clean" - ); - - // Test empty paths case. - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![] }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - assert_eq!(output, "No paths provided."); - - // Test not-found path case (path outside the project root). - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { - paths: vec![PathBuf::from("nonexistent/path.txt")], - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - assert!( - output.contains("Not found (1):"), - "expected not-found header line, got:\n{output}" - ); - assert!( - output.contains("- nonexistent/path.txt"), - "expected not-found path bullet, got:\n{output}" - ); - - let _ = LineEnding::Unix; // keep import used if the buffer edit API changes - } - - #[gpui::test] - async fn test_restore_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(RestoreFileFromDiskTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }); - - cx.run_until_parked(); - - let auth = event_rx.expect_authorization().await; - let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); - assert!( - title.contains("points outside the project"), - "Expected symlink escape authorization, got: {title}", - ); - - auth.response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - )) - .unwrap(); - - let _result = task.await; - } - - #[gpui::test] - async fn test_restore_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.tools.insert( - "restore_file_from_disk".into(), - agent_settings::ToolRules { - default: Some(settings::ToolPermissionMode::Deny), - ..Default::default() - }, - ); - AgentSettings::override_global(settings, cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(RestoreFileFromDiskTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }) - .await; - - assert!(result.is_err(), "Tool should fail when policy denies"); - assert!( - !matches!( - event_rx.try_recv(), - Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) - ), - "Deny policy should not emit symlink authorization prompt", - ); - } - - #[gpui::test] - async fn test_restore_file_symlink_escape_confirm_requires_single_approval( - cx: &mut TestAppContext, - ) { - init_test(cx); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Confirm; - AgentSettings::override_global(settings, cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(RestoreFileFromDiskTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(RestoreFileFromDiskToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }); - - cx.run_until_parked(); - - let auth = event_rx.expect_authorization().await; - let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); - assert!( - title.contains("points outside the project"), - "Expected symlink escape authorization, got: {title}", - ); - - auth.response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - )) - .unwrap(); - - assert!( - !matches!( - event_rx.try_recv(), - Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) - ), - "Expected a single authorization prompt", - ); - - let _result = task.await; - } -} diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs deleted file mode 100644 index f7042098415..00000000000 --- a/crates/agent/src/tools/save_file_tool.rs +++ /dev/null @@ -1,756 +0,0 @@ -use agent_client_protocol::schema as acp; -use agent_settings::AgentSettings; -use collections::FxHashSet; -use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task}; -use language::Buffer; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use util::markdown::MarkdownInlineCode; - -use super::tool_permissions::{ - ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, - path_has_symlink_escape, resolve_project_path, sensitive_settings_kind, -}; -use crate::{ - AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, - authorize_with_sensitive_settings, decide_permission_for_path, -}; - -/// Saves files that have unsaved changes. -/// -/// Use this tool when you need to edit files but they have unsaved changes that must be saved first. -/// Only use this tool after asking the user for permission to save their unsaved changes. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct SaveFileToolInput { - /// The paths of the files to save. - pub paths: Vec, -} - -pub struct SaveFileTool { - project: Entity, -} - -impl SaveFileTool { - pub fn new(project: Entity) -> Self { - Self { project } - } -} - -impl AgentTool for SaveFileTool { - type Input = SaveFileToolInput; - type Output = String; - - const NAME: &'static str = "save_file"; - - fn kind() -> acp::ToolKind { - acp::ToolKind::Other - } - - fn initial_title( - &self, - input: Result, - _cx: &mut App, - ) -> SharedString { - match input { - Ok(input) if input.paths.len() == 1 => "Save file".into(), - Ok(input) => format!("Save {} files", input.paths.len()).into(), - Err(_) => "Save files".into(), - } - } - - fn run( - self: Arc, - input: ToolInput, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let project = self.project.clone(); - - cx.spawn(async move |cx| { - let input = input.recv().await.map_err(|e| e.to_string())?; - - // Check for any immediate deny before doing async work. - for path in &input.paths { - let path_str = path.to_string_lossy(); - let decision = cx.update(|cx| { - decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) - }); - if let ToolPermissionDecision::Deny(reason) = decision { - return Err(reason); - } - } - - let input_paths = input.paths; - - let fs = project.read_with(cx, |project, _cx| project.fs().clone()); - let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; - - let mut confirmation_paths: Vec = Vec::new(); - - for path in &input_paths { - let path_str = path.to_string_lossy(); - let decision = cx.update(|cx| { - decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) - }); - let symlink_escape = project.read_with(cx, |project, cx| { - path_has_symlink_escape(project, path, &canonical_roots, cx) - }); - - match decision { - ToolPermissionDecision::Allow => { - if !symlink_escape { - let is_sensitive = super::tool_permissions::is_sensitive_settings_path( - Path::new(&*path_str), - fs.as_ref(), - ) - .await; - if is_sensitive { - confirmation_paths.push(path_str.to_string()); - } - } - } - ToolPermissionDecision::Deny(reason) => { - return Err(reason); - } - ToolPermissionDecision::Confirm => { - if !symlink_escape { - confirmation_paths.push(path_str.to_string()); - } - } - } - } - - if !confirmation_paths.is_empty() { - let title = if confirmation_paths.len() == 1 { - format!("Save {}", MarkdownInlineCode(&confirmation_paths[0])) - } else { - let paths: Vec<_> = confirmation_paths - .iter() - .take(3) - .map(|p| p.as_str()) - .collect(); - if confirmation_paths.len() > 3 { - format!( - "Save {}, and {} more", - paths.join(", "), - confirmation_paths.len() - 3 - ) - } else { - format!("Save {}", paths.join(", ")) - } - }; - - let mut settings_kind = None; - for p in &confirmation_paths { - if let Some(kind) = sensitive_settings_kind(Path::new(p), fs.as_ref()).await { - settings_kind = Some(kind); - break; - } - } - let context = - crate::ToolPermissionContext::new(Self::NAME, confirmation_paths.clone()); - let authorize = cx.update(|cx| { - authorize_with_sensitive_settings( - settings_kind, - context, - &title, - &event_stream, - cx, - ) - }); - authorize.await.map_err(|e| e.to_string())?; - } - - let mut buffers_to_save: FxHashSet> = FxHashSet::default(); - - let mut dirty_count: usize = 0; - let mut clean_paths: Vec = Vec::new(); - let mut not_found_paths: Vec = Vec::new(); - let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); - let mut authorization_errors: Vec<(PathBuf, String)> = Vec::new(); - let mut save_errors: Vec<(String, String)> = Vec::new(); - - for path in input_paths { - let project_path = match project.read_with(cx, |project, cx| { - resolve_project_path(project, &path, &canonical_roots, cx) - }) { - Ok(resolved) => { - let (project_path, symlink_canonical_target) = match resolved { - ResolvedProjectPath::Safe(path) => (path, None), - ResolvedProjectPath::SymlinkEscape { - project_path, - canonical_target, - } => (project_path, Some(canonical_target)), - }; - if let Some(canonical_target) = &symlink_canonical_target { - let path_str = path.to_string_lossy(); - let authorize_task = cx.update(|cx| { - authorize_symlink_access( - Self::NAME, - &path_str, - canonical_target, - &event_stream, - cx, - ) - }); - let result = authorize_task.await; - if let Err(err) = result { - authorization_errors.push((path.clone(), err.to_string())); - continue; - } - } - project_path - } - Err(_) => { - not_found_paths.push(path); - continue; - } - }; - - let open_buffer_task = - project.update(cx, |project, cx| project.open_buffer(project_path, cx)); - - let buffer = futures::select! { - result = open_buffer_task.fuse() => { - match result { - Ok(buffer) => buffer, - Err(error) => { - open_errors.push((path, error.to_string())); - continue; - } - } - } - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Save cancelled by user".to_string()); - } - }; - - let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); - - if is_dirty { - buffers_to_save.insert(buffer); - dirty_count += 1; - } else { - clean_paths.push(path); - } - } - - // Save each buffer individually since there's no batch save API. - for buffer in buffers_to_save { - let path_for_buffer = buffer - .read_with(cx, |buffer, _| { - buffer - .file() - .map(|file| file.path().to_rel_path_buf()) - .map(|path| path.as_rel_path().as_unix_str().to_owned()) - }) - .unwrap_or_else(|| "".to_string()); - - let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx)); - - let save_result = futures::select! { - result = save_task.fuse() => result, - _ = event_stream.cancelled_by_user().fuse() => { - return Err("Save cancelled by user".to_string()); - } - }; - if let Err(error) = save_result { - save_errors.push((path_for_buffer, error.to_string())); - } - } - - let mut lines: Vec = Vec::new(); - - let successful_saves = dirty_count.saturating_sub(save_errors.len()); - if successful_saves > 0 { - lines.push(format!("Saved {} file(s).", successful_saves)); - } - if !clean_paths.is_empty() { - lines.push(format!("{} clean.", clean_paths.len())); - } - - if !not_found_paths.is_empty() { - lines.push(format!("Not found ({}):", not_found_paths.len())); - for path in ¬_found_paths { - lines.push(format!("- {}", path.display())); - } - } - if !open_errors.is_empty() { - lines.push(format!("Open failed ({}):", open_errors.len())); - for (path, error) in &open_errors { - lines.push(format!("- {}: {}", path.display(), error)); - } - } - if !authorization_errors.is_empty() { - lines.push(format!( - "Authorization failed ({}):", - authorization_errors.len() - )); - for (path, error) in &authorization_errors { - lines.push(format!("- {}: {}", path.display(), error)); - } - } - if !save_errors.is_empty() { - lines.push(format!("Save failed ({}):", save_errors.len())); - for (path, error) in &save_errors { - lines.push(format!("- {}: {}", path, error)); - } - } - - if lines.is_empty() { - Ok("No paths provided.".to_string()) - } else { - Ok(lines.join("\n")) - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::Fs as _; - use gpui::TestAppContext; - use project::FakeFs; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Allow; - AgentSettings::override_global(settings, cx); - }); - } - - #[gpui::test] - async fn test_save_file_output_and_effects(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dirty.txt": "on disk: dirty\n", - "clean.txt": "on disk: clean\n", - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let tool = Arc::new(SaveFileTool::new(project.clone())); - - // Make dirty.txt dirty in-memory. - let dirty_project_path = project.read_with(cx, |project, cx| { - project - .find_project_path("root/dirty.txt", cx) - .expect("dirty.txt should exist in project") - }); - - let dirty_buffer = project - .update(cx, |project, cx| { - project.open_buffer(dirty_project_path, cx) - }) - .await - .unwrap(); - dirty_buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); - }); - assert!( - dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "dirty.txt buffer should be dirty before save" - ); - - // Ensure clean.txt is opened but remains clean. - let clean_project_path = project.read_with(cx, |project, cx| { - project - .find_project_path("root/clean.txt", cx) - .expect("clean.txt should exist in project") - }); - - let clean_buffer = project - .update(cx, |project, cx| { - project.open_buffer(clean_project_path, cx) - }) - .await - .unwrap(); - assert!( - !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "clean.txt buffer should start clean" - ); - - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![ - PathBuf::from("root/dirty.txt"), - PathBuf::from("root/clean.txt"), - ], - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - - // Output should mention saved + clean. - assert!( - output.contains("Saved 1 file(s)."), - "expected saved count line, got:\n{output}" - ); - assert!( - output.contains("1 clean."), - "expected clean count line, got:\n{output}" - ); - - // Effect: dirty buffer should now be clean and disk should have new content. - assert!( - !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "dirty.txt buffer should not be dirty after save" - ); - - let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); - assert_eq!( - disk_dirty, "in memory: dirty\n", - "dirty.txt disk content should be updated" - ); - - // Sanity: clean buffer should remain clean and disk unchanged. - let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap(); - assert_eq!(disk_clean, "on disk: clean\n"); - - // Test empty paths case. - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { paths: vec![] }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - assert_eq!(output, "No paths provided."); - - // Test not-found path case. - let output = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![PathBuf::from("nonexistent/path.txt")], - }), - ToolCallEventStream::test().0, - cx, - ) - }) - .await - .unwrap(); - assert!( - output.contains("Not found (1):"), - "expected not-found header line, got:\n{output}" - ); - assert!( - output.contains("- nonexistent/path.txt"), - "expected not-found path bullet, got:\n{output}" - ); - } - - #[gpui::test] - async fn test_save_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(SaveFileTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }); - - cx.run_until_parked(); - - let auth = event_rx.expect_authorization().await; - let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); - assert!( - title.contains("points outside the project"), - "Expected symlink escape authorization, got: {title}", - ); - - auth.response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - )) - .unwrap(); - - let _result = task.await; - } - - #[gpui::test] - async fn test_save_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { - init_test(cx); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.tools.insert( - "save_file".into(), - agent_settings::ToolRules { - default: Some(settings::ToolPermissionMode::Deny), - ..Default::default() - }, - ); - AgentSettings::override_global(settings, cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(SaveFileTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let result = cx - .update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }) - .await; - - assert!(result.is_err(), "Tool should fail when policy denies"); - assert!( - !matches!( - event_rx.try_recv(), - Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) - ), - "Deny policy should not emit symlink authorization prompt", - ); - } - - #[gpui::test] - async fn test_save_file_symlink_escape_confirm_requires_single_approval( - cx: &mut TestAppContext, - ) { - init_test(cx); - cx.update(|cx| { - let mut settings = AgentSettings::get_global(cx).clone(); - settings.tool_permissions.default = settings::ToolPermissionMode::Confirm; - AgentSettings::override_global(settings, cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "src": {} - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let tool = Arc::new(SaveFileTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![PathBuf::from("project/link.txt")], - }), - event_stream, - cx, - ) - }); - - cx.run_until_parked(); - - let auth = event_rx.expect_authorization().await; - let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); - assert!( - title.contains("points outside the project"), - "Expected symlink escape authorization, got: {title}", - ); - - auth.response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("allow"), - acp::PermissionOptionKind::AllowOnce, - )) - .unwrap(); - - assert!( - !matches!( - event_rx.try_recv(), - Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) - ), - "Expected a single authorization prompt", - ); - - let _result = task.await; - } - - #[gpui::test] - async fn test_save_file_symlink_denial_does_not_reduce_success_count(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "project": { - "dirty.txt": "on disk value\n", - }, - "external": { - "secret.txt": "secret content" - } - }), - ) - .await; - - fs.create_symlink( - path!("/root/project/link.txt").as_ref(), - PathBuf::from("../external/secret.txt"), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; - cx.executor().run_until_parked(); - - let dirty_project_path = project.read_with(cx, |project, cx| { - project - .find_project_path("project/dirty.txt", cx) - .expect("dirty.txt should exist in project") - }); - let dirty_buffer = project - .update(cx, |project, cx| { - project.open_buffer(dirty_project_path, cx) - }) - .await - .unwrap(); - dirty_buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), "in memory value\n")], None, cx); - }); - assert!( - dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), - "dirty.txt should be dirty before save" - ); - - let tool = Arc::new(SaveFileTool::new(project)); - - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| { - tool.clone().run( - ToolInput::resolved(SaveFileToolInput { - paths: vec![ - PathBuf::from("project/dirty.txt"), - PathBuf::from("project/link.txt"), - ], - }), - event_stream, - cx, - ) - }); - - cx.run_until_parked(); - - let auth = event_rx.expect_authorization().await; - auth.response - .send(acp_thread::SelectedPermissionOutcome::new( - acp::PermissionOptionId::new("deny"), - acp::PermissionOptionKind::RejectOnce, - )) - .unwrap(); - - let output = task.await.unwrap(); - assert!( - output.contains("Saved 1 file(s)."), - "Expected successful save count to remain accurate, got:\n{output}", - ); - assert!( - output.contains("Authorization failed (1):"), - "Expected authorization failure section, got:\n{output}", - ); - assert!( - !output.contains("Save failed"), - "Authorization denials should not be counted as save failures, got:\n{output}", - ); - } -} diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 34d19c581a4..4f0c6b48c80 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -34,11 +34,16 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. /// -/// The terminal emulator is an interactive pty, so commands may block waiting for user input. -/// Some commands can be configured not to do this, such as `git --no-pager diff` and similar. +/// The terminal is an interactive pty, so any command that blocks waiting for input will hang the tool until it times out. To avoid this: +/// +/// - Always insert `--no-pager` immediately after `git` for any read-only git command, including `git log`, `git diff`, `git show`, `git blame`, and `git stash show`. Example: `git --no-pager log -n 5` (NOT `git log -n 5`). +/// - Always prepend `GIT_EDITOR=true ` to any git command that may invoke an editor, including `git rebase`, `git commit`, `git merge`, and `git tag`. Example: `GIT_EDITOR=true git rebase origin/main` (NOT `git rebase origin/main`). +/// - For other commands that may open a pager or editor, set `PAGER=cat` and/or `EDITOR=true` similarly. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. Do not include shell substitutions or interpolations such as `$VAR`, `${VAR}`, `$(...)`, backticks, `$((...))`, `<(...)`, or `>(...)`; resolve those values first or ask the user. + /// + /// REMINDER: read-only git commands (`git log`, `git diff`, `git show`, `git blame`) MUST include `--no-pager` (e.g. `git --no-pager log`). Git commands that may open an editor (`git rebase`, `git commit`, `git merge`, `git tag`) MUST be prefixed with `GIT_EDITOR=true ` (e.g. `GIT_EDITOR=true git rebase origin/main`). Otherwise the terminal will hang. pub command: String, /// Working directory for the command. This must be one of the root directories of the project. pub cd: String, diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index aa541c3e0ef..5d59dd2eddb 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -2,6 +2,7 @@ use crate::{ Thread, ToolCallEventStream, ToolPermissionContext, ToolPermissionDecision, decide_permission_for_path, }; +use agent_client_protocol::schema as acp; use anyhow::{Result, anyhow}; use fs::Fs; use gpui::{App, Entity, Task, WeakEntity}; @@ -521,6 +522,91 @@ pub fn authorize_file_edit( }) } +/// The user's choice when prompted about how to handle unsaved changes +/// in a buffer that the agent wants to edit or overwrite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirtyBufferDecision { + /// Save the buffer's pending edits to disk, then proceed. + /// (Edit-mode prompt only.) + Save, + /// Discard the buffer's pending edits (reload from disk), then proceed. + Discard, + /// Keep the buffer's pending edits and cancel the agent's operation. + /// (Overwrite-mode prompt only.) + Keep, +} + +/// Which prompt to show when the agent encounters a dirty buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirtyBufferPromptKind { + /// The agent wants to apply targeted edits on top of the current + /// content. Offers Save (persist edits, then edit on top) vs Discard + /// (revert to disk, then edit). + Edit, + /// The agent wants to overwrite the file's entire contents. Offers + /// Keep (cancel the overwrite to preserve the user's work) vs + /// Discard (reload from disk and let the agent overwrite). + Overwrite, +} + +/// Prompts the user about how to handle a dirty buffer that the agent +/// wants to edit or overwrite. Returns the chosen action; the caller is +/// responsible for actually performing the corresponding side effect +/// (save / reload / cancel) before continuing. +pub fn authorize_dirty_buffer( + kind: DirtyBufferPromptKind, + event_stream: &ToolCallEventStream, + cx: &mut App, +) -> Task> { + let (message, options) = match kind { + DirtyBufferPromptKind::Edit => ( + "This file has unsaved changes. Do you want to save or discard them \ + before the agent continues editing?" + .to_string(), + vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("save"), + "Save", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("discard"), + "Discard", + acp::PermissionOptionKind::RejectOnce, + ), + ], + ), + DirtyBufferPromptKind::Overwrite => ( + "This file has unsaved changes and the agent wants to overwrite it.".to_string(), + vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("discard"), + "Overwrite", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("keep"), + "Cancel", + acp::PermissionOptionKind::RejectOnce, + ), + ], + ), + }; + + let prompt = event_stream.prompt_for_decision(None, Some(message), options, cx); + cx.spawn(async move |_cx| { + let option_id = prompt.await?; + match option_id.0.as_ref() { + "save" => Ok(DirtyBufferDecision::Save), + "discard" => Ok(DirtyBufferDecision::Discard), + "keep" => Ok(DirtyBufferDecision::Keep), + other => Err(anyhow!( + "Unexpected dirty-buffer decision option_id: {other}" + )), + } + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent/src/tools/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs new file mode 100644 index 00000000000..bfd454aba97 --- /dev/null +++ b/crates/agent/src/tools/write_file_tool.rs @@ -0,0 +1,1396 @@ +use super::edit_session::{ + EditSession, EditSessionContext, EditSessionMode, EditSessionOutput, EditSessionResult, + initial_title_from_partial_path, run_session, +}; +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, ToolInputPayload}; +use action_log::ActionLog; +use agent_client_protocol::schema as acp; +use futures::FutureExt as _; +use gpui::{App, AsyncApp, Entity, Task, WeakEntity}; +use language::LanguageRegistry; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use ui::SharedString; + +const DEFAULT_UI_TEXT: &str = "Writing file"; + +/// This is a tool for creating a new file or overwriting an existing file with completely new contents. +/// +/// To make granular edits to an existing file, prefer the `edit_file` tool instead. +/// +/// Before using this tool: +/// +/// 1. Verify the directory path is correct (only applicable when creating new files): +/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct WriteFileToolInput { + /// The full path of the file to create or overwrite in the project. + /// + /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories. + /// + /// The following examples assume we have two root directories in the project: + /// - /a/b/backend + /// - /c/d/frontend + /// + /// + /// `backend/src/main.rs` + /// + /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail! + /// + /// + /// + /// `frontend/db.js` + /// + pub path: PathBuf, + + /// The complete content for the file. + /// This field should contain the entire file content. + pub content: String, +} + +#[derive(Clone, Default, Debug, Deserialize)] +struct WriteFileToolPartialInput { + #[serde(default)] + path: Option, + #[serde(default)] + content: Option, +} + +pub struct WriteFileTool { + session_context: Arc, +} + +impl WriteFileTool { + pub fn new( + project: Entity, + thread: WeakEntity, + action_log: Entity, + language_registry: Arc, + ) -> Self { + Self { + session_context: Arc::new(EditSessionContext::new( + project, + thread, + action_log, + language_registry, + )), + } + } + + async fn process_streaming_writes( + &self, + input: &mut ToolInput, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> EditSessionResult { + let mut session: Option = None; + let mut last_path: Option = None; + + loop { + futures::select! { + payload = input.next().fuse() => { + match payload { + Ok(payload) => match payload { + ToolInputPayload::Partial(partial) => { + if let Ok(parsed) = serde_json::from_value::(partial) { + let path_complete = parsed.path.is_some() + && parsed.path.as_ref() == last_path.as_ref(); + + last_path = parsed.path.clone(); + + if session.is_none() + && path_complete + && let Some(path) = parsed.path.as_ref() + { + match EditSession::new( + PathBuf::from(path), + EditSessionMode::Write, + Self::NAME, + self.session_context.clone(), + event_stream, + cx, + ) + .await + { + Ok(created_session) => session = Some(created_session), + Err(error) => { + log::error!("Failed to create edit session: {}", error); + return EditSessionResult::Failed { + error, + session: None, + }; + } + } + } + + if let Some(current_session) = &mut session + && let Err(error) = current_session.process_write(parsed.content.as_deref(), cx) + { + log::error!("Failed to process write: {}", error); + return EditSessionResult::Failed { error, session }; + } + } + } + ToolInputPayload::Full(full_input) => { + let mut session = if let Some(session) = session { + session + } else { + match EditSession::new( + full_input.path.clone(), + EditSessionMode::Write, + Self::NAME, + self.session_context.clone(), + event_stream, + cx, + ) + .await + { + Ok(created_session) => created_session, + Err(error) => { + log::error!("Failed to create edit session: {}", error); + return EditSessionResult::Failed { + error, + session: None, + }; + } + } + }; + + return match session.finalize_write(&full_input.content, cx).await { + Ok(()) => EditSessionResult::Completed(session), + Err(error) => { + log::error!("Failed to finalize write: {}", error); + EditSessionResult::Failed { + error, + session: Some(session), + } + } + }; + } + ToolInputPayload::InvalidJson { error_message } => { + log::error!("Received invalid JSON: {error_message}"); + return EditSessionResult::Failed { + error: error_message, + session, + }; + } + }, + Err(error) => { + return EditSessionResult::Failed { + error: error.to_string(), + session, + }; + } + } + } + _ = event_stream.cancelled_by_user().fuse() => { + return EditSessionResult::Failed { + error: "Write cancelled by user".to_string(), + session, + }; + } + } + } + } +} + +impl AgentTool for WriteFileTool { + type Input = WriteFileToolInput; + type Output = EditSessionOutput; + + const NAME: &'static str = "write_file"; + + fn supports_input_streaming() -> bool { + true + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Edit + } + + fn initial_title( + &self, + input: Result, + cx: &mut App, + ) -> SharedString { + match input { + Ok(input) => { + self.session_context + .initial_title_from_path(&input.path, DEFAULT_UI_TEXT, cx) + } + Err(raw_input) => initial_title_from_partial_path::( + &self.session_context, + raw_input, + |partial| partial.path.clone(), + DEFAULT_UI_TEXT, + cx, + ), + } + } + + fn run( + self: Arc, + mut input: ToolInput, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |cx: &mut AsyncApp| { + run_session( + self.process_streaming_writes(&mut input, &event_stream, cx) + .await, + cx, + ) + .await + }) + } + + fn replay( + &self, + _input: Self::Input, + output: Self::Output, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> anyhow::Result<()> { + self.session_context.replay_output(output, event_stream, cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AgentTool, ContextServerRegistry, Templates, Thread, ToolCallEventStream, ToolInput, + ToolInputSender, + }; + use acp_thread::Diff; + use action_log::ActionLog; + use fs::Fs as _; + use futures::StreamExt as _; + use gpui::{AppContext as _, Entity, TestAppContext, UpdateGlobal}; + use language::language_settings::FormatOnSave; + use language_model::fake_provider::FakeLanguageModel; + use project::{Project, ProjectPath}; + use prompt_store::ProjectContext; + use serde_json::json; + use settings::{Settings, SettingsStore}; + use std::sync::Arc; + use util::path; + use util::rel_path::{RelPath, rel_path}; + + #[gpui::test] + async fn test_streaming_write_create_file(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"dir": {}})).await; + let result = cx + .update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/dir/new_file.txt".into(), + content: "Hello, World!".into(), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditSessionOutput::Success { new_text, diff, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "Hello, World!"); + assert!(!diff.is_empty()); + } + + #[gpui::test] + async fn test_streaming_write_overwrite_file(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old content"})).await; + let result = cx + .update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/file.txt".into(), + content: "new content".into(), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let EditSessionOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content"); + assert_eq!(*old_text, "old content"); + } + + #[gpui::test] + async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "hello world"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Send partial with path but NO mode — path should NOT be treated as complete + sender.send_partial(json!({ + "path": "root/file" + })); + cx.run_until_parked(); + + // Now the path grows and mode appears + sender.send_partial(json!({ + "path": "root/file.txt", + })); + cx.run_until_parked(); + + // Send final + sender.send_full(json!({ + "path": "root/file.txt", + "content": "new content" + })); + + let result = task.await; + let EditSessionOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content"); + } + + #[gpui::test] + async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"dir": {}})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Stream partials for create mode + sender.send_partial(json!({})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + "content": "Hello, " + })); + cx.run_until_parked(); + + // Final with full content + sender.send_full(json!({ + "path": "root/dir/new_file.txt", + "content": "Hello, World!" + })); + + let result = task.await; + let EditSessionOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "Hello, World!"); + } + + #[gpui::test] + async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"dir": {}})).await; + // Create a channel and send multiple partials before a final, then use + // ToolInput::resolved-style immediate delivery to confirm recv() works + // when partials are already buffered. + let (mut sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Buffer several partials before sending the final + sender.send_partial(json!({})); + sender.send_partial(json!({"path": "root/dir/new.txt"})); + sender.send_partial(json!({ + "path": "root/dir/new.txt", + })); + sender.send_full(json!({ + "path": "root/dir/new.txt", + "content": "streamed content" + })); + + let result = task.await; + let EditSessionOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "streamed content"); + } + + #[gpui::test] + async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { + let mode = EditSessionMode::Write; + + let result = test_resolve_path(&mode, "root/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "dir/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); + + let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); + assert_resolved_path_eq(result.await, rel_path("dir/subdir/existing.txt")); + + let result = test_resolve_path(&mode, "root/dir/subdir", cx); + assert_eq!( + result.await.unwrap_err(), + "Can't write to file: path is a directory" + ); + + let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); + assert_eq!( + result.await.unwrap_err(), + "Can't create file: parent directory doesn't exist" + ); + } + + #[gpui::test] + async fn test_streaming_format_on_save(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + let (write_tool, project, action_log, fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; + + let rust_language = Arc::new(language::Language::new( + language::LanguageConfig { + name: "Rust".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + )); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_language); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Open the buffer to trigger LSP initialization + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + // Register the buffer with language servers + let _handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\ +"; + const FORMATTED_CONTENT: &str = "This file was formatted by the fake formatter in the test.\ +"; + + // Get the fake language server and set up formatting handler + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::({ + |_, _| async move { + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), + new_text: FORMATTED_CONTENT.to_string(), + }])) + } + }); + + // Test with format_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); + settings.project.all_languages.defaults.formatter = + Some(language::language_settings::FormatterList::default()); + }); + }); + }); + + // Use streaming pattern so executor can pump the LSP request/response + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "path": "root/src/main.rs", + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "path": "root/src/main.rs", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + new_content.replace("\r\n", "\n"), + FORMATTED_CONTENT, + "Code should be formatted when format_on_save is enabled" + ); + + let stale_buffer_count = thread + .read_with(cx, |thread, _cx| thread.action_log.clone()) + .read_with(cx, |log, cx| log.stale_buffers(cx).count()); + + assert_eq!( + stale_buffer_count, 0, + "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.", + stale_buffer_count + ); + + // Test with format_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = + Some(FormatOnSave::Off); + }); + }); + }); + + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool2 = Arc::new(WriteFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); + + let task = cx.update(|cx| tool2.run(input, event_stream, cx)); + + sender.send_partial(json!({ + "path": "root/src/main.rs", + })); + cx.run_until_parked(); + + sender.send_full(json!({ + "path": "root/src/main.rs", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + new_content.replace("\r\n", "\n"), + UNFORMATTED_CONTENT, + "Code should not be formatted when format_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + let (write_tool, project, action_log, fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await; + let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); + + // Test with remove_trailing_whitespace_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(true); + }); + }); + }); + + const CONTENT_WITH_TRAILING_WHITESPACE: &str = + "fn main() { \n println!(\"Hello!\"); \n}\n"; + + let result = cx + .update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/src/main.rs".into(), + content: CONTENT_WITH_TRAILING_WHITESPACE.into(), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + assert_eq!( + fs.load(path!("/root/src/main.rs").as_ref()) + .await + .unwrap() + .replace("\r\n", "\n"), + "fn main() {\n println!(\"Hello!\");\n}\n", + "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" + ); + + // Test with remove_trailing_whitespace_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(false); + }); + }); + }); + + let tool2 = Arc::new(WriteFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); + + let result = cx + .update(|cx| { + tool2.run( + ToolInput::resolved(WriteFileToolInput { + path: "root/src/main.rs".into(), + content: CONTENT_WITH_TRAILING_WHITESPACE.into(), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + final_content.replace("\r\n", "\n"), + CONTENT_WITH_TRAILING_WHITESPACE, + "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_streaming_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + let (write_tool, project, action_log, _fs, thread) = + setup_test_with_fs(cx, fs, &[path!("/").as_ref()]).await; + let language_registry = project.read_with(cx, |p, _cx| p.languages().clone()); + + // Ensure the diff is finalized after the edit completes. + { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: path!("/main.rs").into(), + content: "new content".into(), + }), + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(WriteFileTool::new( + project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + ToolInput::resolved(WriteFileToolInput { + path: path!("/main.rs").into(), + content: "dropped content".into(), + }), + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + } + + #[gpui::test] + async fn test_streaming_create_content_streamed(cx: &mut TestAppContext) { + let (write_tool, project, _action_log, _fs, _thread) = + setup_test(cx, json!({"dir": {}})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + })); + cx.run_until_parked(); + + // Stream content incrementally + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + "content": "line 1\n" + })); + cx.run_until_parked(); + + // Verify buffer has partial content + let buffer = project.update(cx, |project, cx| { + let path = project + .find_project_path("root/dir/new_file.txt", cx) + .unwrap(); + project.get_open_buffer(&path, cx).unwrap() + }); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\n"); + + // Stream more content + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + "content": "line 1\nline 2\n" + })); + cx.run_until_parked(); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "line 1\nline 2\n"); + + // Stream final chunk + sender.send_partial(json!({ + "path": "root/dir/new_file.txt", + "content": "line 1\nline 2\nline 3\n" + })); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "line 1\nline 2\nline 3\n" + ); + + // Send final input + sender.send_full(json!({ + "path": "root/dir/new_file.txt", + "content": "line 1\nline 2\nline 3\n" + })); + + let result = task.await; + let EditSessionOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nline 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_overwrite_diff_revealed_during_streaming(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, mut receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "path": "root/file.txt", + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "path": "root/file.txt", + })); + cx.run_until_parked(); + + // Get the diff entity from the event stream + receiver.expect_update_fields().await; + let diff = receiver.expect_diff().await; + + // Diff starts pending with no revealed ranges + diff.read_with(cx, |diff, cx| { + assert!(matches!(diff, Diff::Pending(_))); + assert!(!diff.has_revealed_range(cx)); + }); + + // Stream first content chunk + sender.send_partial(json!({ + "path": "root/file.txt", + "content": "new line 1\n" + })); + cx.run_until_parked(); + + // Diff should now have revealed ranges showing the new content + diff.read_with(cx, |diff, cx| { + assert!(diff.has_revealed_range(cx)); + }); + + // Send final input + sender.send_full(json!({ + "path": "root/file.txt", + "content": "new line 1\nnew line 2\n" + })); + + let result = task.await; + let EditSessionOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new line 1\nnew line 2\n"); + assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); + + // Diff is finalized after completion + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + #[gpui::test] + async fn test_streaming_overwrite_content_streamed(cx: &mut TestAppContext) { + let (write_tool, project, _action_log, _fs, _thread) = setup_test( + cx, + json!({"file.txt": "old line 1\nold line 2\nold line 3\n"}), + ) + .await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + // Transition to BufferResolved + sender.send_partial(json!({ + "path": "root/file.txt", + })); + cx.run_until_parked(); + + // Verify buffer still has old content (no content partial yet) + let buffer = project.update(cx, |project, cx| { + let path = project.find_project_path("root/file.txt", cx).unwrap(); + project.open_buffer(path, cx) + }); + let buffer = buffer.await.unwrap(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "old line 1\nold line 2\nold line 3\n" + ); + + // First content partial replaces old content + sender.send_partial(json!({ + "path": "root/file.txt", + "content": "new line 1\n" + })); + cx.run_until_parked(); + assert_eq!(buffer.read_with(cx, |b, _| b.text()), "new line 1\n"); + + // Subsequent content partials append + sender.send_partial(json!({ + "path": "root/file.txt", + "content": "new line 1\nnew line 2\n" + })); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |b, _| b.text()), + "new line 1\nnew line 2\n" + ); + + // Send final input with complete content + sender.send_full(json!({ + "path": "root/file.txt", + "content": "new line 1\nnew line 2\nnew line 3\n" + })); + + let result = task.await; + let EditSessionOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "new line 1\nnew line 2\nnew line 3\n"); + assert_eq!(*old_text, "old line 1\nold line 2\nold line 3\n"); + } + + #[gpui::test] + async fn test_streaming_write_file_tool_registers_changed_buffers(cx: &mut TestAppContext) { + let (write_tool, _project, action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "original content"})).await; + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (event_stream, _rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/file.txt".into(), + content: "completely new content".into(), + }), + event_stream, + cx, + ) + }); + + let result = task.await; + assert!(result.is_ok(), "write should succeed: {:?}", result.err()); + + cx.run_until_parked(); + + let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + assert!( + !changed.is_empty(), + "action_log.changed_buffers() should be non-empty after streaming write, \ + but no changed buffers were found \u{2014} Accept All / Reject All will not appear" + ); + } + + #[gpui::test] + async fn test_streaming_write_file_tool_fields_out_of_order(cx: &mut TestAppContext) { + let (write_tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (mut sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| write_tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "content": "new_content" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "content": "new_content", + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_full(json!({ + "content": "new_content", + "path": "root/file.txt" + })); + + let result = task.await; + let EditSessionOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { + let (write_tool, _project, action_log, fs, _thread) = + setup_test(cx, json!({"dir": {}})).await; + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // Create a new file via the streaming write file tool + let (event_stream, _rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/dir/new_file.txt".into(), + content: "Hello, World!".into(), + }), + event_stream, + cx, + ) + }); + let result = task.await; + assert!(result.is_ok(), "create should succeed: {:?}", result.err()); + cx.run_until_parked(); + + assert!( + fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, + "file should exist after creation" + ); + + // Reject all edits — this should delete the newly created file + let changed = action_log.read_with(cx, |log, cx| log.changed_buffers(cx)); + assert!( + !changed.is_empty(), + "action_log should track the created file as changed" + ); + + action_log + .update(cx, |log, cx| log.reject_all_edits(None, cx)) + .await; + cx.run_until_parked(); + + assert!( + !fs.is_file(path!("/root/dir/new_file.txt").as_ref()).await, + "file should be deleted after rejecting creation, but an empty file was left behind" + ); + } + + /// When the buffer has unsaved user edits and the user picks + /// "Discard my edits", the pending edits are reverted to match disk + /// and the agent's overwrite proceeds. + #[gpui::test] + async fn test_streaming_write_dirty_buffer_discard(cx: &mut TestAppContext) { + let (write_tool, project, _action_log, fs, _thread) = + setup_test(cx, json!({"file.txt": "on disk content"})).await; + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/file.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/file.txt".into(), + content: "agent overwrote it".into(), + }), + stream_tx, + cx, + ) + }); + + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + + // Verify the prompt is the overwrite-mode prompt. + let content = auth.tool_call.fields.content.as_deref().unwrap_or(&[]); + let acp::ToolCallContent::Content(text) = content.first().expect("expected message body") + else { + panic!("expected text body, got: {:?}", content.first()); + }; + let acp::ContentBlock::Text(text) = &text.content else { + panic!("expected text body, got: {:?}", text.content); + }; + assert!( + text.text.contains("overwrite"), + "expected overwrite-mode prompt, got: {:?}", + text.text, + ); + + // Verify both option ids are present (option_id is the stable contract). + let option_ids: Vec<&str> = match &auth.options { + acp_thread::PermissionOptions::Flat(opts) => { + opts.iter().map(|o| o.option_id.0.as_ref()).collect() + } + other => panic!("expected flat options, got: {other:?}"), + }; + assert!(option_ids.contains(&"keep"), "options: {option_ids:?}"); + assert!(option_ids.contains(&"discard"), "options: {option_ids:?}"); + + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("discard"), + acp::PermissionOptionKind::AllowOnce, + )) + .unwrap(); + + let EditSessionOutput::Success { new_text, .. } = task.await.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "agent overwrote it"); + assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "agent overwrote it"); + } + + /// When the buffer has unsaved user edits and the user picks + /// "Keep my edits", the overwrite is cancelled with an error and the + /// user's pending edits are preserved. + #[gpui::test] + async fn test_streaming_write_dirty_buffer_keep(cx: &mut TestAppContext) { + let (write_tool, project, _action_log, fs, _thread) = + setup_test(cx, json!({"file.txt": "on disk content"})).await; + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/file.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/file.txt".into(), + content: "agent overwrote it".into(), + }), + stream_tx, + cx, + ) + }); + + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + auth.response + .send(acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("keep"), + acp::PermissionOptionKind::RejectOnce, + )) + .unwrap(); + + let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("keep") || error.contains("cancelled"), + "expected cancel-style error message, got: {error:?}", + ); + + // The user's in-memory edits are preserved. + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!(buffer_text, "on disk content plus user edit"); + + // The on-disk content is untouched. + let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "on disk content"); + } + + /// When the user manually saves the buffer (e.g. cmd-s) while the + /// overwrite prompt is visible, that's treated as "Keep my edits": + /// the user just deliberately persisted their work, so we cancel the + /// agent's overwrite to avoid clobbering it. + #[gpui::test] + async fn test_streaming_write_dirty_buffer_resolved_externally(cx: &mut TestAppContext) { + let (write_tool, project, _action_log, fs, _thread) = + setup_test(cx, json!({"file.txt": "on disk content"})).await; + + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/file.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " plus user edit")], None, cx); + }); + assert!(buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + write_tool.clone().run( + ToolInput::resolved(WriteFileToolInput { + path: "root/file.txt".into(), + content: "agent overwrote it".into(), + }), + stream_tx, + cx, + ) + }); + + let _update = stream_rx.expect_update_fields().await; + let auth = stream_rx.expect_authorization().await; + + // User saves manually while the prompt is up. + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + + // The prompt is dismissed by transitioning to InProgress. + let dismiss = stream_rx.expect_update_fields().await; + assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress)); + drop(auth); + + // The overwrite is cancelled with an error. + let EditSessionOutput::Error { error, .. } = task.await.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("saved") || error.contains("cancelled"), + "expected cancel-on-manual-save error, got: {error:?}", + ); + + // The user's edits were saved to disk and not clobbered. + assert!(!buffer.read_with(cx, |buffer, _| buffer.is_dirty())); + let on_disk = fs.load(path!("/root/file.txt").as_ref()).await.unwrap(); + assert_eq!(on_disk, "on disk content plus user edit"); + } + + async fn setup_test_with_fs( + cx: &mut TestAppContext, + fs: Arc, + worktree_paths: &[&std::path::Path], + ) -> ( + Arc, + Entity, + Entity, + Arc, + Entity, + ) { + let project = Project::test(fs.clone(), worktree_paths.iter().copied(), cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + let write_tool = Arc::new(WriteFileTool::new( + project.clone(), + thread.downgrade(), + action_log.clone(), + language_registry, + )); + (write_tool, project, action_log, fs, thread) + } + + async fn setup_test( + cx: &mut TestAppContext, + initial_tree: serde_json::Value, + ) -> ( + Arc, + Entity, + Entity, + Arc, + Entity, + ) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", initial_tree).await; + setup_test_with_fs(cx, fs, &[path!("/root").as_ref()]).await + } + + async fn test_resolve_path( + mode: &EditSessionMode, + path: &str, + cx: &mut TestAppContext, + ) -> Result { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir": { + "subdir": { + "existing.txt": "content" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + crate::tools::edit_session::test_resolve_path(mode, path, &project, cx).await + } + + #[track_caller] + fn assert_resolved_path_eq(path: Result, expected: &RelPath) { + let actual = path.expect("Should return valid path").path; + assert_eq!(actual.as_ref(), expected); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .ensure_final_newline_on_save = Some(false); + }); + }); + }); + } +} diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 93efddb03d8..4ea43aceb68 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -413,6 +413,7 @@ fn enqueue_notification( pub struct AcpConnection { id: AgentId, telemetry_id: SharedString, + agent_version: Option, connection: ConnectionTo, sessions: Rc>>, pending_sessions: Rc>>, @@ -900,12 +901,15 @@ impl AcpConnection { } }); - let telemetry_id = response - .agent_info + let agent_info = response.agent_info; + let telemetry_id = agent_info + .as_ref() // Use the one the agent provides if we have one - .map(|info| info.name.into()) + .map(|info| SharedString::from(info.name.clone())) // Otherwise, just use the name .unwrap_or_else(|| agent_id.0.clone()); + let agent_version = agent_info + .and_then(|info| (!info.version.is_empty()).then(|| SharedString::from(info.version))); let session_list = if response .agent_capabilities @@ -945,6 +949,7 @@ impl AcpConnection { agent_server_store, connection, telemetry_id, + agent_version, sessions, pending_sessions: Rc::new(RefCell::new(HashMap::default())), agent_capabilities: response.agent_capabilities, @@ -978,6 +983,7 @@ impl AcpConnection { Self { id: AgentId::new("test"), telemetry_id: "test".into(), + agent_version: None, connection, sessions, pending_sessions: Rc::new(RefCell::new(HashMap::default())), @@ -1319,6 +1325,10 @@ impl AgentConnection for AcpConnection { self.telemetry_id.clone() } + fn agent_version(&self) -> Option { + self.agent_version.clone() + } + fn new_session( self: Rc, project: Entity, @@ -1984,6 +1994,10 @@ pub mod test_support { self.inner.telemetry_id() } + fn agent_version(&self) -> Option { + self.inner.agent_version() + } + fn new_session( self: Rc, project: Entity, @@ -3345,6 +3359,7 @@ fn handle_request_permission( thread.request_tool_call_authorization( args.tool_call, acp_thread::PermissionOptions::Flat(args.options), + acp_thread::AuthorizationKind::PermissionGrant, cx, ) }) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index da0704889e7..67d21211026 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -17,7 +17,7 @@ use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, - ScrollHandle, Subscription, Task, WeakEntity, + ScrollHandle, Subscription, Task, TaskExt, WeakEntity, }; use itertools::Itertools; use language::LanguageRegistry; @@ -1135,10 +1135,13 @@ impl AgentConfiguration { id: agent_server_name.clone(), }; - let connection_status = self - .agent_connection_store - .read(cx) - .connection_status(&agent, cx); + let (connection_status, running_version) = { + let connection_store = self.agent_connection_store.read(cx); + ( + connection_store.connection_status(&agent, cx), + connection_store.agent_version(&agent, cx), + ) + }; let restart_button = matches!( connection_status, @@ -1252,6 +1255,7 @@ impl AgentConfiguration { AiSettingItem::new(id, display_name, status, source_kind) .icon(icon) + .when_some(running_version, |this, version| this.detail_label(version)) .when_some(restart_button, |this, button| this.action(button)) .when_some(uninstall_button, |this, button| this.action(button)) } diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 1cff19c7cf4..8eeda6447e8 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -4,7 +4,7 @@ use anyhow::Result; use collections::HashSet; use fs::Fs; use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, + DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, }; use language_model::LanguageModelRegistry; use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 465d31b416e..48d01e506bf 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -5,7 +5,8 @@ use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, - Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, + Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, + prelude::*, }; use language::{Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 9e042b8ad66..e81c14ca0e5 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -218,6 +218,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile Default Model Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let fs = self.fs.clone(); let profile_id_for_closure = profile_id.clone(); @@ -314,6 +319,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile MCPs Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let settings = AgentSettings::get_global(cx); let Some(profile) = settings.profiles.get(&profile_id).cloned() else { return; @@ -350,6 +360,11 @@ impl ManageProfilesModal { window: &mut Window, cx: &mut Context, ) { + telemetry::event!( + "Agent Profile Tools Configured", + profile_id = profile_id.as_str(), + is_builtin = builtin_profiles::is_builtin(&profile_id) + ); let settings = AgentSettings::get_global(cx); let Some(profile) = settings.profiles.get(&profile_id).cloned() else { return; @@ -398,9 +413,16 @@ impl ManageProfilesModal { Mode::ChooseProfile { .. } => {} Mode::NewProfile(mode) => { let name = mode.name_editor.read(cx).text(cx); + let base_profile_id = mode.base_profile_id.clone(); let profile_id = - AgentProfile::create(name, mode.base_profile_id.clone(), self.fs.clone(), cx); + AgentProfile::create(name, base_profile_id.clone(), self.fs.clone(), cx); + telemetry::event!( + "Agent Profile Created", + profile_id = profile_id.as_str(), + is_fork = base_profile_id.is_some(), + base_profile_id = base_profile_id.as_ref().map(|id| id.as_str()) + ); self.view_profile(profile_id, window, cx); } Mode::ViewProfile(_) => {} @@ -421,6 +443,8 @@ impl ManageProfilesModal { return; } + telemetry::event!("Agent Profile Deleted", profile_id = profile_id.as_str()); + let fs = self.fs.clone(); update_settings_file(fs, cx, move |settings, _cx| { diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index a01f19dd0f2..fb4ae4b1c4b 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -97,6 +97,13 @@ impl AgentConnectionStore { .unwrap_or(AgentConnectionStatus::Disconnected) } + pub fn agent_version(&self, key: &Agent, cx: &App) -> Option { + match self.entries.get(key)?.read(cx) { + AgentConnectionEntry::Connected(state) => state.connection.agent_version(), + AgentConnectionEntry::Connecting { .. } | AgentConnectionEntry::Error { .. } => None, + } + } + pub fn active_acp_connections(&self, cx: &App) -> Vec { self.entries .values() diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 4a5771fd981..6f4e900be42 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -15,7 +15,7 @@ use editor::{ use gpui::{ Action, AnyElement, App, AppContext, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*, + Global, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, prelude::*, }; use language::{Buffer, Capability, OffsetRangeExt, Point}; diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 8285da9e113..10fe51feb73 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,5 @@ use std::{ + fmt, path::PathBuf, rc::Rc, sync::{ @@ -32,6 +33,7 @@ use zed_actions::{ use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; +use crate::completion_provider::AgentContextSource; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, @@ -56,11 +58,12 @@ use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; +use feature_flags::{AgentPanelTerminalFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, Subscription, - Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, + Task, TaskExt, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::LanguageModelRegistry; @@ -69,7 +72,7 @@ use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; -use terminal::terminal_settings::TerminalSettings; +use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ @@ -81,6 +84,7 @@ use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + item::ItemEvent, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -98,6 +102,29 @@ impl MaxIdleRetainedThreads { } } +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct TerminalId(uuid::Uuid); + +impl TerminalId { + fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl fmt::Display for TerminalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Debug)] +pub struct AgentPanelTerminalInfo { + pub id: TerminalId, + pub title: SharedString, + pub created_at: DateTime, + pub has_notification: bool, +} + #[derive(Serialize, Deserialize)] struct LastUsedAgent { agent: Agent, @@ -154,10 +181,19 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option(&json).log_err()) } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +enum AgentPanelEntryKind { + #[default] + Thread, + Terminal, +} + #[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { selected_agent: Option, #[serde(default)] + last_created_entry_kind: AgentPanelEntryKind, + #[serde(default)] last_active_thread: Option, draft_thread_prompt: Option>, } @@ -174,9 +210,9 @@ pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { workspace - .register_action(|workspace, action: &NewThread, window, cx| { + .register_action(|workspace, _: &NewThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| panel.new_thread(action, window, cx)); + panel.update(cx, |panel, cx| panel.new_entry(Some(workspace), window, cx)); workspace.focus_panel::(window, cx); } }) @@ -455,19 +491,36 @@ pub fn init(cx: &mut App) { return; } - let Some(panel) = workspace.panel::(cx) else { + let Some(agent_panel) = workspace.panel::(cx) else { return; }; - if !panel.focus_handle(cx).contains_focused(window, cx) { + let source = AgentContextSource::from_focused(workspace, window, cx); + let source = source.or_else(|| { + let cached = agent_panel.read(cx).last_context_source.clone()?; + cached.exists(workspace, cx).then_some(cached) + }); + let source = + source.or_else(|| AgentContextSource::from_active(workspace, cx)); + + let Some(source) = source else { + return; + }; + + let Some(selection) = source.read_selection(workspace, true, cx) else { + return; + }; + + if !agent_panel.focus_handle(cx).contains_focused(window, cx) { workspace.toggle_panel_focus::(window, cx); } - panel.update(cx, |_, cx| { + agent_panel.update(cx, |panel, cx| { + panel.last_context_source = Some(source); cx.defer_in(window, move |panel, window, cx| { if let Some(conversation_view) = panel.active_conversation_view() { conversation_view.update(cx, |conversation_view, cx| { - conversation_view.insert_selections(window, cx); + conversation_view.insert_selection(selection, window, cx); }); } }); @@ -630,11 +683,60 @@ pub(crate) struct AgentThread { conversation_view: Entity, } +struct AgentTerminal { + view: Entity, + title_editor: Entity, + last_known_title: String, + created_at: DateTime, + has_notification: bool, + _subscriptions: Vec, +} + +impl AgentTerminal { + fn display_title(&self, cx: &App) -> SharedString { + let view = self.view.read(cx); + view.custom_title() + .map(SharedString::from) + .or_else(|| { + let breadcrumb_text = &view.terminal().read(cx).breadcrumb_text; + if breadcrumb_text.is_empty() { + None + } else { + Some(breadcrumb_text.clone().into()) + } + }) + .unwrap_or_else(|| SharedString::from(view.terminal().read(cx).title(true))) + } + + fn refresh_title(&mut self, window: &mut Window, cx: &mut App) -> bool { + let title = self.display_title(cx).to_string(); + let changed = self.last_known_title != title; + if changed { + self.last_known_title = title.clone(); + } + + let should_update_editor = { + let title_editor = self.title_editor.read(cx); + !title_editor.is_focused(window) && title_editor.text(cx) != title + }; + if should_update_editor { + self.title_editor.update(cx, |title_editor, cx| { + title_editor.set_text(title, window, cx); + }); + } + + changed + } +} + enum BaseView { Uninitialized, AgentThread { conversation_view: Entity, }, + Terminal { + terminal_id: TerminalId, + }, } impl From for BaseView { @@ -652,6 +754,7 @@ enum OverlayView { enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), + Terminal(&'a Entity), Configuration(Option<&'a Entity>), } @@ -662,7 +765,10 @@ enum WhichFontSize { impl BaseView { pub fn which_font_size_used(&self) -> WhichFontSize { - WhichFontSize::AgentFont + match self { + BaseView::AgentThread { .. } => WhichFontSize::AgentFont, + BaseView::Terminal { .. } | BaseView::Uninitialized => WhichFontSize::None, + } } } @@ -690,9 +796,11 @@ pub struct AgentPanel { configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, + last_created_entry_kind: AgentPanelEntryKind, overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, + terminals: HashMap, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -707,6 +815,7 @@ pub struct AgentPanel { _base_view_observation: Option, _draft_editor_observation: Option, _thread_metadata_store_subscription: Subscription, + last_context_source: Option, } impl AgentPanel { @@ -716,6 +825,7 @@ impl AgentPanel { }; let selected_agent = self.selected_agent.clone(); + let last_created_entry_kind = self.last_created_entry_kind; let is_draft_active = self.active_thread_is_draft(cx); let last_active_thread = self @@ -774,6 +884,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { selected_agent: Some(selected_agent), + last_created_entry_kind, last_active_thread, draft_thread_prompt, }, @@ -866,6 +977,7 @@ impl AgentPanel { global_last_used_agent.filter(|agent| !is_via_collab || agent.is_native()); if let Some(serialized_panel) = &serialized_panel { + panel.last_created_entry_kind = serialized_panel.last_created_entry_kind; if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } else if let Some(agent) = global_fallback { @@ -1035,6 +1147,7 @@ impl AgentPanel { let mut panel = Self { workspace_id, base_view, + last_created_entry_kind: AgentPanelEntryKind::Thread, overlay_view: None, workspace, user_store, @@ -1049,6 +1162,7 @@ impl AgentPanel { context_server_registry, draft_thread: None, retained_threads: HashMap::default(), + terminals: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -1065,6 +1179,7 @@ impl AgentPanel { _base_view_observation: None, _draft_editor_observation: None, _thread_metadata_store_subscription, + last_context_source: None, }; // Initial sync of agent servers from extensions @@ -1188,8 +1303,32 @@ impl AgentPanel { cx.notify(); } + pub fn new_entry( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_new_entry(cx) { + self.new_terminal(workspace, window, cx); + } else { + self.activate_new_thread(true, "agent_panel", window, cx); + } + } + pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.activate_draft(true, "agent_panel", window, cx); + self.new_entry(None, window, cx); + } + + pub fn activate_new_thread( + &mut self, + focus: bool, + trigger: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx); + self.activate_draft(focus, trigger, window, cx); } pub fn new_external_agent_thread( @@ -1201,7 +1340,311 @@ impl AgentPanel { if let Some(agent) = action.agent.clone() { self.selected_agent = agent; } - self.activate_draft(true, "agent_panel", window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); + } + + pub fn new_terminal( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let working_directory = workspace + .map(|workspace| terminal_view::default_working_directory(workspace, cx)) + .unwrap_or_else(|| self.default_terminal_working_directory(cx)); + self.spawn_terminal(TerminalId::new(), working_directory, true, window, cx); + } + + pub fn supports_terminal(&self, cx: &App) -> bool { + cx.has_flag::() + && self.project.read(cx).supports_terminal(cx) + } + + pub fn should_create_terminal_for_new_entry(&self, cx: &App) -> bool { + self.last_created_entry_kind == AgentPanelEntryKind::Terminal && self.supports_terminal(cx) + } + + fn set_last_created_entry_kind( + &mut self, + entry_kind: AgentPanelEntryKind, + cx: &mut Context, + ) { + if self.last_created_entry_kind != entry_kind { + self.last_created_entry_kind = entry_kind; + self.serialize(cx); + } + } + + fn spawn_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_task = self.project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }); + let workspace = self.workspace.clone(); + let workspace_id = self.workspace_id; + let project = self.project.downgrade(); + + cx.spawn_in(window, async move |this, cx| { + let terminal = match terminal_task.await { + Ok(terminal) => terminal, + Err(error) => { + log::error!("failed to spawn agent panel terminal: {error:#}"); + workspace + .update(cx, |workspace, cx| workspace.show_error(&error, cx)) + .log_err(); + return anyhow::Ok(()); + } + }; + this.update_in(cx, |this, window, cx| { + let terminal_view = cx.new(|cx| { + TerminalView::new(terminal, workspace, workspace_id, project, window, cx) + }); + this.insert_terminal(terminal_id, terminal_view, focus, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn insert_terminal( + &mut self, + terminal_id: TerminalId, + terminal_view: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let terminal_entity = terminal_view.read(cx).terminal().clone(); + let title = { + let terminal_view = terminal_view.read(cx); + terminal_view + .custom_title() + .map(ToString::to_string) + .unwrap_or_else(|| terminal_view.terminal().read(cx).title(true)) + }; + let title_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(title, window, cx); + editor + }); + let title_editor_subscription = cx.subscribe_in( + &title_editor, + window, + move |this, title_editor, event: &editor::EditorEvent, window, cx| { + this.handle_terminal_title_editor_event( + terminal_id, + title_editor, + event, + window, + cx, + ); + }, + ); + let view_subscription = cx.subscribe_in( + &terminal_view, + window, + move |this, _terminal_view, event: &ItemEvent, window, cx| match event { + ItemEvent::UpdateTab | ItemEvent::UpdateBreadcrumbs => { + this.refresh_terminal_title(terminal_id, window, cx); + } + ItemEvent::CloseItem | ItemEvent::Edit => {} + }, + ); + // Listen on the underlying `Terminal` entity for shell-driven metadata + // changes and bell. + let terminal_subscription = cx.subscribe_in( + &terminal_entity, + window, + move |this, _terminal, event: &TerminalEvent, window, cx| match event { + TerminalEvent::TitleChanged + | TerminalEvent::Wakeup + | TerminalEvent::BreadcrumbsChanged => { + this.refresh_terminal_title(terminal_id, window, cx); + } + TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), + TerminalEvent::CloseTerminal => { + this.close_terminal(terminal_id, window, cx); + } + TerminalEvent::BlinkChanged(_) + | TerminalEvent::SelectionsChanged + | TerminalEvent::NewNavigationTarget(_) + | TerminalEvent::Open(_) => {} + }, + ); + + let mut terminal = AgentTerminal { + view: terminal_view, + title_editor, + last_known_title: String::new(), + created_at: Utc::now(), + has_notification: false, + _subscriptions: vec![ + view_subscription, + terminal_subscription, + title_editor_subscription, + ], + }; + self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx); + terminal.refresh_title(window, cx); + self.terminals.insert(terminal_id, terminal); + if focus { + self.set_base_view(BaseView::Terminal { terminal_id }, true, window, cx); + } + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + pub fn activate_terminal( + &mut self, + terminal_id: TerminalId, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + let had_notification = terminal.has_notification; + terminal.has_notification = false; + self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); + if had_notification { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + pub fn close_terminal( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let was_active = self.active_terminal_id() == Some(terminal_id); + + if self.terminals.remove(&terminal_id).is_none() { + return; + } + if was_active { + self.base_view = BaseView::Uninitialized; + self.refresh_base_view_subscriptions(window, cx); + self.activate_draft(false, "agent_panel", window, cx); + } + + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + fn refresh_terminal_title( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) + && terminal.refresh_title(window, cx) + { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn handle_terminal_title_editor_event( + &mut self, + terminal_id: TerminalId, + title_editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::BufferEdited => { + if !title_editor.read(cx).is_focused(window) { + return; + } + let Some(terminal_view) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.clone()) + else { + return; + }; + let new_title = title_editor.read(cx).text(cx).trim().to_string(); + let label = if new_title.is_empty() { + None + } else { + let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true); + if new_title == terminal_title { + None + } else { + Some(new_title) + } + }; + + cx.defer(move |cx| { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(label, cx); + }); + }); + } + editor::EditorEvent::Blurred => { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.refresh_title(window, cx); + } + } + _ => {} + } + } + + fn mark_terminal_notification( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self.active_terminal_id() == Some(terminal_id); + // Only suppress when the user can actually see the bell, i.e. the + // terminal is focused AND the OS window is active. A bell delivered to + // a background window should still be marked unseen. + let user_is_looking = is_active + && window.is_window_active() + && self.terminals.get(&terminal_id).is_some_and(|terminal| { + terminal.view.focus_handle(cx).contains_focused(window, cx) + }); + if user_is_looking { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if !terminal.has_notification { + terminal.has_notification = true; + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn default_terminal_working_directory(&self, cx: &App) -> Option { + // Reuse the workspace-based helper so behavior matches the regular + // terminal panel (e.g. `WorkingDirectory::FirstProjectDirectory` falling + // back to a file's parent directory when the worktree root is a file). + self.workspace + .upgrade() + .and_then(|workspace| terminal_view::default_working_directory(workspace.read(cx), cx)) } pub fn activate_draft( @@ -1312,6 +1755,33 @@ impl AgentPanel { } } + pub fn active_terminal_id(&self) -> Option { + match &self.base_view { + BaseView::Terminal { terminal_id } => Some(*terminal_id), + _ => None, + } + } + + pub fn has_terminal(&self, terminal_id: TerminalId) -> bool { + self.terminals.contains_key(&terminal_id) + } + + pub fn terminals(&self, cx: &App) -> Vec { + if !cx.has_flag::() { + return Vec::new(); + } + + self.terminals + .iter() + .map(|(id, terminal)| AgentPanelTerminalInfo { + id: *id, + title: terminal.display_title(cx), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + }) + .collect() + } + pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option { let cv = self .retained_threads @@ -1869,7 +2339,7 @@ impl AgentPanel { }); } - self.new_thread(&NewThread, window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); if let Some((thread, model)) = self .active_native_agent_thread(cx) .zip(provider.default_model(cx)) @@ -2047,7 +2517,8 @@ impl AgentPanel { self.retain_running_thread(old_view, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { - let thread_agent = conversation_view.read(cx).agent_key().clone(); + let conversation_view = conversation_view.read(cx); + let thread_agent = conversation_view.agent_key().clone(); if self.selected_agent != thread_agent { self.selected_agent = thread_agent; self.serialize(cx); @@ -2099,7 +2570,7 @@ impl AgentPanel { let focus_handle = conversation_view.focus_handle(cx); self._active_thread_focus_subscription = Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { - cx.emit(AgentPanelEvent::ThreadFocused); + cx.emit(AgentPanelEvent::ActiveViewFocused); cx.notify(); })); Some(cx.observe_in( @@ -2114,6 +2585,26 @@ impl AgentPanel { }, )) } + BaseView::Terminal { terminal_id } => { + self._thread_view_subscription = None; + if let Some(terminal) = self.terminals.get(terminal_id) { + let terminal_id = *terminal_id; + let focus_handle = terminal.view.focus_handle(cx); + self._active_thread_focus_subscription = + Some( + cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { + if let Some(terminal) = this.terminals.get_mut(&terminal_id) { + terminal.has_notification = false; + } + cx.emit(AgentPanelEvent::ActiveViewFocused); + cx.notify(); + }), + ); + } else { + self._active_thread_focus_subscription = None; + } + None + } BaseView::Uninitialized => { self._thread_view_subscription = None; self._active_thread_focus_subscription = None; @@ -2137,6 +2628,11 @@ impl AgentPanel { BaseView::AgentThread { conversation_view } => { VisibleSurface::AgentThread(conversation_view) } + BaseView::Terminal { terminal_id } => self + .terminals + .get(terminal_id) + .map(|terminal| VisibleSurface::Terminal(&terminal.view)) + .unwrap_or(VisibleSurface::Uninitialized), } } @@ -2393,7 +2889,7 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); this.serialize(cx); } else { - cx.emit(AgentPanelEvent::RetainedThreadChanged); + cx.emit(AgentPanelEvent::EntryChanged); } cx.notify(); }) @@ -2420,6 +2916,7 @@ impl Focusable for AgentPanel { match self.visible_surface() { VisibleSurface::Uninitialized => self.focus_handle.clone(), VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), + VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), VisibleSurface::Configuration(configuration) => { if let Some(configuration) = configuration { configuration.focus_handle(cx) @@ -2437,8 +2934,8 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, + ActiveViewFocused, + EntryChanged, ThreadInteracted { thread_id: ThreadId }, } @@ -2542,6 +3039,12 @@ impl Panel for AgentPanel { true } + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.agent.get_or_insert_default().button = Some(false); + })) + } + fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { self.zoomed } @@ -2560,12 +3063,16 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() || !self.retained_threads.is_empty() { + if self.overlay_view.is_some() + || !self.retained_threads.is_empty() + || !self.terminals.is_empty() + { return true; } match &self.base_view { BaseView::Uninitialized => false, + BaseView::Terminal { .. } => true, BaseView::AgentThread { conversation_view } => { let has_entries = conversation_view .read(cx) @@ -2597,31 +3104,26 @@ impl AgentPanel { } fn active_initial_content(&self, cx: &App) -> Option { - self.active_thread_view(cx).and_then(|thread_view| { + let thread_view = self.active_thread_view(cx)?; + let thread_view = thread_view.read(cx); + let saved = thread_view + .thread + .read(cx) + .draft_prompt() + .map(|blocks| blocks.to_vec()) + .filter(|blocks| !blocks.is_empty()); + let blocks = saved.unwrap_or_else(|| { thread_view + .message_editor .read(cx) - .thread - .read(cx) - .draft_prompt() - .map(|draft| AgentInitialContent::ContentBlock { - blocks: draft.to_vec(), - auto_submit: false, - }) - .filter(|initial_content| match initial_content { - AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(), - _ => true, - }) - .or_else(|| { - let text = thread_view.read(cx).message_editor.read(cx).text(cx); - if text.trim().is_empty() { - None - } else { - Some(AgentInitialContent::ContentBlock { - blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], - auto_submit: false, - }) - } - }) + .draft_content_blocks_snapshot(cx) + }); + if blocks.is_empty() { + return None; + } + Some(AgentInitialContent::ContentBlock { + blocks, + auto_submit: false, }) } @@ -2755,6 +3257,30 @@ impl AgentPanel { .into_any_element() } } + VisibleSurface::Terminal(_) => { + if let Some((title_editor, terminal_view)) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .map(|terminal| (terminal.title_editor.clone(), terminal.view.clone())) + { + let terminal_view_cancel = terminal_view.clone(); + div() + .flex_1() + .on_action(move |_: &menu::Confirm, window, cx| { + terminal_view.focus_handle(cx).focus(window, cx); + }) + .on_action(move |_: &editor::actions::Cancel, window, cx| { + terminal_view_cancel.focus_handle(cx).focus(window, cx); + }) + .child(title_editor) + .into_any_element() + } else { + Label::new("Terminal") + .color(Color::Muted) + .truncate() + .into_any_element() + } + } VisibleSurface::Configuration(_) => { Label::new("Settings").truncate().into_any_element() } @@ -2900,25 +3426,28 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + let supports_terminal = self.supports_terminal(cx); - let (selected_agent_custom_icon, selected_agent_label) = - if let Agent::Custom { id, .. } = &self.selected_agent { - let store = agent_server_store.read(cx); - let icon = store.agent_icon(&id); + let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_)); + let (selected_agent_custom_icon, selected_agent_label) = if showing_terminal { + (None, SharedString::from("Terminal")) + } else if let Agent::Custom { id, .. } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&id); - let label = store - .agent_display_name(&id) - .unwrap_or_else(|| self.selected_agent.label()); - (icon, label) - } else { - (None, self.selected_agent.label()) - }; + let label = store + .agent_display_name(&id) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) + } else { + (None, self.selected_agent.label()) + }; let active_thread = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).as_native_thread(cx) } - BaseView::Uninitialized => None, + BaseView::Terminal { .. } | BaseView::Uninitialized => None, }; let new_thread_menu_builder: Rc< @@ -2993,6 +3522,33 @@ impl AgentPanel { } }), ) + .when(supports_terminal, |menu| { + menu.item( + ContextMenuEntry::new("Terminal") + .icon(IconName::Terminal) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_terminal( + Some(workspace), + window, + cx, + ); + }); + } + }); + } + } + }), + ) + }) .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); let registry_store = project::AgentRegistryStore::try_global(cx); @@ -3110,7 +3666,11 @@ impl AgentPanel { let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); - let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_builtin_icon = if showing_terminal { + Some(IconName::Terminal) + } else { + self.selected_agent.icon() + }; let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() @@ -3150,7 +3710,8 @@ impl AgentPanel { selected_agent.into_any_element() }; - let is_empty_state = !self.active_thread_has_messages(cx); + let is_empty_state = !matches!(self.base_view, BaseView::Terminal { .. }) + && !self.active_thread_has_messages(cx); let is_in_history_or_config = self.is_overlay_open(); @@ -3326,7 +3887,7 @@ impl AgentPanel { return false; } } - BaseView::Uninitialized => { + BaseView::Terminal { .. } | BaseView::Uninitialized => { return false; } } @@ -3378,7 +3939,7 @@ impl AgentPanel { }); match &self.base_view { - BaseView::Uninitialized => false, + BaseView::Uninitialized | BaseView::Terminal { .. } => false, BaseView::AgentThread { conversation_view } => { if conversation_view.read(cx).as_native_thread(cx).is_some() { let history_is_empty = ThreadStore::global(cx).read(cx).is_empty(); @@ -3507,16 +4068,18 @@ impl AgentPanel { conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - BaseView::Uninitialized => {} + BaseView::Terminal { .. } | BaseView::Uninitialized => {} } } fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - match &self.base_view { - BaseView::AgentThread { .. } => key_context.add("acp_thread"), - BaseView::Uninitialized => {} + match self.visible_surface() { + VisibleSurface::AgentThread(_) => key_context.add("acp_thread"), + VisibleSurface::Terminal(_) + | VisibleSurface::Configuration(_) + | VisibleSurface::Uninitialized => {} } key_context } @@ -3566,6 +4129,7 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), + VisibleSurface::Terminal(terminal_view) => parent.child(terminal_view.clone()), VisibleSurface::Configuration(configuration) => { parent.children(configuration.cloned()) } @@ -3745,6 +4309,61 @@ impl AgentPanel { self.draft_thread = Some(thread.conversation_view.clone()); self.set_base_view(thread.into(), true, window, cx); } + + #[cfg(any(test, feature = "test-support"))] + pub fn insert_test_terminal( + &mut self, + title: impl Into, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) -> Result { + if !cx.has_flag::() { + anyhow::bail!("agent-panel-terminal feature flag must be enabled"); + } + + let terminal_id = TerminalId::new(); + let settings = TerminalSettings::get_global(cx).clone(); + let path_style = self.project.read(cx).path_style(cx); + let builder = terminal::TerminalBuilder::new_display_only( + settings.cursor_shape, + settings.alternate_scroll, + settings.max_scroll_history_lines, + cx.entity_id().as_u64(), + cx.background_executor(), + path_style, + )?; + let terminal = cx.new(|cx| builder.subscribe(cx)); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal, + self.workspace.clone(), + self.workspace_id, + self.project.downgrade(), + window, + cx, + ) + }); + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(Some(title.into()), cx); + }); + self.insert_terminal(terminal_id, terminal_view, focus, window, cx); + Ok(terminal_id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn emit_test_terminal_bell(&mut self, terminal_id: TerminalId, cx: &mut Context) { + let Some(terminal_entity) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.read(cx).terminal().clone()) + else { + return; + }; + terminal_entity.update(cx, |_terminal, cx| { + cx.emit(TerminalEvent::Bell); + }); + } } #[cfg(test)] @@ -4719,6 +5338,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); let project = Project::test(fs.clone(), [], cx).await; let multi_workspace = @@ -4737,6 +5357,98 @@ mod tests { (panel, cx) } + #[gpui::test] + async fn test_terminal_entry_kind_controls_new_entry(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + panel.read_with(&cx, |panel, cx| { + assert!(panel.supports_terminal(cx)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.has_terminal(terminal_id)); + assert!(panel.should_create_terminal_for_new_entry(cx)); + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Dev Server"); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_new_thread(false, "test", window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), None); + assert!(panel.has_terminal(terminal_id)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + } + + #[gpui::test] + async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let first_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + }); + + panel.update(&mut cx, |panel, cx| { + panel.emit_test_terminal_bell(first_terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(first_terminal.has_notification); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_terminal(first_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(!first_terminal.has_notification); + }); + } + #[gpui::test] async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 226471fc024..75862241193 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -61,7 +61,9 @@ use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads}; +pub use crate::agent_panel::{ + AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, +}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 59a6cb4c924..32f98d7fc57 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -12,7 +12,7 @@ use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; -use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, BackgroundExecutor, Entity, Focusable, SharedString, Task, WeakEntity, Window}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; use multi_buffer::ToOffset as _; @@ -24,7 +24,7 @@ use project::{ }; use prompt_store::{PromptStore, UserPromptId}; use rope::Point; -use settings::{Settings, TerminalDockPosition}; +use settings::Settings; use terminal::terminal_settings::TerminalSettings; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{Anchor, ToOffset as _, ToPoint as _}; @@ -35,11 +35,108 @@ use util::paths::PathStyle; use util::rel_path::RelPath; use util::truncate_and_remove_front; use workspace::Workspace; -use workspace::dock::DockPosition; use crate::AgentPanel; use crate::mention_set::MentionSet; +#[derive(Clone)] +pub(crate) enum AgentContextSelection { + Editor(Vec<(Entity, Range)>), + Terminal(Vec), +} + +#[derive(Clone)] +pub(crate) enum AgentContextSource { + Editor(WeakEntity), + TerminalView(WeakEntity), + TerminalPanel, +} + +impl AgentContextSource { + pub(crate) fn read_selection( + &self, + workspace: &Workspace, + include_current_line: bool, + cx: &mut App, + ) -> Option { + match self { + Self::Editor(handle) => { + let editor = handle.upgrade()?; + let ranges = editor_selection_ranges(&editor, include_current_line, cx); + (!ranges.is_empty()).then_some(AgentContextSelection::Editor(ranges)) + } + Self::TerminalView(handle) => { + let terminal_view = handle.upgrade()?; + terminal_view_selection(&terminal_view, cx) + .map(|text| AgentContextSelection::Terminal(vec![text])) + } + Self::TerminalPanel => { + let panel = workspace.panel::(cx)?; + let selections = panel.read(cx).terminal_selections(cx); + (!selections.is_empty()).then_some(AgentContextSelection::Terminal(selections)) + } + } + } + + pub(crate) fn from_focused(workspace: &Workspace, window: &Window, cx: &App) -> Option { + if let Some(agent_panel) = workspace.panel::(cx) + && agent_panel.focus_handle(cx).contains_focused(window, cx) + { + return None; + } + + if let Some(active_item) = workspace.active_item(cx) { + if let Some(editor) = active_item.act_as::(cx) { + if editor.focus_handle(cx).is_focused(window) { + return Some(Self::Editor(editor.downgrade())); + } + } else if let Some(terminal_view) = active_item.act_as::(cx) + && terminal_view.focus_handle(cx).is_focused(window) + { + return Some(Self::TerminalView(terminal_view.downgrade())); + } + } + + if let Some(panel) = workspace.panel::(cx) + && panel.focus_handle(cx).contains_focused(window, cx) + { + return Some(Self::TerminalPanel); + } + + None + } + + pub(crate) fn from_active(workspace: &Workspace, cx: &App) -> Option { + if let Some(active_item) = workspace.active_item(cx) { + if let Some(editor) = active_item.act_as::(cx) { + return Some(Self::Editor(editor.downgrade())); + } else if let Some(terminal_view) = active_item.act_as::(cx) { + return Some(Self::TerminalView(terminal_view.downgrade())); + } + } + if terminal_panel_dock_is_open(workspace, cx) { + return Some(Self::TerminalPanel); + } + None + } + + pub(crate) fn exists(&self, workspace: &Workspace, cx: &App) -> bool { + match self { + Self::Editor(handle) => handle.upgrade().is_some(), + Self::TerminalView(handle) => handle.upgrade().is_some(), + Self::TerminalPanel => terminal_panel_dock_is_open(workspace, cx), + } + } +} + +fn terminal_panel_dock_is_open(workspace: &Workspace, cx: &App) -> bool { + if workspace.panel::(cx).is_none() { + return false; + } + let position = TerminalSettings::get_global(cx).dock.into(); + workspace.dock_at_position(position).read(cx).is_open() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PromptContextEntry { Mode(PromptContextType), @@ -267,14 +364,13 @@ impl PromptCompletionProvider { // inserted confirm: Some(Arc::new(|_, _, _| true)), }), - PromptContextEntry::Action(action) => Self::completion_for_action( - action, - source_range, - editor, - mention_set, - workspace, - cx, - ), + PromptContextEntry::Action(action) => { + let selection = workspace.update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }); + Self::completion_for_action(action, source_range, editor, mention_set, selection) + } } } @@ -542,136 +638,27 @@ impl PromptCompletionProvider { source_range: Range, editor: WeakEntity, mention_set: WeakEntity, - workspace: &Entity, - cx: &mut App, + selection: Option, ) -> Option { let (new_text, on_action) = match action { - PromptContextAction::AddSelections => { - // Collect non-empty editor selections - let editor_selections: Vec<_> = selection_ranges(workspace, cx) - .into_iter() - .filter(|(buffer, range)| { - let snapshot = buffer.read(cx).snapshot(); - range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot) - }) - .collect(); - - // Collect terminal selections from all terminal views if the terminal panel is visible - let terminal_selections: Vec = terminal_selections(workspace, cx); - - const EDITOR_PLACEHOLDER: &str = "selection "; - const TERMINAL_PLACEHOLDER: &str = "terminal "; - - let selections = editor_selections - .into_iter() - .enumerate() - .map(|(ix, (buffer, range))| { - ( - buffer, - range, - (EDITOR_PLACEHOLDER.len() * ix) - ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1), - ) - }) - .collect::>(); - - let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len()); - - // Add terminal placeholders for each terminal selection - let terminal_ranges: Vec<(String, std::ops::Range)> = terminal_selections - .into_iter() - .map(|text| { - let start = new_text.len(); - new_text.push_str(TERMINAL_PLACEHOLDER); - (text, start..(new_text.len() - 1)) - }) - .collect(); - - let callback = Arc::new({ - let source_range = source_range.clone(); - move |_: CompletionIntent, window: &mut Window, cx: &mut App| { - let editor = editor.clone(); - let selections = selections.clone(); - let mention_set = mention_set.clone(); - let source_range = source_range.clone(); - let terminal_ranges = terminal_ranges.clone(); - window.defer(cx, move |window, cx| { - if let Some(editor) = editor.upgrade() { - // Insert editor selections - if !selections.is_empty() { - mention_set - .update(cx, |store, cx| { - store.confirm_mention_for_selection( - source_range.clone(), - selections, - editor.clone(), - window, - cx, - ) - }) - .ok(); - } - - // Insert terminal selections - for (terminal_text, terminal_range) in terminal_ranges { - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let Some(start) = - snapshot.anchor_in_excerpt(source_range.start) - else { - return; - }; - let offset = start.to_offset(&snapshot); - - let line_count = terminal_text.lines().count() as u32; - let mention_uri = MentionUri::TerminalSelection { line_count }; - let range = snapshot.anchor_after(offset + terminal_range.start) - ..snapshot.anchor_after(offset + terminal_range.end); - - let crease = crate::mention_set::crease_for_mention( - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - range, - editor.downgrade(), - ); - - let crease_id = editor.update(cx, |editor, cx| { - let crease_ids = - editor.insert_creases(vec![crease.clone()], cx); - editor.fold_creases(vec![crease], false, window, cx); - crease_ids.first().copied().unwrap() - }); - - mention_set - .update(cx, |mention_set, _| { - mention_set.insert_mention( - crease_id, - mention_uri.clone(), - gpui::Task::ready(Ok( - crate::mention_set::Mention::Text { - content: terminal_text, - tracked_buffers: vec![], - }, - )) - .shared(), - ); - }) - .ok(); - } - } - }); - false - } - }); - - ( - new_text, - callback - as Arc< - dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync, - >, - ) - } + PromptContextAction::AddSelections => match selection? { + AgentContextSelection::Editor(editor_selections) => { + completion_text_for_editor_selections( + source_range.clone(), + editor, + mention_set, + editor_selections, + ) + } + AgentContextSelection::Terminal(terminal_selections) => { + completion_text_for_terminal_selections( + source_range.clone(), + editor, + mention_set, + terminal_selections, + ) + } + }, }; Some(Completion { @@ -1166,19 +1153,12 @@ impl PromptCompletionProvider { entries.push(PromptContextEntry::Mode(PromptContextType::Thread)); } - let has_editor_selection = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.downcast::()) - .is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = !terminal_selections(workspace, cx).is_empty(); - - if has_editor_selection || has_terminal_selection { + let has_active_selection = workspace.update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx) + .and_then(|source| source.read_selection(workspace, false, cx)) + .is_some() + }); + if has_active_selection { entries.push(PromptContextEntry::Action( PromptContextAction::AddSelections, )); @@ -2168,81 +2148,219 @@ fn build_code_label_for_path( label.build() } -fn terminal_selections(workspace: &Entity, cx: &App) -> Vec { - let mut selections = Vec::new(); - - // Check if the active item is a terminal (in a panel or not) - if let Some(terminal_view) = workspace +fn terminal_view_selection(terminal_view: &Entity, cx: &App) -> Option { + terminal_view .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - { - if let Some(text) = terminal_view - .read(cx) - .terminal() - .read(cx) - .last_content - .selection_text - .clone() - .filter(|text| !text.is_empty()) - { - selections.push(text); - } - } - - if let Some(panel) = workspace.read(cx).panel::(cx) { - let position = match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - }; - let dock_is_open = workspace - .read(cx) - .dock_at_position(position) - .read(cx) - .is_open(); - if dock_is_open { - selections.extend(panel.read(cx).terminal_selections(cx)); - } - } - - selections + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + .filter(|text| !text.is_empty()) } -fn selection_ranges( - workspace: &Entity, +fn editor_selection_ranges( + editor: &Entity, + include_current_line: bool, cx: &mut App, ) -> Vec<(Entity, Range)> { - let Some(editor) = workspace - .read(cx) - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - else { - return Vec::new(); - }; - editor.update(cx, |editor, cx| { let selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); - let buffer = editor.buffer().clone().read(cx); - let snapshot = buffer.snapshot(cx); + let multi_buffer = editor.buffer().read(cx); + let multi_buffer_snapshot = multi_buffer.snapshot(cx); - selections - .into_iter() + let non_empty_rows: collections::HashSet = selections + .iter() .filter(|s| !s.is_empty()) - .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end)) - .flat_map(|range| { - let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?; - let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?; - if start_buffer != end_buffer { - return None; + .flat_map(|s| s.start.row..=s.end.row) + .collect(); + + let mut seen_current_line_rows = collections::HashSet::default(); + let mut results = Vec::new(); + + for s in selections { + if s.is_empty() { + if !include_current_line + || non_empty_rows.contains(&s.start.row) + || !seen_current_line_rows.insert(s.start.row) + { + continue; } - Some((start_buffer, start..end)) - }) - .collect::>() + let Some((buffer, anchor)) = multi_buffer.text_anchor_for_position(s.start, cx) + else { + continue; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let row = anchor.to_point(&buffer_snapshot).row; + let line_start = text::Point::new(row, 0); + let line_end = text::Point::new(row, buffer_snapshot.line_len(row)); + let start = buffer_snapshot.anchor_after(line_start); + let end = buffer_snapshot.anchor_before(line_end); + if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) { + continue; + } + results.push((buffer, start..end)); + } else { + let mb_start = multi_buffer_snapshot.anchor_after(s.start); + let mb_end = multi_buffer_snapshot.anchor_before(s.end); + let Some((start_buffer, start)) = + multi_buffer.text_anchor_for_position(mb_start, cx) + else { + continue; + }; + let Some((end_buffer, end)) = multi_buffer.text_anchor_for_position(mb_end, cx) + else { + continue; + }; + if start_buffer != end_buffer { + continue; + } + let buffer_snapshot = start_buffer.read(cx).snapshot(); + if start.to_offset(&buffer_snapshot) == end.to_offset(&buffer_snapshot) { + continue; + } + results.push((start_buffer, start..end)); + } + } + + results }) } +type ConfirmCallback = Arc bool + Send + Sync>; + +fn completion_text_for_editor_selections( + source_range: Range, + editor: WeakEntity, + mention_set: WeakEntity, + editor_selections: Vec<(Entity, Range)>, +) -> (String, ConfirmCallback) { + const EDITOR_PLACEHOLDER: &str = "selection "; + + let selections = editor_selections + .into_iter() + .enumerate() + .map(|(ix, (buffer, range))| { + ( + buffer, + range, + (EDITOR_PLACEHOLDER.len() * ix)..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1), + ) + }) + .collect::>(); + + let new_text = EDITOR_PLACEHOLDER.repeat(selections.len()); + + let callback: ConfirmCallback = Arc::new({ + move |_: CompletionIntent, window: &mut Window, cx: &mut App| { + let editor = editor.clone(); + let selections = selections.clone(); + let mention_set = mention_set.clone(); + let source_range = source_range.clone(); + window.defer(cx, move |window, cx| { + if let Some(editor) = editor.upgrade() + && !selections.is_empty() + { + mention_set + .update(cx, |store, cx| { + store.confirm_mention_for_selection( + source_range.clone(), + selections, + editor.clone(), + window, + cx, + ) + }) + .ok(); + } + }); + false + } + }); + + (new_text, callback) +} + +fn completion_text_for_terminal_selections( + source_range: Range, + editor: WeakEntity, + mention_set: WeakEntity, + terminal_selections: Vec, +) -> (String, ConfirmCallback) { + const TERMINAL_PLACEHOLDER: &str = "terminal "; + + let mut new_text = String::new(); + let terminal_ranges: Vec<(String, std::ops::Range)> = terminal_selections + .into_iter() + .map(|text| { + let start = new_text.len(); + new_text.push_str(TERMINAL_PLACEHOLDER); + (text, start..(new_text.len() - 1)) + }) + .collect(); + + let callback: ConfirmCallback = Arc::new({ + move |_: CompletionIntent, window: &mut Window, cx: &mut App| { + let editor = editor.clone(); + let mention_set = mention_set.clone(); + let source_range = source_range.clone(); + let terminal_ranges = terminal_ranges.clone(); + window.defer(cx, move |window, cx| { + let Some(editor) = editor.upgrade() else { + return; + }; + for (terminal_text, terminal_range) in terminal_ranges { + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let Some(start) = snapshot.anchor_in_excerpt(source_range.start) else { + return; + }; + let offset = start.to_offset(&snapshot); + + let line_count = terminal_text.lines().count() as u32; + let mention_uri = MentionUri::TerminalSelection { line_count }; + let range = snapshot.anchor_after(offset + terminal_range.start) + ..snapshot.anchor_after(offset + terminal_range.end); + + let crease = crate::mention_set::crease_for_mention( + mention_uri.name().into(), + mention_uri.icon_path(cx), + None, + range, + editor.downgrade(), + ); + + let Some(crease_id) = editor.update(cx, |editor, cx| { + let crease_ids = editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + crease_ids.first().copied() + }) else { + log::error!("insert_creases returned no ids for terminal selection"); + continue; + }; + + mention_set + .update(cx, |mention_set, _| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + Task::ready(Ok(crate::mention_set::Mention::Text { + content: terminal_text, + tracked_buffers: vec![], + })) + .shared(), + ); + }) + .ok(); + } + }); + false + } + }); + + (new_text, callback) +} + #[cfg(test)] mod tests { use super::*; @@ -2652,4 +2770,71 @@ mod tests { "dir1/a.txt should be second" ); } + + #[gpui::test] + async fn test_source_read_selection_editor_whole_line(cx: &mut TestAppContext) { + use editor::Editor; + use project::Project; + use serde_json::json; + use text::ToOffset as _; + use util::path; + use workspace::{AppState, MultiWorkspace}; + + crate::conversation_view::tests::init_test(cx); + + let app_state = cx.update(AppState::test); + + app_state + .fs + .as_fake() + .insert_tree(path!("/root"), json!({ "a.txt": "" })) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let buffer = cx.new(|cx| language::Buffer::local("abc\ndef\nghi", cx)); + let editor = + cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx)); + + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([text::Point::new(1, 1)..text::Point::new(1, 1)]); + }); + }); + + let source = AgentContextSource::Editor(editor.downgrade()); + + workspace.update(cx, |workspace, cx| { + let selection = source + .read_selection(workspace, true, cx) + .expect("editor source with cursor on a line should yield a selection"); + assert!( + matches!(selection, AgentContextSelection::Editor(_)), + "expected Editor variant" + ); + if let AgentContextSelection::Editor(ranges) = selection { + assert_eq!( + ranges.len(), + 1, + "expected exactly one range for whole-line fallback" + ); + let (range_buffer, range) = &ranges[0]; + let snapshot = range_buffer.read(cx).snapshot(); + let start_offset = range.start.to_offset(&snapshot); + let end_offset = range.end.to_offset(&snapshot); + assert_eq!( + &snapshot.text()[start_offset..end_offset], + "def", + "whole-line fallback should capture the current row" + ); + } + + // With include_current_line = false and no non-empty selection, the + // fallback is suppressed and read_selection should return None. + assert!(source.read_selection(workspace, false, cx).is_none()); + }); + } } diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c01d8d8c04c..20f5212dbce 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -31,7 +31,7 @@ use futures::FutureExt as _; use gpui::{ Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, - ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, + ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TaskExt, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point, pulsating_between, }; @@ -80,6 +80,7 @@ use crate::agent_connection_store::{ AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore, }; use crate::agent_diff::AgentDiff; +use crate::completion_provider::AgentContextSelection; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; @@ -2707,10 +2708,10 @@ impl ConversationView { &panel, window, move |this, _, event: &AgentPanelEvent, window, cx| match event { - AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => { + AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => { dismiss_if_visible(this, window, cx); } - AgentPanelEvent::RetainedThreadChanged + AgentPanelEvent::EntryChanged | AgentPanelEvent::ThreadInteracted { .. } => {} }, )); @@ -2760,11 +2761,16 @@ impl ConversationView { /// Inserts the selected text into the message editor or the message being /// edited, if any. - pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context) { + pub(crate) fn insert_selection( + &self, + selection: AgentContextSelection, + window: &mut Window, + cx: &mut Context, + ) { if let Some(active_thread) = self.active_thread() { active_thread.update(cx, |thread, cx| { thread.active_editor(cx).update(cx, |editor, cx| { - editor.insert_selections(window, cx); + editor.insert_selections(selection, window, cx); }) }); } @@ -2974,6 +2980,7 @@ pub(crate) mod tests { use workspace::{Item, MultiWorkspace}; use crate::agent_panel; + use crate::completion_provider::AgentContextSource; use crate::thread_metadata_store::ThreadMetadataStore; use super::*; @@ -5903,7 +5910,14 @@ pub(crate) mod tests { .and_then(|active| active.read(cx).editing_message), Some(0) ); - view.insert_selections(window, cx); + let workspace = workspace.upgrade().unwrap(); + let selection = workspace + .update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }) + .unwrap(); + view.insert_selection(selection, window, cx); }); user_message_editor.read_with(cx, |editor, cx| { @@ -5966,7 +5980,14 @@ pub(crate) mod tests { .and_then(|active| active.read(cx).editing_message), None ); - view.insert_selections(window, cx); + let workspace = view.workspace.upgrade().unwrap(); + let selection = workspace + .update(cx, |workspace, cx| { + AgentContextSource::from_active(workspace, cx)? + .read_selection(workspace, false, cx) + }) + .unwrap(); + view.insert_selection(selection, window, cx); }); message_editor.read_with(cx, |editor, cx| { @@ -7099,6 +7120,7 @@ pub(crate) mod tests { "Allow", acp::PermissionOptionKind::AllowOnce, )]), + acp_thread::AuthorizationKind::PermissionGrant, cx, ) .unwrap() diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0e0b3d04a8d..c8971392941 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -13,6 +13,7 @@ use feature_flags::AcpBetaFeatureFlag; use crate::message_editor::SharedSessionCapabilities; use gpui::List; +use gpui::TaskExt; use heapless::Vec as ArrayVec; use language_model::{LanguageModelEffortLevel, Speed}; use settings::{SidebarSide, update_settings_file}; diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index d442a61e01a..b13a9b615b6 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -35,8 +35,8 @@ use editor::{ use fs::Fs; use futures::{FutureExt, channel::mpsc}; use gpui::{ - App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, - WeakEntity, Window, point, + App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, TaskExt, + UpdateGlobal, WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 8c98b9458bb..a1d4065ea20 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -170,6 +170,10 @@ impl MentionSet { self.mentions.keys().cloned().collect() } + pub fn is_empty(&self) -> bool { + self.mentions.is_empty() + } + pub fn mentions(&self) -> HashSet { self.mentions.values().map(|(uri, _)| uri.clone()).collect() } @@ -178,6 +182,16 @@ impl MentionSet { self.mentions.get(crease_id).map(|(uri, _)| uri.clone()) } + /// Returns the resolved mention for a crease, if any. + pub fn resolved_mention_for_crease( + &self, + crease_id: &CreaseId, + ) -> Option<(MentionUri, Option)> { + let (uri, task) = self.mentions.get(crease_id)?; + let mention = task.clone().now_or_never().and_then(|result| result.ok()); + Some((uri.clone(), mention)) + } + pub fn set_mentions(&mut self, mentions: HashMap) { self.mentions = mentions; } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index d839e87d98e..67be4804d54 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -3,8 +3,8 @@ use crate::SendImmediately; use crate::{ ChatWithFollow, completion_provider::{ - PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, - PromptContextType, SlashCommandCompletion, + AgentContextSelection, PromptCompletionProvider, PromptCompletionProviderDelegate, + PromptContextAction, PromptContextType, SlashCommandCompletion, }, mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; @@ -15,14 +15,16 @@ use anyhow::{Result, anyhow}; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, - actions::{Copy, Paste}, + actions::{Copy, Cut, Paste}, code_context_menus::CodeContextMenu, + display_map::{CreaseId, CreaseSnapshot}, scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; use gpui::{ AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, + Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, TextStyle, + WeakEntity, }; use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; @@ -33,7 +35,7 @@ use project::{ use prompt_store::PromptStore; use rope::Point; use settings::Settings; -use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; +use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc}; use theme_settings::ThemeSettings; use ui::{ContextMenu, prelude::*}; use util::paths::PathStyle; @@ -676,7 +678,7 @@ impl MessageEditor { } pub fn is_empty(&self, cx: &App) -> bool { - self.editor.read(cx).is_empty(cx) + self.editor.read(cx).text(cx).trim().is_empty() } pub fn is_completions_menu_visible(&self, cx: &App) -> bool { @@ -767,90 +769,46 @@ impl MessageEditor { self.session_capabilities.read().supports_embedded_context(); cx.spawn(async move |_, cx| { - let contents = contents.await?; - let mut all_tracked_buffers = Vec::new(); - - let result = editor.update(cx, |editor, cx| { + let mut contents = contents.await?; + Ok(editor.update(cx, |editor, cx| { + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); let text = editor.text(cx); - let (mut ix, _) = text - .char_indices() - .find(|(_, c)| !c.is_whitespace()) - .unwrap_or((0, '\0')); - let mut chunks: Vec = Vec::new(); - editor.display_map.update(cx, |map, cx| { - let snapshot = map.snapshot(cx); - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - let Some((uri, mention)) = contents.get(&crease_id) else { - continue; - }; - - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); - if crease_range.start.0 > ix { - let chunk = text[ix..crease_range.start.0].into(); - chunks.push(chunk); - } - let chunk = match mention { - Mention::Text { - content, - tracked_buffers, - } => { - all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource::new( - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents::new( - content.clone(), - uri.to_uri().to_string(), - ), - ), - )) - } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink::new( - uri.name(), - uri.to_uri().to_string(), - )) - } - } - Mention::Image(mention_image) => acp::ContentBlock::Image( - acp::ImageContent::new( - mention_image.data.clone(), - mention_image.format.mime_type(), - ) - .uri(match uri { - MentionUri::File { .. } => Some(uri.to_uri().to_string()), - MentionUri::PastedImage { .. } => { - Some(uri.to_uri().to_string()) - } - other => { - debug_panic!( - "unexpected mention uri for image: {:?}", - other - ); - None - } - }), - ), - Mention::Link => acp::ContentBlock::ResourceLink( - acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), - ), - }; - chunks.push(chunk); - ix = crease_range.end.0; - } - - if ix < text.len() { - let last_chunk = text[ix..].trim_end().to_owned(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }); - anyhow::Ok((chunks, all_tracked_buffers)) - })?; - Ok(result) + build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| { + contents + .remove(crease_id) + .map(|(uri, mention)| (uri, Some(mention))) + }, + ) + })) }) } + /// Snapshots the editor's current draft into a list of `ContentBlock`s + /// without awaiting any pending mention resolution. + pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec { + let editor = self.editor.read(cx); + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let text = editor.text(cx); + let mention_set = self.mention_set.read(cx); + let supports_embedded_context = + self.session_capabilities.read().supports_embedded_context(); + let (chunks, _tracked_buffers) = build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| mention_set.resolved_mention_for_crease(crease_id), + ); + chunks + } + pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); @@ -1222,7 +1180,7 @@ impl MessageEditor { } fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - let Some(text) = self.serialized_copy_text(cx) else { + let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else { cx.propagate(); return; }; @@ -1231,6 +1189,24 @@ impl MessageEditor { cx.write_to_clipboard(ClipboardItem::new_string(text)); } + fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + let Some((text, ranges)) = self.serialize_selection_with_mentions(true, cx) else { + cx.propagate(); + return; + }; + + cx.stop_propagation(); + self.editor.update(cx, |editor, cx| { + editor.transact(window, cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges(ranges); + }); + editor.insert("", window, cx); + }); + }); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { let editor = self.editor.clone(); window.defer(cx, move |window, cx| { @@ -1407,7 +1383,12 @@ impl MessageEditor { .detach_and_log_err(cx); } - pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn insert_selections( + &mut self, + selection: AgentContextSelection, + window: &mut Window, + cx: &mut Context, + ) { let editor = self.editor.read(cx); let editor_buffer = editor.buffer().read(cx); let Some(buffer) = editor_buffer.as_singleton() else { @@ -1418,17 +1399,13 @@ impl MessageEditor { let anchor = buffer.update(cx, |buffer, _cx| { buffer.anchor_before(cursor_offset.0.min(buffer.len())) }); - let Some(workspace) = self.workspace.upgrade() else { - return; - }; let Some(completion) = PromptCompletionProvider::::completion_for_action( PromptContextAction::AddSelections, anchor..anchor, self.editor.downgrade(), self.mention_set.downgrade(), - &workspace, - cx, + Some(selection), ) else { return; @@ -1730,12 +1707,20 @@ impl MessageEditor { }); } - fn serialized_copy_text(&self, cx: &mut App) -> Option { + fn serialize_selection_with_mentions( + &self, + expand_empty_to_line: bool, + cx: &mut App, + ) -> Option<(String, Vec>)> { + if self.mention_set.read(cx).is_empty() { + return None; + } + let display_snapshot = self .editor .update(cx, |editor, cx| editor.display_snapshot(cx)); let editor = self.editor.read(cx); - if !editor.has_non_empty_selection(&display_snapshot) { + if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) { return None; } @@ -1756,48 +1741,55 @@ impl MessageEditor { }) .collect::>(); + let line_mode = editor.selections.line_mode(); + let max_point = snapshot.max_point(); + let point_selections = editor.selections.all::(&display_snapshot); + let mut text = String::new(); + let mut ranges = Vec::with_capacity(point_selections.len()); let mut has_mentions = false; let mut is_first = true; + let mut prev_was_entire_line = false; + + for mut selection in point_selections { + let is_entire_line = (selection.is_empty() && expand_empty_to_line) || line_mode; + if is_entire_line { + selection.start = Point::new(selection.start.row, 0); + if !selection.is_empty() && selection.end.column == 0 { + selection.end = min(max_point, selection.end); + } else { + selection.end = min(max_point, Point::new(selection.end.row + 1, 0)); + } + } + let range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot); - for selection in editor - .selections - .all::(&display_snapshot) - { if is_first { is_first = false; - } else { + } else if !prev_was_entire_line { text.push('\n'); } + prev_was_entire_line = is_entire_line; - let mut overlapping_mentions = mention_ranges + let mut cursor = range.start; + for (start, end, uri) in mention_ranges .iter() - .filter(|(start, end, _)| *start < selection.end && selection.start < *end) - .peekable(); - - if overlapping_mentions.peek().is_none() { - text.extend(snapshot.text_for_range(selection.start..selection.end)); - continue; - } - - has_mentions = true; - - let mut cursor = selection.start; - for (start, end, uri) in overlapping_mentions { + .filter(|(start, end, _)| *start < range.end && range.start < *end) + { if cursor < *start { text.extend(snapshot.text_for_range(cursor..*start)); } - write!(text, "{}", uri.as_link()).unwrap(); cursor = *end; + has_mentions = true; + } + if cursor < range.end { + text.extend(snapshot.text_for_range(cursor..range.end)); } - if cursor < selection.end { - text.extend(snapshot.text_for_range(cursor..selection.end)); - } + ranges.push(range); } - has_mentions.then_some(text) + has_mentions.then_some((text, ranges)) } } @@ -1816,6 +1808,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) .capture_action(cx.listener(Self::copy)) + .capture_action(cx.listener(Self::cut)) .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() @@ -1873,6 +1866,92 @@ impl Addon for MessageEditorAddon { } } +/// Walks the editor's creases in order, interleaving plain-text chunks from +/// `text` with mention blocks produced from `resolve`. +fn build_chunks_from_creases( + text: &str, + crease_snapshot: &CreaseSnapshot, + buffer_snapshot: &MultiBufferSnapshot, + supports_embedded_context: bool, + mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option)>, +) -> (Vec, Vec>) { + let mut ix = text + .char_indices() + .find(|(_, c)| !c.is_whitespace()) + .map_or(text.len(), |(i, _)| i); + let mut chunks = Vec::new(); + let mut tracked_buffers = Vec::new(); + + for (crease_id, crease) in crease_snapshot.creases() { + let Some((uri, mention)) = resolve(&crease_id) else { + continue; + }; + let crease_range = crease.range().to_offset(buffer_snapshot); + if crease_range.start.0 > ix { + chunks.push(text[ix..crease_range.start.0].into()); + } + chunks.push(mention_to_content_block( + &uri, + mention.as_ref(), + supports_embedded_context, + &mut tracked_buffers, + )); + ix = crease_range.end.0; + } + + if ix < text.len() { + let last_chunk = text[ix..].trim_end().to_owned(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + (chunks, tracked_buffers) +} + +fn mention_to_content_block( + uri: &MentionUri, + mention: Option<&Mention>, + supports_embedded_context: bool, + tracked_buffers: &mut Vec>, +) -> acp::ContentBlock { + match mention { + Some(Mention::Text { + content, + tracked_buffers: mention_tracked_buffers, + }) => { + tracked_buffers.extend(mention_tracked_buffers.iter().cloned()); + if supports_embedded_context { + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()), + ), + )) + } else { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) + } + } + Some(Mention::Image(mention_image)) => acp::ContentBlock::Image( + acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type()) + .uri(match uri { + MentionUri::File { .. } | MentionUri::PastedImage { .. } => { + Some(uri.to_uri().to_string()) + } + other => { + debug_panic!("unexpected mention uri for image: {:?}", other); + None + } + }), + ), + _ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )), + } +} + /// Parses markdown mention links in the format `[@name](uri)` from text. /// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text. fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range, MentionUri)> { @@ -1946,7 +2025,7 @@ mod tests { use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, - actions::Paste, + actions::{Cut, Paste}, }; use fs::FakeFs; @@ -1966,7 +2045,7 @@ mod tests { use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{AppState, Item, MultiWorkspace}; - use crate::completion_provider::PromptContextType; + use crate::completion_provider::{AgentContextSelection, PromptContextType}; use crate::{ conversation_view::tests::init_test, mention_set::insert_crease_for_mention, @@ -3687,11 +3766,17 @@ mod tests { }) }); - // Now let's insert the selection in the Agent Panel's editor and - // confirm that, after the insertion, the cursor is now in the visible - // range. + let text_editor_selection = editor.update(&mut cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer = multibuffer.as_singleton().unwrap(); + let buffer_snapshot = buffer.read(cx).snapshot(); + let start = buffer_snapshot.anchor_before(0); + let end = buffer_snapshot.anchor_after(5); + AgentContextSelection::Editor(vec![(buffer, start..end)]) + }); + message_editor.update_in(&mut cx, |message_editor, window, cx| { - message_editor.insert_selections(window, cx); + message_editor.insert_selections(text_editor_selection, window, cx); }); cx.run_until_parked(); @@ -3978,7 +4063,8 @@ mod tests { let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| { message_editor - .serialized_copy_text(cx) + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) .expect("selection mentions should serialize") }); let expected_text = format!( @@ -4043,7 +4129,9 @@ mod tests { message_editor: Entity, first_uri: MentionUri, first_range: Range, + second_uri: MentionUri, second_range: Range, + buffer_len: MultiBufferOffset, } async fn setup_selection_mention_fixture( @@ -4068,7 +4156,7 @@ mod tests { line_range: 2..=3, }; - message_editor.update_in(&mut cx, |message_editor, window, cx| { + let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| { message_editor.set_text(source_text, window, cx); let snapshot = message_editor @@ -4123,6 +4211,8 @@ mod tests { ); }); } + + snapshot.len() }); ( @@ -4130,7 +4220,9 @@ mod tests { message_editor, first_uri, first_range, + second_uri, second_range, + buffer_len, }, cx, ) @@ -4158,7 +4250,9 @@ mod tests { let copied = fixture .message_editor .update(&mut cx, |message_editor, cx| { - message_editor.serialized_copy_text(cx) + message_editor + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) }); assert_eq!(copied, Some(fixture.first_uri.as_link().to_string())); @@ -4190,12 +4284,175 @@ mod tests { let copied = fixture .message_editor .update(&mut cx, |message_editor, cx| { - message_editor.serialized_copy_text(cx) + message_editor + .serialize_selection_with_mentions(false, cx) + .map(|(text, _)| text) }); assert_eq!(copied, None); } + #[gpui::test] + async fn test_draft_content_blocks_snapshot_preserves_selection_mentions( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let blocks = fixture.message_editor.update(&mut cx, |editor, cx| { + editor + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); + editor.draft_content_blocks_snapshot(cx) + }); + + // Each selection mention must round-trip as a `Resource` block carrying + // its URI and content, not as a `Text` block containing the fold + // placeholder string. + let resource_uris: Vec<&str> = + blocks + .iter() + .filter_map(|block| match block { + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { uri, .. }, + ), + .. + }) => Some(uri.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + resource_uris.len(), + 2, + "snapshot should emit one Resource block per selection mention; got {blocks:#?}" + ); + assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str())); + for block in &blocks { + if let acp::ContentBlock::Text(text) = block { + assert!( + !text.text.split_whitespace().any(|word| word == "selection"), + "text block must not contain bare fold placeholder: {:?}", + text.text + ); + } + } + } + + #[gpui::test] + async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let buffer_len = fixture.buffer_len; + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(0)..buffer_len]); + }); + }); + message_editor.cut(&Cut, window, cx); + }); + + let expected_text = format!( + "{} needs work\n{} looks fine", + fixture.first_uri.as_link(), + fixture.second_uri.as_link() + ); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| match item.entries().first().cloned() { + Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), + _ => None, + }) + .expect("cut should write serialized text to clipboard"); + assert_eq!(clipboard_text, expected_text); + + let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(remaining_text, ""); + } + + #[gpui::test] + async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4); + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([cursor_offset..cursor_offset]); + }); + }); + message_editor.cut(&Cut, window, cx); + }); + + let clipboard_text = cx + .read_from_clipboard() + .and_then(|item| match item.entries().first().cloned() { + Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()), + _ => None, + }) + .expect("cut should write serialized text to clipboard"); + assert_eq!( + clipboard_text, + format!("{} needs work\n", fixture.first_uri.as_link()) + ); + + let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| { + message_editor.editor.read(cx).text(cx) + }); + assert_eq!(remaining_text, "selection looks fine"); + } + + #[gpui::test] + async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let between_start = fixture.first_range.end; + let between_end = fixture.second_range.start - 1; + fixture + .message_editor + .update_in(&mut cx, |message_editor, window, cx| { + message_editor.editor.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([ + MultiBufferOffset(between_start)..MultiBufferOffset(between_end) + ]); + }); + }); + }); + + let result = fixture + .message_editor + .update(&mut cx, |message_editor, cx| { + message_editor.serialize_selection_with_mentions(true, cx) + }); + + assert!( + result.is_none(), + "serialize_selection_with_mentions should return None so the default editor cut runs" + ); + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, diff --git a/crates/agent_ui/src/model_selector.rs b/crates/agent_ui/src/model_selector.rs index e1cf7307394..47171979496 100644 --- a/crates/agent_ui/src/model_selector.rs +++ b/crates/agent_ui/src/model_selector.rs @@ -11,7 +11,7 @@ use futures::FutureExt; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, - WeakEntity, + TaskExt, WeakEntity, }; use itertools::Itertools; use ordered_float::OrderedFloat; diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 2f32d279835..5919abbbc97 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -95,6 +95,11 @@ impl ProfileSelector { if let Some((next_profile_id, _)) = profiles.get_index(next_index) { self.provider.set_profile(next_profile_id.clone(), cx); + telemetry::event!( + "Agent Profile Switched", + profile_id = next_profile_id.as_str(), + source = "cycle" + ); cx.notify(); } } diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index f5d6fa1a657..1bee86602ed 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -9,7 +9,7 @@ use fs::Fs; use futures::FutureExt as _; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, - Render, SharedString, Task, WeakEntity, Window, + Render, SharedString, Task, TaskExt, WeakEntity, Window, }; use itertools::Itertools as _; use notifications::status_toast::StatusToast; diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 2e6c3313eba..00d132f5a36 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -20,7 +20,7 @@ use db::{ }; use fs::Fs; use futures::{FutureExt, future::Shared}; -use gpui::{AppContext as _, Entity, Global, Subscription, Task}; +use gpui::{AppContext as _, Entity, Global, Subscription, Task, TaskExt}; pub use project::WorktreePaths; use project::{AgentId, linked_worktree_short_name}; use remote::{RemoteConnectionOptions, same_remote_connection_identity}; diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs index 73b0a426b30..b510da96b4e 100644 --- a/crates/agent_ui/src/thread_worktree_archive.rs +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -12,7 +12,7 @@ use project::{ }; use remote::{RemoteConnectionOptions, same_remote_connection_identity}; use settings::Settings; -use util::ResultExt; +use util::{ResultExt, paths::PathStyle}; use workspace::{AppState, MultiWorkspace, Workspace}; use crate::thread_metadata_store::{ArchivedGitWorktree, ThreadId, ThreadMetadataStore}; @@ -77,9 +77,13 @@ fn archived_worktree_ref_name(id: i64) -> String { /// This intentionally reads the *global* `git.worktree_directory` setting /// rather than any project-local override, because Zed always uses the /// global value when creating worktrees and the archive check must match. -fn worktrees_base_for_repo(main_repo_path: &Path, cx: &App) -> Option { +fn worktrees_base_for_repo( + main_repo_path: &Path, + path_style: PathStyle, + cx: &App, +) -> Option { let setting = &ProjectSettings::get_global(cx).git.worktree_directory; - worktrees_directory_for_repo(main_repo_path, setting).log_err() + worktrees_directory_for_repo(main_repo_path, setting, path_style).log_err() } /// Builds a [`RootPlan`] for archiving the git worktree at `path`. @@ -165,7 +169,7 @@ pub fn build_root_plan( // Only archive worktrees that live inside the Zed-managed worktrees // directory (configured via `git.worktree_directory`). Worktrees the // user created outside that directory should be left untouched. - let worktrees_base = worktrees_base_for_repo(&main_repo_path, cx)?; + let worktrees_base = worktrees_base_for_repo(&main_repo_path, linked_snapshot.path_style, cx)?; if !path.starts_with(&worktrees_base) { return None; } diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 82836928876..ebd8c3d94f0 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -19,7 +19,8 @@ use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px, + ListState, Render, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, list, + prelude::*, px, }; use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; @@ -101,24 +102,30 @@ impl TimeBucket { } } -fn fuzzy_match_positions(query: &str, text: &str) -> Option> { - let mut positions = Vec::new(); - let mut query_chars = query.chars().peekable(); - for (byte_idx, candidate_char) in text.char_indices() { - if let Some(&query_char) = query_chars.peek() { - if candidate_char.eq_ignore_ascii_case(&query_char) { - positions.push(byte_idx); - query_chars.next(); +pub fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { + let query_chars: Vec = query.chars().collect(); + if query_chars.is_empty() { + return Some(Vec::new()); + } + + let candidate_chars: Vec<(usize, char)> = candidate.char_indices().collect(); + let window_count = candidate_chars.len().checked_sub(query_chars.len() - 1)?; + + 'outer: for window_start in 0..window_count { + for (qi, &query_char) in query_chars.iter().enumerate() { + let (_, cand_char) = candidate_chars[window_start + qi]; + if !cand_char.eq_ignore_ascii_case(&query_char) { + continue 'outer; } - } else { - break; } + return Some( + (0..query_chars.len()) + .map(|qi| candidate_chars[window_start + qi].0) + .collect(), + ); } - if query_chars.peek().is_none() { - Some(positions) - } else { - None - } + + None } pub enum ThreadsArchiveViewEvent { diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index e3059ab8724..236e57ddffb 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -4,7 +4,8 @@ use acp_thread::MentionUri; use agent_client_protocol::schema as acp; use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use gpui::{ - Animation, AnimationExt, AnyView, Context, IntoElement, WeakEntity, Window, pulsating_between, + Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window, + pulsating_between, }; use prompt_store::PromptId; use rope::Point; diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 14745892304..30aaa4206fe 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -16,7 +16,7 @@ pub use young_account_banner::YoungAccountBanner; use std::sync::Arc; use client::{Client, UserStore, zed_urls}; -use gpui::{AnyElement, Entity, IntoElement, ParentElement}; +use gpui::{AnyElement, Entity, IntoElement, ParentElement, TaskExt}; use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*}; #[derive(PartialEq)] @@ -156,11 +156,11 @@ impl ZedAiOnboarding { .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new("Sign in to try Zed Pro for 14 days, no credit card required.") + Label::new("Sign in to try Zed Pro free for 14 days.") .color(Color::Muted) .mb_2(), ) - .child(PlanDefinitions.pro_plan()) + .child(PlanDefinitions.sign_in_upsell()) .child( Button::new("sign_in", "Try Zed Pro for Free") .disabled(signing_in) diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index cc80b5ccf6d..2ac7aeab566 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -14,6 +14,13 @@ impl PlanDefinitions { .child(ListBulletItem::new("Unlimited use of external agents")) } + pub fn sign_in_upsell(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("$20 of tokens in Zed agent")) + .child(ListBulletItem::new("No credit card required")) + } + pub fn pro_trial(&self, period: bool) -> impl IntoElement { List::new() .child(ListBulletItem::new("$20 of tokens in Zed agent")) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index a057a30c6d3..c14de5a801c 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -3,8 +3,8 @@ use client::Client; use db::kvp::KeyValueStore; use futures_lite::StreamExt; use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, Window, - actions, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, TaskExt, + Window, actions, }; use http_client::{HttpClient, HttpClientWithUrl}; use paths::remote_servers_dir; @@ -81,6 +81,11 @@ fn linux_rsync_install_hint() -> &'static str { || distribution_id == "almalinux" }) { Some("Install it with: sudo dnf install rsync") + } else if distribution_ids + .iter() + .any(|distribution_id| distribution_id == "nixos") + { + Some("Install pkgs.rsync from nixpkgs") } else { None }; diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index 77ba83597ed..6bd577ddb1b 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -6,7 +6,8 @@ use db::kvp::Dismissable; use editor::{Editor, MultiBuffer}; use fs::Fs; use gpui::{ - App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window, actions, prelude::*, + App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TaskExt, Window, actions, + prelude::*, }; use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPreviewView}; use notifications::status_toast::StatusToast; diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index a3f60fd7b50..af0b9c8a32a 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -32,8 +32,6 @@ use thiserror::Error; pub use crate::models::*; -pub const CONTEXT_1M_BETA_HEADER: &str = "context-1m-2025-08-07"; - pub async fn stream_completion( client: bedrock::Client, request: Request, @@ -70,13 +68,6 @@ pub async fn stream_completion( _ => {} } - if request.allow_extended_context { - additional_fields.insert( - "anthropic_beta".to_string(), - Document::Array(vec![Document::String(CONTEXT_1M_BETA_HEADER.to_string())]), - ); - } - if !additional_fields.is_empty() { response = response.additional_model_request_fields(Document::Object(additional_fields)); } @@ -211,7 +202,6 @@ pub struct Request { pub temperature: Option, pub top_k: Option, pub top_p: Option, - pub allow_extended_context: bool, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index 35937a0e902..298c36002ca 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -384,19 +384,15 @@ impl Model { } pub fn max_token_count(&self) -> u64 { - self.max_tokens() - } - - pub fn max_tokens(&self) -> u64 { match self { Self::ClaudeHaiku4_5 | Self::ClaudeSonnet4 | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 - | Self::ClaudeSonnet4_6 => 200_000, + | Self::ClaudeSonnet4_6 => 1_000_000, + Self::ClaudeOpus4_1 => 200_000, Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000, Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000, Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000, @@ -526,18 +522,6 @@ impl Model { } } - pub fn supports_extended_context(&self) -> bool { - matches!( - self, - Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 - | Self::ClaudeSonnet4_6 - ) - } - pub fn supports_caching(&self) -> bool { match self { Self::ClaudeHaiku4_5 @@ -1040,11 +1024,11 @@ mod tests { } #[test] - fn test_max_tokens() { - assert_eq!(Model::ClaudeSonnet4_5.max_tokens(), 200_000); - assert_eq!(Model::ClaudeOpus4_6.max_tokens(), 200_000); - assert_eq!(Model::Llama4Scout17B.max_tokens(), 128_000); - assert_eq!(Model::NovaPremier.max_tokens(), 1_000_000); + fn test_max_token_count() { + assert_eq!(Model::ClaudeSonnet4_5.max_token_count(), 1_000_000); + assert_eq!(Model::ClaudeOpus4_6.max_token_count(), 1_000_000); + assert_eq!(Model::Llama4Scout17B.max_token_count(), 128_000); + assert_eq!(Model::NovaPremier.max_token_count(), 1_000_000); } #[test] diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index c0c1535cd45..eabc214b115 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -9,7 +9,7 @@ use collections::HashSet; use futures::{Future, FutureExt, channel::oneshot, future::Shared}; use gpui::{ AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, - WeakEntity, Window, + TaskExt, WeakEntity, Window, }; use postage::watch; use project::Project; diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index f9df2b758f7..21b40822cf0 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -14,7 +14,7 @@ use fs::Fs; use futures::StreamExt; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FutureExt as _, - ScreenCaptureSource, ScreenCaptureStream, Task, Timeout, WeakEntity, + ScreenCaptureSource, ScreenCaptureStream, Task, TaskExt, Timeout, WeakEntity, }; use gpui_tokio::Tokio; use language::LanguageRegistry; @@ -935,6 +935,11 @@ impl Room { for sid in participant.video_tracks.keys() { cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); } + if !participant.video_tracks.is_empty() { + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + } false } }); diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 5bc34320a87..fc3c5126774 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -26,7 +26,7 @@ use futures::{ future::BoxFuture, stream::BoxStream, }; -use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; +use gpui::{App, AsyncApp, Entity, Global, Task, TaskExt, WeakEntity, actions}; use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env}; use parking_lot::{Mutex, RwLock}; use postage::watch; diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs index 70457679e4b..058be7905fa 100644 --- a/crates/client/src/llm_token.rs +++ b/crates/client/src/llm_token.rs @@ -4,6 +4,7 @@ use cloud_api_types::websocket_protocol::MessageToClient; use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription, + TaskExt, }; use std::sync::Arc; diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 0f436904913..3673393631d 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -15,7 +15,8 @@ use derive_more::Deref; use feature_flags::FeatureFlagAppExt; use futures::{Future, StreamExt, channel::mpsc}; use gpui::{ - App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, WeakEntity, + App, AsyncApp, Context, Entity, EventEmitter, SharedString, SharedUri, Task, TaskExt, + WeakEntity, }; use http_client::http::{HeaderMap, HeaderValue}; use postage::{sink::Sink, watch}; diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 439ed5b2e82..67836cc5e56 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -1,4 +1,5 @@ mod extension; +pub mod internal_api; mod known_or_unknown; mod plan; mod timestamp; diff --git a/crates/cloud_api_types/src/internal_api.rs b/crates/cloud_api_types/src/internal_api.rs new file mode 100644 index 00000000000..954dcdad420 --- /dev/null +++ b/crates/cloud_api_types/src/internal_api.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct User { + pub id: String, + pub legacy_user_id: i32, + pub github_login: String, + pub github_user_id: i32, + pub name: Option, + pub admin: bool, + pub connected_once: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LookUpUsersByLegacyIdBody { + pub legacy_user_ids: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LookUpUsersByLegacyIdResponse { + pub users: Vec, +} diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index 8a4c74067a7..fc0d899822d 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -2,8 +2,8 @@ DATABASE_URL = "postgres://postgres@localhost/zed" # DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc" DATABASE_MAX_CONNECTIONS = 5 HTTP_PORT = 8080 -API_TOKEN = "secret" ZED_ENVIRONMENT = "development" +ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret" LIVEKIT_SERVER = "http://localhost:7880" LIVEKIT_KEY = "devkey" LIVEKIT_SECRET = "secret" diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index bad3d290d79..d37840218c2 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -87,16 +87,16 @@ spec: key: url - name: DATABASE_MAX_CONNECTIONS value: "${DATABASE_MAX_CONNECTIONS}" - - name: API_TOKEN - valueFrom: - secretKeyRef: - name: api - key: token - name: ZED_CLIENT_CHECKSUM_SEED valueFrom: secretKeyRef: name: zed-client key: checksum-seed + - name: ZED_CLOUD_INTERNAL_API_KEY + valueFrom: + secretKeyRef: + name: zed-cloud + key: internal-api-key - name: LIVEKIT_SERVER valueFrom: secretKeyRef: diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 0ef44682a11..9c39dd4c260 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -21,8 +21,8 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id" CREATE TABLE "contacts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "user_id_a" INTEGER REFERENCES users (id) NOT NULL, - "user_id_b" INTEGER REFERENCES users (id) NOT NULL, + "user_id_a" INTEGER NOT NULL, + "user_id_b" INTEGER NOT NULL, "a_to_b" BOOLEAN NOT NULL, "should_notify" BOOLEAN NOT NULL, "accepted" BOOLEAN NOT NULL @@ -44,7 +44,7 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); CREATE TABLE "projects" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE, - "host_user_id" INTEGER REFERENCES users (id), + "host_user_id" INTEGER, "host_connection_id" INTEGER, "host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE, "unregistered" BOOLEAN NOT NULL DEFAULT FALSE, @@ -208,14 +208,14 @@ CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and CREATE TABLE "room_participants" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "room_id" INTEGER NOT NULL REFERENCES rooms (id), - "user_id" INTEGER NOT NULL REFERENCES users (id), + "user_id" INTEGER NOT NULL, "answering_connection_id" INTEGER, "answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE, "answering_connection_lost" BOOLEAN NOT NULL, "location_kind" INTEGER, "location_project_id" INTEGER, "initial_project_id" INTEGER, - "calling_user_id" INTEGER NOT NULL REFERENCES users (id), + "calling_user_id" INTEGER NOT NULL, "calling_connection_id" INTEGER NOT NULL, "calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL, "participant_index" INTEGER, @@ -279,7 +279,7 @@ CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_pa CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "user_id" INTEGER NOT NULL REFERENCES users (id), + "user_id" INTEGER NOT NULL, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "connection_id" INTEGER NOT NULL, "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE @@ -290,7 +290,7 @@ CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_pa CREATE TABLE "channel_members" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL, "role" VARCHAR NOT NULL, "accepted" BOOLEAN NOT NULL DEFAULT false, "updated_at" TIMESTAMP NOT NULL DEFAULT now @@ -332,7 +332,7 @@ CREATE TABLE "channel_buffer_collaborators" ( "connection_id" INTEGER NOT NULL, "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, "connection_lost" BOOLEAN NOT NULL DEFAULT false, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL, "replica_id" INTEGER NOT NULL ); @@ -351,7 +351,7 @@ CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection ); CREATE TABLE "observed_buffer_edits" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL, "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, "epoch" INTEGER NOT NULL, "lamport_timestamp" INTEGER NOT NULL, @@ -371,7 +371,7 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE "notifications" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP, - "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "recipient_id" INTEGER NOT NULL, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), "entity_id" INTEGER, "content" TEXT, @@ -382,7 +382,7 @@ CREATE TABLE "notifications" ( CREATE INDEX "index_notifications_on_recipient_id_is_read_kind_entity_id" ON "notifications" ("recipient_id", "is_read", "kind", "entity_id"); CREATE TABLE contributors ( - user_id INTEGER REFERENCES users (id), + user_id INTEGER, signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id) ); @@ -437,7 +437,7 @@ CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); CREATE TABLE IF NOT EXISTS "shared_threads" ( "id" TEXT PRIMARY KEY NOT NULL, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL, "title" VARCHAR(512) NOT NULL, "data" BLOB NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 629d93388dd..3ea8d839088 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -11,8 +11,7 @@ use std::sync::Arc; /// Validates the authorization header and adds an Extension to the request. /// Authorization: -/// can be an access_token attached to that user, or an access token of an admin -/// or (in development) the string ADMIN:. +/// is the access_token attached to that user. /// Authorization: "dev-server-token" pub async fn validate_header(mut req: Request, next: Next) -> impl IntoResponse { let mut auth_header = req diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 7b435ba1aa2..b4ee2caa0d6 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -684,6 +684,26 @@ impl Database { .await } + /// Returns the channel memberships for the users with the specified IDs. + #[cfg(feature = "test-support")] + pub async fn get_channel_memberships_for_user_ids( + &self, + channel: &Channel, + ids: Vec, + ) -> Result> { + self.transaction(|tx| async { + let tx = tx; + let members = channel_member::Entity::find() + .filter(channel_member::Column::ChannelId.eq(channel.id)) + .filter(channel_member::Column::UserId.is_in(ids.iter().copied())) + .all(&*tx) + .await?; + + Ok(members) + }) + .await + } + /// Returns the details for the specified channel member. pub async fn get_channel_participant_details( &self, diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 933e78ed426..c797fe41509 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/crates/collab/src/db/tables/user.rs @@ -25,6 +25,8 @@ impl From for crate::entities::User { crate::entities::User { id: user.id, github_login: user.github_login, + github_user_id: user.github_user_id, + name: user.name, admin: user.admin, connected_once: user.connected_once, } diff --git a/crates/collab/src/entities/user.rs b/crates/collab/src/entities/user.rs index 0c31d78ac51..248916ad81d 100644 --- a/crates/collab/src/entities/user.rs +++ b/crates/collab/src/entities/user.rs @@ -4,6 +4,8 @@ use crate::db::UserId; pub struct User { pub id: UserId, pub github_login: String, + pub github_user_id: i32, + pub name: Option, pub admin: bool, pub connected_once: bool, } diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 51541242a44..d1948d15749 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -6,6 +6,7 @@ pub mod env; pub mod executor; pub mod rpc; pub mod seed; +pub mod services; use anyhow::Context as _; use aws_config::{BehaviorVersion, Region}; @@ -19,6 +20,10 @@ use serde::Deserialize; use std::{path::PathBuf, sync::Arc}; use util::ResultExt; +use crate::services::{ + CloudUserService, DatabaseUserService, TransitionalUserService, UserService, +}; + pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA"); @@ -121,7 +126,6 @@ pub struct Config { pub database_url: String, pub seed_path: Option, pub database_max_connections: u32, - pub api_token: String, pub livekit_server: Option, pub livekit_key: Option, pub livekit_secret: Option, @@ -137,6 +141,7 @@ pub struct Config { pub kinesis_access_key: Option, pub kinesis_secret_key: Option, pub zed_environment: Arc, + pub zed_cloud_internal_api_key: String, pub zed_client_checksum_seed: Option, } @@ -168,13 +173,13 @@ impl Config { http_port: 0, database_url: "".into(), database_max_connections: 0, - api_token: "".into(), livekit_server: None, livekit_key: None, livekit_secret: None, rust_log: None, log_json: None, zed_environment: "test".into(), + zed_cloud_internal_api_key: "test-internal-api-key".into(), blob_store_url: None, blob_store_region: None, blob_store_access_key: None, @@ -216,6 +221,7 @@ pub struct AppState { pub blob_store_client: Option, pub executor: Executor, pub kinesis_client: Option<::aws_sdk_kinesis::Client>, + pub user_service: Arc, pub config: Config, } @@ -250,7 +256,7 @@ impl AppState { let db = Arc::new(db); let this = Self { db: db.clone(), - http_client: Some(http_client), + http_client: Some(http_client.clone()), livekit_client, blob_store_client: build_blob_store_client(&config).await.log_err(), executor, @@ -259,6 +265,19 @@ impl AppState { } else { None }, + user_service: { + let database_user_service = DatabaseUserService::new(db); + let cloud_user_service = CloudUserService::new( + http_client, + config.zed_cloud_url().to_string(), + config.zed_cloud_internal_api_key.clone(), + ); + + Arc::new(TransitionalUserService::new( + cloud_user_service, + database_user_service, + )) + }, config, }; Ok(Arc::new(this)) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 39f442bcafd..3412a40c4a8 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2541,10 +2541,11 @@ async fn get_users( .map(UserId::from_proto) .collect(); let users = session - .db() - .await + .app_state + .user_service .get_users_by_ids(user_ids) - .await? + .await?; + let users = users .into_iter() .map(|user| proto::User { id: user.id.to_proto(), @@ -2567,13 +2568,19 @@ async fn fuzzy_search_users( let users = match query.len() { 0 => vec![], 1 | 2 => session - .db() - .await + .app_state + .user_service .get_user_by_github_login(&query) .await? .into_iter() .collect(), - _ => session.db().await.fuzzy_search_users(&query, 10).await?, + _ => { + session + .app_state + .user_service + .fuzzy_search_users(&query, 10) + .await? + } }; let users = users .into_iter() @@ -3163,13 +3170,11 @@ async fn get_channel_members( let channel = db.get_channel(channel_id, session.user_id()).await?; - let (members, users) = db - .get_channel_participant_details(&channel, &request.query, limit) + let (members, users) = session + .app_state + .user_service + .search_channel_members(&channel, &request.query, limit as u32) .await?; - let members = members - .into_iter() - .map(proto::ChannelMember::from) - .collect(); let users = users.into_iter().map(proto::User::from).collect(); response.send(proto::GetChannelMembersResponse { members, users })?; @@ -4081,3 +4086,17 @@ where } } } + +impl From for proto::User { + fn from(user: User) -> Self { + Self { + id: user.id.to_proto(), + avatar_url: format!( + "https://avatars.githubusercontent.com/u/{}?s=128&v=4", + user.github_user_id + ), + github_login: user.github_login, + name: user.name, + } + } +} diff --git a/crates/collab/src/services.rs b/crates/collab/src/services.rs new file mode 100644 index 00000000000..eb87237236f --- /dev/null +++ b/crates/collab/src/services.rs @@ -0,0 +1,3 @@ +mod user_service; + +pub use user_service::*; diff --git a/crates/collab/src/services/user_service.rs b/crates/collab/src/services/user_service.rs new file mode 100644 index 00000000000..f2fb968b231 --- /dev/null +++ b/crates/collab/src/services/user_service.rs @@ -0,0 +1,390 @@ +use std::sync::Arc; + +use anyhow::{Context as _, anyhow}; +use async_trait::async_trait; +use cloud_api_types::internal_api::{ + self, LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse, +}; +use rpc::proto; + +use crate::Result; +use crate::db::{Channel, Database, UserId}; +use crate::entities::User; + +#[cfg(feature = "test-support")] +pub use self::fake_user_service::*; + +#[async_trait] +pub trait UserService: Send + Sync + 'static { + async fn get_users_by_ids(&self, ids: Vec) -> Result>; + + async fn get_user_by_github_login(&self, github_login: &str) -> Result>; + + async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result>; + + // NOTE: This method is only tangentially related to users, but we're putting it on the `UserService` to avoid + // introducing a separate service. + // + // We're also using the `proto::ChannelMember` representation in the return type, as we don't yet have a domain + // representation of a channel member (and doesn't seem necessary to introduce one, at this point). + async fn search_channel_members( + &self, + channel: &Channel, + query: &str, + limit: u32, + ) -> Result<(Vec, Vec)>; + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> Arc { + panic!("called as_fake on a real `UserService`"); + } +} + +/// A [`UserService`] implementation for transitioning from reading from the database to reading from Cloud. +pub struct TransitionalUserService { + cloud_user_service: CloudUserService, + database_user_service: DatabaseUserService, +} + +impl TransitionalUserService { + pub fn new( + cloud_user_service: CloudUserService, + database_user_service: DatabaseUserService, + ) -> Self { + Self { + cloud_user_service, + database_user_service, + } + } +} + +#[async_trait] +impl UserService for TransitionalUserService { + async fn get_users_by_ids(&self, ids: Vec) -> Result> { + self.cloud_user_service.get_users_by_ids(ids).await + } + + async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + self.database_user_service + .get_user_by_github_login(github_login) + .await + } + + async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result> { + self.database_user_service + .fuzzy_search_users(query, limit) + .await + } + + async fn search_channel_members( + &self, + channel: &Channel, + query: &str, + limit: u32, + ) -> Result<(Vec, Vec)> { + self.database_user_service + .search_channel_members(channel, query, limit) + .await + } +} + +/// A [`UserService`] implementation backed by Cloud. +pub struct CloudUserService { + http_client: reqwest::Client, + zed_cloud_url: String, + internal_api_key: String, +} + +impl CloudUserService { + pub fn new( + http_client: reqwest::Client, + zed_cloud_url: String, + internal_api_key: String, + ) -> Self { + Self { + http_client, + zed_cloud_url, + internal_api_key, + } + } +} + +#[async_trait] +impl UserService for CloudUserService { + async fn get_users_by_ids(&self, ids: Vec) -> Result> { + let response = self + .http_client + .post(format!( + "{}/internal/users/look_up_by_legacy_id", + &self.zed_cloud_url + )) + .header("Content-Type", "application/json") + .header( + "Authorization", + format!("Bearer {}", &self.internal_api_key), + ) + .json(&LookUpUsersByLegacyIdBody { + legacy_user_ids: ids.into_iter().map(|id| id.0).collect(), + }) + .send() + .await + .context("failed to get users by legacy IDs")?; + + match response.error_for_status() { + Ok(response) => { + let response_body: LookUpUsersByLegacyIdResponse = response + .json() + .await + .context("failed to parse response body")?; + + Ok(response_body.users.into_iter().map(User::from).collect()) + } + Err(_err) => Err(anyhow!("failed to get users by legacy IDs"))?, + } + } + + async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + let _ = github_login; + + unimplemented!("not yet implemented in Cloud") + } + + async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result> { + let _ = query; + let _ = limit; + + unimplemented!("not yet implemented in Cloud") + } + + async fn search_channel_members( + &self, + channel: &Channel, + query: &str, + limit: u32, + ) -> Result<(Vec, Vec)> { + let _ = channel; + let _ = query; + let _ = limit; + + unimplemented!("not yet implemented in Cloud") + } +} + +impl From for User { + fn from(user: internal_api::User) -> Self { + Self { + id: UserId(user.legacy_user_id), + github_login: user.github_login, + github_user_id: user.github_user_id, + name: user.name, + admin: user.admin, + connected_once: user.connected_once, + } + } +} + +/// A [`UserService`] implementation backed by the database. +pub struct DatabaseUserService { + database: Arc, +} + +impl DatabaseUserService { + pub fn new(database: Arc) -> Self { + Self { database } + } +} + +#[async_trait] +impl UserService for DatabaseUserService { + async fn get_users_by_ids(&self, ids: Vec) -> Result> { + let users = self.database.get_users_by_ids(ids).await?; + + Ok(users.into_iter().map(User::from).collect()) + } + + async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + let user = self.database.get_user_by_github_login(github_login).await?; + + Ok(user.map(User::from)) + } + + async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result> { + let users = self.database.fuzzy_search_users(query, limit).await?; + + Ok(users.into_iter().map(User::from).collect()) + } + + async fn search_channel_members( + &self, + channel: &Channel, + query: &str, + limit: u32, + ) -> Result<(Vec, Vec)> { + let (members, users) = self + .database + .get_channel_participant_details(channel, query, limit as u64) + .await?; + + Ok(( + members + .into_iter() + .map(proto::ChannelMember::from) + .collect(), + users.into_iter().map(User::from).collect(), + )) + } +} + +#[cfg(feature = "test-support")] +mod fake_user_service { + use std::sync::Weak; + + use collections::HashMap; + use tokio::sync::Mutex; + + use super::*; + + #[derive(Debug)] + pub struct NewUserParams { + pub github_login: String, + pub github_user_id: i32, + } + + pub struct FakeUserService { + this: Weak, + state: Arc>, + database: Arc, + } + + struct FakeUserServiceState { + next_user_id: UserId, + users: HashMap, + } + + impl Default for FakeUserServiceState { + fn default() -> Self { + Self { + next_user_id: UserId(1), + users: HashMap::default(), + } + } + } + + impl FakeUserService { + pub fn new(database: Arc) -> Arc { + Arc::new_cyclic(|this| Self { + this: this.clone(), + state: Arc::new(Mutex::default()), + database, + }) + } + + pub async fn create_user( + &self, + email_address: &str, + name: Option<&str>, + admin: bool, + params: NewUserParams, + ) -> UserId { + let mut state = self.state.lock().await; + + let user_id = state.next_user_id; + let _ = email_address; + state.users.insert( + user_id, + User { + id: user_id, + github_login: params.github_login, + github_user_id: params.github_user_id, + name: name.map(|name| name.to_string()), + admin, + connected_once: false, + }, + ); + + state.next_user_id = UserId(state.next_user_id.0 + 1); + + user_id + } + + pub async fn get_user_by_id(&self, id: UserId) -> Result> { + let state = self.state.lock().await; + + let user = state.users.get(&id).cloned(); + + Ok(user) + } + } + + #[async_trait] + impl UserService for FakeUserService { + async fn get_users_by_ids(&self, ids: Vec) -> Result> { + let state = self.state.lock().await; + + let users = state + .users + .values() + .filter(|user| ids.contains(&user.id)) + .cloned() + .collect(); + + Ok(users) + } + + async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + let state = self.state.lock().await; + + let user = state + .users + .values() + .find(|user| user.github_login == github_login) + .cloned(); + + Ok(user) + } + + async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result> { + let _ = query; + let _ = limit; + unimplemented!("not currently exercised by any tests") + } + + async fn search_channel_members( + &self, + channel: &Channel, + query: &str, + limit: u32, + ) -> Result<(Vec, Vec)> { + let state = self.state.lock().await; + + let users = state + .users + .values() + .filter(|user| user.github_login.contains(query)) + .take(limit as usize) + .cloned() + .collect::>(); + + let members = self + .database + .get_channel_memberships_for_user_ids( + channel, + users.iter().map(|user| user.id).collect(), + ) + .await?; + + Ok(( + members + .into_iter() + .map(proto::ChannelMember::from) + .collect(), + users, + )) + } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> Arc { + self.this.upgrade().unwrap() + } + } +} diff --git a/crates/collab/tests/integration/auto_watch_tests.rs b/crates/collab/tests/integration/auto_watch_tests.rs index c8d395407b3..f119e1a4af4 100644 --- a/crates/collab/tests/integration/auto_watch_tests.rs +++ b/crates/collab/tests/integration/auto_watch_tests.rs @@ -1,18 +1,20 @@ use crate::TestServer; use call::ActiveCall; +use client::ChannelId; use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource}; use project::Project; -use serde_json::json; -use util::path; -use workspace::Workspace; +use rpc::proto::PeerId; +use workspace::{AutoWatch, SharedScreen, Workspace}; use super::TestClient; struct AutoWatchTestSetup { client_a: TestClient, - _client_b: TestClient, - _client_c: TestClient, - project_a: Entity, + client_b: TestClient, + client_c: TestClient, + channel_id: ChannelId, + user_a_project: Entity, + user_b_project: Entity, } async fn setup_auto_watch_test( @@ -20,35 +22,67 @@ async fn setup_auto_watch_test( user_a: &mut TestAppContext, user_b: &mut TestAppContext, user_c: &mut TestAppContext, +) -> AutoWatchTestSetup { + setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await +} + +async fn setup_auto_watch_late_joiner_test( + server: &mut TestServer, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) -> AutoWatchTestSetup { + setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await +} + +async fn setup_auto_watch_test_with_initial_participants( + server: &mut TestServer, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, + join_user_c: bool, ) -> AutoWatchTestSetup { let client_a = server.create_client(user_a, "user_a").await; let client_b = server.create_client(user_b, "user_b").await; let client_c = server.create_client(user_c, "user_c").await; - server - .create_room(&mut [ + let channel_id = server + .make_channel( + "the-channel", + None, (&client_a, user_a), - (&client_b, user_b), - (&client_c, user_c), - ]) + &mut [(&client_b, user_b), (&client_c, user_c)], + ) .await; + let user_a_project = client_a.build_empty_local_project(false, user_a); + let user_b_project = client_b.build_empty_local_project(false, user_b); + let active_call_a = user_a.read(ActiveCall::global); - - client_a - .fs() - .insert_tree(path!("/a"), json!({ "file.txt": "content" })) - .await; - let (project_a, _worktree_id) = client_a.build_local_project(path!("/a"), user_a).await; active_call_a - .update(user_a, |call, cx| call.set_location(Some(&project_a), cx)) + .update(user_a, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + let active_call_b = user_b.read(ActiveCall::global); + active_call_b + .update(user_b, |call, cx| call.join_channel(channel_id, cx)) .await .unwrap(); + if join_user_c { + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + } + AutoWatchTestSetup { client_a, - _client_b: client_b, - _client_c: client_c, - project_a, + client_b, + client_c, + channel_id, + user_a_project, + user_b_project, } } @@ -61,7 +95,9 @@ async fn test_auto_watch_opens_existing_share_on_toggle( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); executor.run_until_parked(); start_screen_share(user_b).await; @@ -73,7 +109,11 @@ async fn test_auto_watch_opens_existing_share_on_toggle( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -86,7 +126,9 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -96,7 +138,11 @@ async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -109,7 +155,9 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -119,7 +167,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); start_screen_share(user_c).await; @@ -129,7 +181,11 @@ async fn test_auto_watch_switches_to_next_share_on_share_end( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_c's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); }); } @@ -142,7 +198,9 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); start_screen_share(user_a).await; executor.run_until_parked(); @@ -155,16 +213,11 @@ async fn test_auto_watch_ignores_shares_while_user_is_sharing( }); executor.run_until_parked(); - // Ensure that no screen share is found in user a's tab bar workspace_a.update(user_a, |workspace, cx| { - let has_shared_screen_tab = workspace - .active_pane() - .read(cx) - .items() - .any(|item| item.tab_content_text(0, cx).contains("screen")); - assert!( - !has_shared_screen_tab, - "should not open anyone's screen share when toggling on while sharing" + assert_no_screen_share_tabs_exist( + workspace, + "should not open anyone's screen share when toggling on while sharing", + cx, ); }); } @@ -178,7 +231,9 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -193,7 +248,11 @@ async fn test_auto_watch_opens_share_after_local_user_stops_sharing( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); } @@ -206,7 +265,9 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open( ) { let mut server = TestServer::start(executor.clone()).await; let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; - let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a); + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); workspace_a.update_in(user_a, |workspace, window, cx| { workspace.toggle_auto_watch(window, cx); @@ -215,7 +276,11 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open( executor.run_until_parked(); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); }); workspace_a.update_in(user_a, |workspace, window, cx| { @@ -223,19 +288,165 @@ async fn test_auto_watch_toggle_off_leaves_tabs_open( }); workspace_a.update(user_a, |workspace, cx| { - assert_active_matches_title(workspace, "user_b's screen", cx); + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); + }); +} + +#[gpui::test] +async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant( + executor: BackgroundExecutor, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await; + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); + let (workspace_b, user_b) = setup + .client_b + .build_workspace(&setup.user_b_project, user_b); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + workspace_b.update_in(user_b, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + executor.run_until_parked(); + + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(setup.channel_id, cx)) + .await + .unwrap(); + executor.run_until_parked(); + + start_screen_share(user_c).await; + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + workspace_b.update(user_b, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + + active_call_c + .update(user_c, |call, cx| call.hang_up(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_no_screen_share_tabs_exist( + workspace, + "user A should stop seeing user C's screen after user C hangs up", + cx, + ); + }); + workspace_b.update(user_b, |workspace, cx| { + assert_no_screen_share_tabs_exist( + workspace, + "user B should stop seeing user C's screen after user C hangs up", + cx, + ); + }); + + let active_call_c = user_c.read(ActiveCall::global); + active_call_c + .update(user_c, |call, cx| call.join_channel(setup.channel_id, cx)) + .await + .unwrap(); + executor.run_until_parked(); + + start_screen_share(user_c).await; + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); + workspace_b.update(user_b, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_c.peer_id().unwrap(), + cx, + ); + }); +} + +#[gpui::test] +async fn test_auto_watch_is_disabled_when_following_collaborator( + executor: BackgroundExecutor, + user_a: &mut TestAppContext, + user_b: &mut TestAppContext, + user_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await; + let (workspace_a, user_a) = setup + .client_a + .build_workspace(&setup.user_a_project, user_a); + let user_b_peer_id = setup.client_b.peer_id().unwrap(); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.toggle_auto_watch(window, cx); + }); + start_screen_share(user_b).await; + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, cx| { + assert_active_item_is_screen_share_for_peer( + workspace, + setup.client_b.peer_id().unwrap(), + cx, + ); + }); + + workspace_a.update_in(user_a, |workspace, window, cx| { + workspace.follow(user_b_peer_id, window, cx); + }); + executor.run_until_parked(); + + workspace_a.update(user_a, |workspace, _cx| { + assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off); }); } #[track_caller] -fn assert_active_matches_title(workspace: &Workspace, expected_title: &str, cx: &App) { +fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) { + let has_shared_screen_tab = workspace + .active_pane() + .read(cx) + .items() + .any(|item| item.downcast::().is_some()); + assert!(!has_shared_screen_tab, "{message}"); +} + +#[track_caller] +fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) { let active_item = workspace.active_item(cx).expect("no active item"); - assert_eq!( - active_item.tab_content_text(0, cx), - expected_title, - "expected active item to be '{}'", - expected_title - ); + let shared_screen = active_item + .downcast::() + .expect("expected active item to be a shared screen"); + assert_eq!(shared_screen.read(cx).peer_id, peer_id); } async fn start_screen_share(cx: &mut TestAppContext) { @@ -260,6 +471,7 @@ async fn start_screen_share(cx: &mut TestAppContext) { .unwrap(); } +#[track_caller] fn stop_screen_share(cx: &mut TestAppContext) { let active_call = cx.read(ActiveCall::global); active_call diff --git a/crates/collab/tests/integration/channel_guest_tests.rs b/crates/collab/tests/integration/channel_guest_tests.rs index 95b1eeca5fc..5065a24c3a4 100644 --- a/crates/collab/tests/integration/channel_guest_tests.rs +++ b/crates/collab/tests/integration/channel_guest_tests.rs @@ -281,7 +281,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes // User B signs the zed CLA. let user_b = server .app_state - .db + .user_service .get_user_by_github_login("user_b") .await .unwrap() diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 2c723a833f3..4cd66b2a121 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -1399,7 +1399,12 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte "Should have fetched one code lens action, but got: {resulting_lens_actions:?}" ); assert_eq!( - resulting_lens_actions.first().unwrap().lsp_action.title(), + resulting_lens_actions + .values() + .next() + .unwrap() + .lsp_action + .title(), "LSP Command 1", "Only the final code lens action should be in the data" ) diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index 7109b0f3145..b4b29ade760 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -2381,3 +2381,106 @@ async fn test_following_while_deactivated(cx_a: &mut TestAppContext, cx_b: &mut assert_eq!(editor.tab_content_text(0, cx), "2.js"); }); } + +#[gpui::test(iterations = 10)] +async fn test_following_with_multibuffer_excerpts_at_unobserved_lamport( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let executor = cx_a.executor(); + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + cx_a.update(editor::init); + cx_b.update(editor::init); + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + client_a + .fs() + .insert_tree(path!("/a"), json!({ "1.txt": sample_text(20, 5, 'a') })) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_buffer((worktree_id, rel_path("1.txt")), cx) + }) + .await + .unwrap(); + // B must already have the buffer open at a low Lamport so that A's + // subsequent edits create anchors B hasn't observed. + let _buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, rel_path("1.txt")), cx) + }) + .await + .unwrap(); + + workspace_b.update_in(cx_b, |workspace, window, cx| { + workspace.follow(client_a.peer_id().unwrap(), window, cx) + }); + executor.run_until_parked(); + + buffer_a.update(cx_a, |buf, cx| { + for i in 0..30 { + let len = buf.len(); + buf.edit([(len..len, format!("\nappended line {i}"))], None, cx); + } + }); + let multibuffer_a = cx_a.new(|cx| { + let mut mb = MultiBuffer::new(Capability::ReadWrite); + let max_row = buffer_a.read(cx).max_point().row; + mb.set_excerpts_for_path( + PathKey::for_buffer(&buffer_a, cx), + buffer_a.clone(), + [Point::row_range(max_row.saturating_sub(5)..max_row)], + 1, + cx, + ); + mb + }); + workspace_a.update_in(cx_a, |workspace, window, cx| { + let editor = cx + .new(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + }); + + executor.run_until_parked(); + + let active_text = |workspace: &Entity, cx: &mut VisualTestContext| { + workspace.update(cx, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + .update(cx, |editor, cx| editor.text(cx)) + }) + }; + assert_eq!( + active_text(&workspace_a, cx_a), + active_text(&workspace_b, cx_b) + ); +} diff --git a/crates/collab/tests/integration/random_project_collaboration_tests.rs b/crates/collab/tests/integration/random_project_collaboration_tests.rs index ab5bde6d321..a7eaa9cd60f 100644 --- a/crates/collab/tests/integration/random_project_collaboration_tests.rs +++ b/crates/collab/tests/integration/random_project_collaboration_tests.rs @@ -7,7 +7,7 @@ use collections::{BTreeMap, HashMap}; use editor::Bias; use fs::{FakeFs, Fs as _}; use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; -use gpui::{BackgroundExecutor, Entity, TestAppContext}; +use gpui::{BackgroundExecutor, Entity, TaskExt, TestAppContext}; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, PointUtf16, range_to_lsp, }; diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 32f0e29c6dc..d177f63ef8e 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -7,9 +7,10 @@ use client::{ proto::PeerId, }; use clock::FakeSystemClock; +use collab::services::{FakeUserService, NewUserParams}; use collab::{ AppState, Config, - db::{NewUserParams, UserId}, + db::UserId, executor::Executor, rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion}, }; @@ -179,14 +180,19 @@ impl TestServer { let clock = Arc::new(FakeSystemClock::new()); - let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await + let user_id = if let Ok(Some(user)) = self + .app_state + .user_service + .get_user_by_github_login(name) + .await { user.id } else { let github_user_id = self.next_github_user_id; self.next_github_user_id += 1; self.app_state - .db + .user_service + .as_fake() .create_user( &format!("{name}@example.com"), None, @@ -197,8 +203,6 @@ impl TestServer { }, ) .await - .expect("creating user failed") - .user_id }; let http = FakeHttpClient::create({ @@ -244,7 +248,7 @@ impl TestServer { let client_name = name.to_string(); let client = cx.update(|cx| Client::new(clock, http.clone(), cx)); let server = self.server.clone(); - let db = self.app_state.db.clone(); + let user_service = self.app_state.user_service.clone(); let connection_killers = self.connection_killers.clone(); let forbid_connections = self.forbid_connections.clone(); @@ -268,7 +272,7 @@ impl TestServer { ); let server = server.clone(); - let db = db.clone(); + let user_service = user_service.clone(); let connection_killers = connection_killers.clone(); let forbid_connections = forbid_connections.clone(); let client_name = client_name.clone(); @@ -281,7 +285,8 @@ impl TestServer { let (client_conn, server_conn, killed) = Connection::in_memory(cx.background_executor().clone()); let (connection_id_tx, connection_id_rx) = oneshot::channel(); - let user = db + let user = user_service + .as_fake() .get_user_by_id(user_id) .await .map_err(|e| { @@ -294,7 +299,7 @@ impl TestServer { cx.background_spawn(server.handle_connection( server_conn, client_name, - Principal::User(user.into()), + Principal::User(user), ZedVersion(semver::Version::new(1, 0, 0)), Some("test".to_string()), None, @@ -576,17 +581,18 @@ impl TestServer { blob_store_client: None, executor, kinesis_client: None, + user_service: FakeUserService::new(test_db.db().clone()), config: Config { http_port: 0, database_url: "".into(), database_max_connections: 0, - api_token: "".into(), livekit_server: None, livekit_key: None, livekit_secret: None, rust_log: None, log_json: None, zed_environment: "test".into(), + zed_cloud_internal_api_key: "test-internal-api-key".into(), blob_store_url: None, blob_store_region: None, blob_store_access_key: None, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index cea3806edb3..13208fe5ae1 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2913,6 +2913,13 @@ impl CollabPanel { if show_auto_watch || show_copy { Some( h_flex() + .when_some(channel_link, |this, channel_link| { + this.child( + CopyButton::new("copy-channel-link", channel_link) + .visible_on_hover("section-header") + .tooltip_label("Copy Channel Link"), + ) + }) .when(has_auto_watch_flag, |this| { this.child( IconButton::new( @@ -2952,13 +2959,6 @@ impl CollabPanel { )), ) }) - .when_some(channel_link, |this, channel_link| { - this.child( - CopyButton::new("copy-channel-link", channel_link) - .visible_on_hover("section-header") - .tooltip_label("Copy Channel Link"), - ) - }) .into_any_element(), ) } else { @@ -3830,6 +3830,12 @@ impl Panel for CollabPanel { fn activation_priority(&self) -> u32 { 5 } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.collaboration_panel.get_or_insert_default().button = Some(false); + })) + } } impl Focusable for CollabPanel { diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 1781a8e93e0..befe7703e65 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -6,7 +6,8 @@ use client::{ use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement, - Render, Styled, Subscription, Task, WeakEntity, Window, actions, anchored, deferred, div, + Render, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, anchored, deferred, + div, }; use picker::{Picker, PickerDelegate}; use std::sync::Arc; diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 71940794f41..5a9628ac87d 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -1,7 +1,7 @@ use crate::notification_window_options; use call::{ActiveCall, IncomingCall}; use futures::StreamExt; -use gpui::{App, WindowHandle, prelude::*}; +use gpui::{App, TaskExt, WindowHandle, prelude::*}; use std::sync::{Arc, Weak}; use ui::{CollabNotification, prelude::*}; diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 3c231c5397a..e39d1cd32a5 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -2,7 +2,7 @@ use crate::notification_window_options; use call::{ActiveCall, room}; use client::User; use collections::HashMap; -use gpui::{App, Size}; +use gpui::{App, Size, TaskExt}; use std::sync::{Arc, Weak}; use ui::{CollabNotification, prelude::*}; diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 68d04537a02..35af6f071be 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -16,7 +16,7 @@ use command_palette_hooks::{ use fuzzy_nucleo::{StringMatch, StringMatchCandidate}; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - ParentElement, Render, Styled, Task, WeakEntity, Window, + ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window, }; use persistence::CommandPaletteDB; use picker::Direction; diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 3a51accb780..39288c5a6d8 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -17,7 +17,6 @@ test-support = ["gpui/test-support"] [dependencies] anyhow.workspace = true async-channel.workspace = true -async-process.workspace = true async-trait.workspace = true base64.workspace = true collections.workspace = true diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index 1c433d9fd34..b8a321d01b5 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -131,6 +131,7 @@ struct Notification<'a, T> { jsonrpc: &'static str, #[serde(borrow)] method: &'a str, + #[serde(skip_serializing_if = "is_null_value")] params: T, } diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 4bf4b77cda7..5ee5fc30c22 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -1,15 +1,16 @@ use std::path::PathBuf; use std::pin::Pin; -use anyhow::{Context as _, Result}; -use async_process::Child; +use anyhow::Result; use async_trait::async_trait; use futures::io::{BufReader, BufWriter}; use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; + use util::TryFutureExt as _; +use util::process::Child; use util::shell::Shell; use util::shell_builder::ShellBuilder; @@ -31,22 +32,20 @@ impl StdioTransport { ) -> Result { let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive(); let mut command = - builder.build_smol_command(Some(binary.executable.display().to_string()), &binary.args); + builder.build_std_command(Some(binary.executable.display().to_string()), &binary.args); - command - .envs(binary.env.unwrap_or_default()) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); + command.envs(binary.env.unwrap_or_default()); if let Some(working_directory) = working_directory { command.current_dir(working_directory); } - let mut server = command - .spawn() - .with_context(|| format!("failed to spawn command {command:?})",))?; + let mut server = Child::spawn( + command, + std::process::Stdio::piped(), + std::process::Stdio::piped(), + std::process::Stdio::piped(), + )?; let stdin = server.stdin.take().unwrap(); let stdout = server.stdout.take().unwrap(); diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index e789a89df65..4b75feafe4b 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -10,7 +10,7 @@ use edit_prediction_types::{ EditPrediction, EditPredictionDelegate, EditPredictionDiscardReason, EditPredictionIconSet, interpolate_edits, }; -use gpui::{App, Context, Entity, Task}; +use gpui::{App, Context, Entity, Task, TaskExt}; use icons::IconName; use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToPointUtf16}; use std::{ops::Range, sync::Arc, time::Duration}; diff --git a/crates/copilot_chat/src/copilot_chat.rs b/crates/copilot_chat/src/copilot_chat.rs index fb89c2e0853..ab5c08b6174 100644 --- a/crates/copilot_chat/src/copilot_chat.rs +++ b/crates/copilot_chat/src/copilot_chat.rs @@ -9,6 +9,7 @@ use anyhow::{Result, anyhow}; use collections::HashSet; use fs::Fs; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; +use gpui::TaskExt; use gpui::WeakEntity; use gpui::{App, AsyncApp, Global, prelude::*}; use http_client::HttpRequestExt; diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 09267020e5c..f0408ea063a 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -6,7 +6,7 @@ use copilot::{ use gpui::{ App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, - Subscription, Window, WindowBounds, WindowOptions, div, point, + Subscription, TaskExt, Window, WindowBounds, WindowOptions, div, point, }; use project::project_settings::ProjectSettings; use settings::Settings as _; diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 749a6cd7888..76d31bdd232 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -11,7 +11,8 @@ use futures::{ }; use gpui::{ App, AppContext, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, actions, div, + ParentElement, Render, SharedString, Styled, Subscription, TaskExt, WeakEntity, Window, + actions, div, }; use project::{ Project, diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 6e537ae0c6e..5f07f2a70d2 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -1,7 +1,7 @@ use dap::{DapRegistry, DebugRequest}; use futures::channel::oneshot; use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task}; +use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, TaskExt}; use gpui::{Subscription, WeakEntity}; use picker::{Picker, PickerDelegate}; use project::Project; diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index f92b87a773c..c034363bcd9 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -19,7 +19,7 @@ use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_ use gpui::{ Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, - Subscription, Task, WeakEntity, anchored, deferred, + Subscription, Task, TaskExt, WeakEntity, anchored, deferred, }; use itertools::Itertools as _; @@ -1606,6 +1606,12 @@ impl Panel for DebugPanel { 7 } + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.debugger.get_or_insert_default().button = Some(false); + })) + } + fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context) {} fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index f5947a4393b..2fe87d1ef00 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -2,7 +2,7 @@ use std::any::TypeId; use debugger_panel::DebugPanel; use editor::{Editor, MultiBufferOffsetUtf16}; -use gpui::{Action, App, DispatchPhase, EntityInputHandler, actions}; +use gpui::{Action, App, DispatchPhase, EntityInputHandler, TaskExt, actions}; use new_process_modal::{NewProcessModal, NewProcessMode}; use project::debugger::{self, breakpoint_store::SourceBreakpoint, session::ThreadStatus}; use schemars::JsonSchema; diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index f0d243995f6..6c1fe4c45b4 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -16,7 +16,7 @@ use editor::Editor; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, App, AppContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - KeyContext, Render, Subscription, Task, WeakEntity, actions, + KeyContext, Render, Subscription, Task, TaskExt, WeakEntity, actions, }; use itertools::Itertools as _; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index c496aa193a9..a964eb389f6 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -33,7 +33,7 @@ use dap::{ use futures::{SinkExt, channel::mpsc}; use gpui::{ Action as _, AnyView, AppContext, Axis, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - NoAction, Pixels, Point, Subscription, Task, WeakEntity, + NoAction, Pixels, Point, Subscription, Task, TaskExt, WeakEntity, }; use language::Buffer; use loaded_source_list::LoadedSourceList; diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index 7175b8556a4..982fc0f8567 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -8,7 +8,7 @@ use dap::adapters::DebugAdapterName; use db::kvp::KeyValueStore; use gpui::{ Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, - Subscription, Task, WeakEntity, list, + Subscription, Task, TaskExt, WeakEntity, list, }; use util::{ debug_panic, diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 991961f627c..4f39ae49db9 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -8,8 +8,9 @@ use dap::{ use editor::Editor; use gpui::{ Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity, - FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TextStyleRefinement, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TaskExt, + TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, + uniform_list, }; use itertools::Itertools; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious}; diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index fe850303e83..0c96524ec03 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -19,6 +19,7 @@ collections.workspace = true component.workspace = true ctor.workspace = true editor.workspace = true +futures-lite.workspace = true gpui.workspace = true indoc.workspace = true itertools.workspace = true diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index e703e193c31..b05e6a0f438 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -13,7 +13,7 @@ use editor::{ use gpui::{ AnyElement, App, AppContext, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, WeakEntity, Window, actions, div, + Task, TaskExt, WeakEntity, Window, actions, div, }; use language::{Buffer, Capability, DiagnosticEntry, DiagnosticEntryRef, Point}; use project::{ diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 4ee8259dd69..de99274d86a 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -981,24 +981,26 @@ async fn context_range_for_entry( snapshot: BufferSnapshot, cx: &mut AsyncApp, ) -> Range { - let range = if let Some(rows) = heuristic_syntactic_expand( + let expanded_range = heuristic_syntactic_expand( range.clone(), DIAGNOSTIC_EXPANSION_ROW_LIMIT, snapshot.clone(), cx, ) - .await - .filter(|rows| rows.start() != rows.end()) - { - Range { - start: Point::new(*rows.start(), 0), - end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left), - } + .await; + let row_range = expanded_range.unwrap_or_else(|| range.start.row..=range.end.row); + let row_count = row_range.end().saturating_sub(*row_range.start()) + 1; + let target_row_count = context.saturating_mul(2).saturating_add(1); + let row_range = if let Some(rows_to_add) = target_row_count.checked_sub(row_count) { + let rows_before = rows_to_add.div_ceil(2); + let rows_after = rows_to_add / 2; + row_range.start().saturating_sub(rows_before)..=row_range.end().saturating_add(rows_after) } else { - Range { - start: Point::new(range.start.row.saturating_sub(context), 0), - end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left), - } + row_range + }; + let range = Range { + start: Point::new(*row_range.start(), 0), + end: snapshot.clip_point(Point::new(*row_range.end(), u32::MAX), Bias::Left), }; snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end) } @@ -1050,47 +1052,41 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - cx.background_executor() - .await_on_background(async { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = - previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() - && child_range.contains(&input_range.start) - { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } - } + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() && child_range.contains(&input_range.start) { + included_child_start = Some(child_range.start); } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); - return; - } + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } - ancestor_range = Some(None); } - }) - .await; + } + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + } + } + if ancestor_range.is_none() { + ancestor_range = Some(None); + } + } if let Some(node) = ancestor_range { return node; } @@ -1139,6 +1135,7 @@ async fn heuristic_syntactic_expand( return None; }; node = parent; + futures_lite::future::yield_now().await; } } diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 67a6877bbe9..7733dab8201 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -2,15 +2,15 @@ use std::time::Duration; use editor::{Editor, MultiBufferOffset}; use gpui::{ - Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Task, - WeakEntity, Window, + App, Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, + Task, WeakEntity, Window, }; use language::Diagnostic; use project::project_settings::{GoToDiagnosticSeverityFilter, ProjectSettings}; use settings::Settings; use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*}; use util::ResultExt; -use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle}; +use workspace::{HideStatusItem, StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle}; use crate::{Deploy, IncludeWarnings, ProjectDiagnosticsEditor}; @@ -224,4 +224,10 @@ impl StatusItemView for DiagnosticIndicator { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings.diagnostics.get_or_insert_default().button = Some(false); + })) + } } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 6c98e296ef4..7f835dfbdf1 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -24,6 +24,7 @@ use futures::{ select_biased, }; use gpui::BackgroundExecutor; +use gpui::TaskExt; use gpui::http_client::Url; use gpui::{ App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions, @@ -32,16 +33,16 @@ use gpui::{ }; use heapless::Vec as ArrayVec; use language::{ - Anchor, Buffer, BufferSnapshot, EditPredictionsMode, EditPreview, File, OffsetRangeExt, Point, - TextBufferSnapshot, ToOffset, ToPoint, language_settings::all_language_settings, + Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, EditPredictionsMode, EditPreview, + File, OffsetRangeExt, Point, TextBufferSnapshot, ToOffset, ToPoint, + language_settings::all_language_settings, }; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; use serde::de::DeserializeOwned; use settings::{ - EditPredictionDataCollectionChoice, EditPredictionPromptFormat, EditPredictionProvider, - Settings as _, update_settings_file, + EditPredictionDataCollectionChoice, EditPredictionProvider, Settings as _, update_settings_file, }; use std::collections::{VecDeque, hash_map}; use std::env; diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 44a5b2541fb..301ca7fb468 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -6,10 +6,9 @@ use crate::{ use anyhow::{Context as _, Result, anyhow}; use gpui::{App, AppContext as _, Entity, Task}; use language::{ - Anchor, Buffer, BufferSnapshot, ToOffset, ToPoint as _, + Anchor, Buffer, BufferSnapshot, EditPredictionPromptFormat, ToOffset, ToPoint as _, language_settings::all_language_settings, }; -use settings::EditPredictionPromptFormat; use std::{path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges}; diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 8e9dfa6cee3..492071f7c7b 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -8,7 +8,7 @@ use cloud_llm_client::EditPredictionRejectReason; use credentials_provider::CredentialsProvider; use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Context, Entity, Global, SharedString, Task, + App, AppContext as _, Context, Entity, Global, SharedString, Task, TaskExt, http_client::{self, AsyncBody, HttpClient, Method, StatusCode}, }; use language::{ToOffset, ToPoint as _}; diff --git a/crates/edit_prediction/src/ollama.rs b/crates/edit_prediction/src/ollama.rs index 0ae90dd9f6e..fc0f36d8321 100644 --- a/crates/edit_prediction/src/ollama.rs +++ b/crates/edit_prediction/src/ollama.rs @@ -1,7 +1,7 @@ use anyhow::{Context as _, Result}; use futures::AsyncReadExt as _; use gpui::{ - App, SharedString, + App, SharedString, TaskExt, http_client::{self, HttpClient}, }; use language::language_settings::OpenAiCompatibleEditPredictionSettings; diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index c2e622ea010..a5637ca3cec 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -10,13 +10,12 @@ use cloud_llm_client::{ AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest, }; use edit_prediction_types::PredictedCursorPosition; -use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*}; +use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*}; use language::{ - Buffer, BufferSnapshot, DiagnosticSeverity, OffsetRangeExt as _, ToOffset as _, - language_settings::all_language_settings, text_diff, + Buffer, BufferSnapshot, DiagnosticSeverity, EditPredictionPromptFormat, OffsetRangeExt as _, + ToOffset as _, ZetaVersion, language_settings::all_language_settings, text_diff, }; use release_channel::AppVersion; -use settings::EditPredictionPromptFormat; use text::{Anchor, Bias, Point}; use ui::SharedString; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; @@ -24,8 +23,7 @@ use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{env, ops::Range, path::Path, sync::Arc}; use zeta_prompt::{ - ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, - prompt_input_contains_special_tokens, stop_tokens_for_format, + ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, stop_tokens_for_format, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -102,10 +100,30 @@ pub fn request_prediction_with_zeta( let request_task = cx.background_spawn({ async move { - let zeta_version = raw_config + let local_zeta_version = custom_server_settings + .as_ref() + .and_then(|settings| match settings.prompt_format { + EditPredictionPromptFormat::Zeta(version) => Some(version), + EditPredictionPromptFormat::Infer => { + match settings.model.to_ascii_lowercase().as_str() { + "zeta" | "zeta1" => Some(ZetaVersion::Zeta1), + "zeta2" => Some(ZetaVersion::Zeta2), + "zeta2.1" => Some(ZetaVersion::Zeta2_1), + _ => None, + } + } + _ => None, + }) + .unwrap_or_default(); + let zeta_format = raw_config .as_ref() .map(|config| config.format) - .unwrap_or(ZetaFormat::default()); + .or(match local_zeta_version { + ZetaVersion::Zeta1 => None, + ZetaVersion::Zeta2 => Some(ZetaFormat::V0211SeedCoder), + ZetaVersion::Zeta2_1 => Some(ZetaFormat::V0318SeedMultiRegions), + }) + .unwrap_or_default(); let cursor_offset = position.to_offset(&snapshot); let (full_context_offset_range, prompt_input) = zeta2_prompt_input( @@ -120,11 +138,7 @@ pub fn request_prediction_with_zeta( repo_url, ); - if prompt_input_contains_special_tokens(&prompt_input, zeta_version) { - return Err(anyhow::anyhow!("prompt contains special tokens")); - } - - let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version); + let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_format); if let Some(debug_tx) = &debug_tx { debug_tx @@ -144,8 +158,8 @@ pub fn request_prediction_with_zeta( (if let Some(custom_settings) = &custom_server_settings { let max_tokens = custom_settings.max_output_tokens * 4; - Some(match custom_settings.prompt_format { - EditPredictionPromptFormat::Zeta => { + Some(match local_zeta_version { + ZetaVersion::Zeta1 => { let ranges = &prompt_input.excerpt_ranges; let editable_range_in_excerpt = ranges.editable_350.clone(); let prompt = zeta1::format_zeta1_from_input( @@ -181,11 +195,11 @@ pub fn request_prediction_with_zeta( (request_id, parsed_output, None, None) } - EditPredictionPromptFormat::Zeta2 => { + ZetaVersion::Zeta2 | ZetaVersion::Zeta2_1 => { let Some(prompt) = formatted_prompt.clone() else { return Ok((None, None)); }; - let prefill = get_prefill(&prompt_input, zeta_version); + let prefill = get_prefill(&prompt_input, zeta_format); let prompt = format!("{prompt}{prefill}"); let (response_text, request_id) = send_custom_server_request( @@ -193,7 +207,7 @@ pub fn request_prediction_with_zeta( custom_settings, prompt, max_tokens, - stop_tokens_for_format(zeta_version) + stop_tokens_for_format(zeta_format) .iter() .map(|token| token.to_string()) .collect(), @@ -209,14 +223,13 @@ pub fn request_prediction_with_zeta( let output = format!("{prefill}{response_text}"); Some(parse_zeta2_model_output( &output, - zeta_version, + zeta_format, &prompt_input, )?) }; (request_id, output_text, None, None) } - _ => anyhow::bail!("unsupported prompt format"), }) } else if let Some(config) = &raw_config { let Some(prompt) = format_zeta_prompt(&prompt_input, config.format) else { diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index bbd12dec4e3..a5dd0c15783 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -2,7 +2,9 @@ use crate::assemble_excerpts::assemble_excerpt_ranges; use anyhow::Result; use collections::HashMap; use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; -use gpui::{App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, TaskExt, WeakEntity, +}; use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; use project::{LocationLink, Project, ProjectPath}; use smallvec::SmallVec; diff --git a/crates/edit_prediction_metrics/Cargo.toml b/crates/edit_prediction_metrics/Cargo.toml index 62184f2f6f9..ba4990d9381 100644 --- a/crates/edit_prediction_metrics/Cargo.toml +++ b/crates/edit_prediction_metrics/Cargo.toml @@ -12,7 +12,7 @@ workspace = true path = "src/edit_prediction_metrics.rs" [dependencies] -language.workspace = true +imara-diff.workspace = true serde.workspace = true serde_json = "1.0" similar = "2.7.0" diff --git a/crates/edit_prediction_metrics/src/prediction_score.rs b/crates/edit_prediction_metrics/src/prediction_score.rs index 55c1d828762..942ce3c9d1a 100644 --- a/crates/edit_prediction_metrics/src/prediction_score.rs +++ b/crates/edit_prediction_metrics/src/prediction_score.rs @@ -218,7 +218,9 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore { for expected in input.expected_patches { let delta_chr_f_metrics = delta_chr_f(input.original_text, &expected.text, &actual_text); - if delta_chr_f_metrics.score > best_delta_chr_f_metrics.score { + if best_expected_text.is_none() + || delta_chr_f_metrics.score > best_delta_chr_f_metrics.score + { best_delta_chr_f_metrics = delta_chr_f_metrics; best_expected_cursor = expected.cursor_editable_region_offset; best_expected_text = Some(expected.text.as_str()); @@ -317,3 +319,33 @@ fn compute_cursor_metrics( (Some(_), None) | (None, Some(_)) => (None, Some(false)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kept_rate_is_computed_when_best_delta_chr_f_score_is_zero() { + let original_text = ""; + let actual_patch = "--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+bbbbbb\n"; + let expected_patch = "--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+cccccc\n"; + let expected_patches = [PreparedExpectedPatch { + patch: expected_patch.to_string(), + text: "cccccc".to_string(), + cursor_editable_region_offset: None, + }]; + + let score = score_prediction(PredictionScoringInput { + original_text, + expected_patches: &expected_patches, + actual_patch: Some(actual_patch), + actual_cursor: None, + reversal_context: None, + cumulative_logprob: None, + avg_logprob: None, + }); + + assert_eq!(score.delta_chr_f, 0.0); + assert_eq!(score.kept_rate, Some(0.0)); + } +} diff --git a/crates/edit_prediction_metrics/src/reversal.rs b/crates/edit_prediction_metrics/src/reversal.rs index d6263d84c3f..fb84f57aa4e 100644 --- a/crates/edit_prediction_metrics/src/reversal.rs +++ b/crates/edit_prediction_metrics/src/reversal.rs @@ -1,10 +1,155 @@ +use std::iter; use std::ops::Range; use std::path::Path; use std::sync::Arc; -use language::{char_diff, text_diff}; +use crate::tokenize::tokenize; +use imara_diff::{ + Algorithm, diff, + intern::{InternedInput, Token}, + sources::lines_with_terminator, +}; use zeta_prompt::udiff::apply_diff_to_string; +fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range, Arc)> { + let empty: Arc = Arc::default(); + let mut edits = Vec::new(); + let mut hunk_input = InternedInput::default(); + let input = InternedInput::new( + lines_with_terminator(old_text), + lines_with_terminator(new_text), + ); + + diff_internal(&input, &mut |old_byte_range, + new_byte_range, + old_rows, + new_rows| { + if should_perform_token_diff_within_hunk( + &old_byte_range, + &new_byte_range, + &old_rows, + &new_rows, + ) { + let old_offset = old_byte_range.start; + let new_offset = new_byte_range.start; + hunk_input.clear(); + hunk_input.update_before(tokenize(&old_text[old_byte_range]).into_iter()); + hunk_input.update_after(tokenize(&new_text[new_byte_range]).into_iter()); + diff_internal(&hunk_input, &mut |old_byte_range, new_byte_range, _, _| { + let old_byte_range = + old_offset + old_byte_range.start..old_offset + old_byte_range.end; + let new_byte_range = + new_offset + new_byte_range.start..new_offset + new_byte_range.end; + let replacement_text = if new_byte_range.is_empty() { + empty.clone() + } else { + new_text[new_byte_range].into() + }; + edits.push((old_byte_range, replacement_text)); + }); + } else { + let replacement_text = if new_byte_range.is_empty() { + empty.clone() + } else { + new_text[new_byte_range].into() + }; + edits.push((old_byte_range, replacement_text)); + } + }); + + edits +} + +fn char_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec<(Range, &'a str)> { + let mut input: InternedInput<&str> = InternedInput::default(); + input.update_before(tokenize_chars(old_text)); + input.update_after(tokenize_chars(new_text)); + let mut edits = Vec::new(); + + diff_internal(&input, &mut |old_byte_range, new_byte_range, _, _| { + let replacement = if new_byte_range.is_empty() { + "" + } else { + &new_text[new_byte_range] + }; + edits.push((old_byte_range, replacement)); + }); + + edits +} + +fn should_perform_token_diff_within_hunk( + old_byte_range: &Range, + new_byte_range: &Range, + old_row_range: &Range, + new_row_range: &Range, +) -> bool { + const MAX_TOKEN_DIFF_LEN: usize = 512; + const MAX_TOKEN_DIFF_LINE_COUNT: usize = 8; + + !old_byte_range.is_empty() + && !new_byte_range.is_empty() + && old_byte_range.len() <= MAX_TOKEN_DIFF_LEN + && new_byte_range.len() <= MAX_TOKEN_DIFF_LEN + && old_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT + && new_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT +} + +fn diff_internal( + input: &InternedInput<&str>, + on_change: &mut dyn FnMut(Range, Range, Range, Range), +) { + let mut old_offset = 0; + let mut new_offset = 0; + let mut old_token_ix = 0; + let mut new_token_ix = 0; + + diff( + Algorithm::Histogram, + input, + |old_tokens: Range, new_tokens: Range| { + old_offset += token_len( + input, + &input.before[old_token_ix as usize..old_tokens.start as usize], + ); + new_offset += token_len( + input, + &input.after[new_token_ix as usize..new_tokens.start as usize], + ); + let old_len = token_len( + input, + &input.before[old_tokens.start as usize..old_tokens.end as usize], + ); + let new_len = token_len( + input, + &input.after[new_tokens.start as usize..new_tokens.end as usize], + ); + let old_byte_range = old_offset..old_offset + old_len; + let new_byte_range = new_offset..new_offset + new_len; + old_token_ix = old_tokens.end; + new_token_ix = new_tokens.end; + old_offset = old_byte_range.end; + new_offset = new_byte_range.end; + on_change(old_byte_range, new_byte_range, old_tokens, new_tokens); + }, + ); +} + +fn tokenize_chars(text: &str) -> impl Iterator { + let mut chars = text.char_indices(); + iter::from_fn(move || { + let (start, character) = chars.next()?; + Some(&text[start..start + character.len_utf8()]) + }) +} + +fn token_len(input: &InternedInput<&str>, tokens: &[Token]) -> usize { + tokens + .iter() + .map(|token| input.interner[*token].len()) + .sum() +} + fn apply_diff_to_string_lenient(diff_str: &str, text: &str) -> String { let hunks = parse_diff_hunks(diff_str); let mut result = text.to_string(); @@ -651,7 +796,7 @@ pub fn compute_prediction_reversal_ratio_from_history( mod tests { use super::*; use indoc::indoc; - use zeta_prompt::udiff::apply_diff_to_string; + use zeta_prompt::udiff::{apply_diff_to_string, unified_diff_with_context}; use zeta_prompt::{ExcerptRanges, ZetaPromptInput}; fn compute_prediction_reversal_ratio( @@ -1008,8 +1153,8 @@ mod tests { last line "}; - // unified_diff doesn't include file headers, but apply_diff_to_string needs them - let diff_body = language::unified_diff(original, modified); + // unified_diff_with_context doesn't include file headers, but apply_diff_to_string needs them + let diff_body = unified_diff_with_context(original, modified, 0, 0, 3); let forward_diff = format!("--- a/file\n+++ b/file\n{}", diff_body); let reversed_diff = reverse_diff(&forward_diff); diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index d8e52fe8a7b..43edcfb8910 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -12,7 +12,7 @@ use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ Action, Anchor, Animation, AnimationExt, App, AsyncWindowContext, Entity, FocusHandle, - Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div, + Focusable, IntoElement, ParentElement, Render, Subscription, TaskExt, WeakEntity, actions, div, ease_in_out, pulsating_between, }; use indoc::indoc; @@ -37,7 +37,7 @@ use ui::{ use util::ResultExt as _; use workspace::{ - StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, + HideStatusItem, StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; use zed_actions::{OpenBrowser, OpenSettingsAt}; @@ -1364,6 +1364,13 @@ impl StatusItemView for EditPredictionButton { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + // This button is already gated on having a non-disabled edit + // prediction provider, which the user manages through provider/AI + // settings. + None + } } async fn open_disabled_globs_setting_in_editor( diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index 2f6280619ad..05f1224f506 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -7,6 +7,7 @@ use edit_prediction::{EditPredictionStore, ResetOnboarding, capture_example}; use edit_prediction_context_view::EditPredictionContextView; use editor::Editor; use feature_flags::FeatureFlagAppExt as _; +use gpui::TaskExt; use gpui::actions; use language::language_settings::AllLanguageSettings; use project::DisableAiSettings; diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs index c52089ca6ac..fbe58b06abb 100644 --- a/crates/editor/src/clangd_ext.rs +++ b/crates/editor/src/clangd_ext.rs @@ -1,5 +1,5 @@ use anyhow::Context as _; -use gpui::{App, Context, Entity, Window}; +use gpui::{App, Context, Entity, TaskExt, Window}; use language::Language; use project::lsp_store::lsp_ext_command::SwitchSourceHeaderResult; use rpc::proto; diff --git a/crates/editor/src/code_actions.rs b/crates/editor/src/code_actions.rs new file mode 100644 index 00000000000..a5d33926d0c --- /dev/null +++ b/crates/editor/src/code_actions.rs @@ -0,0 +1,523 @@ +use super::*; + +impl Editor { + /// Toggles an action selection menu for the latest selection. + /// May show LSP code actions, code lens' command, runnables and potentially more entities applicable as actions. + /// Previous menu toggled with this method will be closed. + pub fn toggle_code_actions( + &mut self, + action: &ToggleCodeActions, + window: &mut Window, + cx: &mut Context, + ) { + let quick_launch = action.quick_launch; + let mut context_menu = self.context_menu.borrow_mut(); + if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { + if code_actions.deployed_from == action.deployed_from { + // Toggle if we're selecting the same one + *context_menu = None; + cx.notify(); + return; + } else { + // Otherwise, clear it and start a new one + *context_menu = None; + cx.notify(); + } + } + drop(context_menu); + let snapshot = self.snapshot(window, cx); + let deployed_from = action.deployed_from.clone(); + let action = action.clone(); + self.completion_tasks.clear(); + self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + + let multibuffer_point = match &action.deployed_from { + Some(CodeActionSource::Indicator(row)) | Some(CodeActionSource::RunMenu(row)) => { + DisplayPoint::new(*row, 0).to_point(&snapshot) + } + _ => self + .selections + .newest::(&snapshot.display_snapshot) + .head(), + }; + let Some((buffer, buffer_row)) = snapshot + .buffer_snapshot() + .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) + .and_then(|(buffer_snapshot, range)| { + self.buffer() + .read(cx) + .buffer(buffer_snapshot.remote_id()) + .map(|buffer| (buffer, range.start.row)) + }) + else { + return; + }; + let buffer_id = buffer.read(cx).remote_id(); + let tasks = self + .runnables + .runnables((buffer_id, buffer_row)) + .map(|t| Arc::new(t.to_owned())); + + let project = self.project.clone(); + let runnable_task = match deployed_from { + Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), + _ => { + let mut task_context_task = Task::ready(Ok(None)); + let workspace = self.workspace().map(|w| w.downgrade()); + if let Some(tasks) = &tasks + && let Some(project) = project + { + task_context_task = + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx); + } + + cx.spawn_in(window, { + let buffer = buffer.clone(); + async move |editor, cx| { + let task_context = match workspace { + Some(ws) => task_context_task + .await + .notify_workspace_async_err(ws, cx) + .flatten(), + None => task_context_task.await.ok().flatten(), + }; + + let resolved_tasks = + tasks + .zip(task_context.clone()) + .map(|(tasks, task_context)| ResolvedTasks { + templates: tasks.resolve(&task_context).collect(), + position: snapshot.buffer_snapshot().anchor_before(Point::new( + multibuffer_point.row, + tasks.column, + )), + }); + let debug_scenarios = editor + .update(cx, |editor, cx| { + editor.debug_scenarios(&resolved_tasks, &buffer, cx) + })? + .await; + anyhow::Ok((resolved_tasks, debug_scenarios, task_context)) + } + }) + } + }; + + let toggle_task = cx.spawn_in(window, async move |editor, cx| { + let (resolved_tasks, debug_scenarios, task_context) = runnable_task.await?; + + let code_actions = if let Some(CodeActionSource::RunMenu(_)) = &deployed_from { + None + } else { + editor.update(cx, |editor, _cx| match &editor.code_actions_for_selection { + CodeActionsForSelection::None => None, + CodeActionsForSelection::Fetching(task) => Some(task.clone()), + CodeActionsForSelection::Ready(action_fetch_ready) => { + Some(Task::ready(Some(action_fetch_ready.clone())).shared()) + } + })? + }; + let code_actions = match code_actions { + Some(code_actions) => code_actions + .await + .filter(|ActionFetchReady { location, .. }| { + let snapshot = location.buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let point_range = location.range.to_point(&snapshot); + (point_range.start.row..=point_range.end.row).contains(&buffer_row) + }) + .map(|ActionFetchReady { actions, .. }| actions), + None => None, + }; + + editor.update_in(cx, |editor, window, cx| { + let spawn_straight_away = quick_launch + && resolved_tasks + .as_ref() + .is_some_and(|tasks| tasks.templates.len() == 1) + && code_actions + .as_ref() + .is_none_or(|actions| actions.is_empty()) + && debug_scenarios.is_empty(); + + crate::hover_popover::hide_hover(editor, cx); + let actions = CodeActionContents::new( + resolved_tasks, + code_actions, + debug_scenarios, + task_context.unwrap_or_default(), + ); + + // Don't show the menu if there are no actions available + if actions.is_empty() { + cx.notify(); + return Task::ready(Ok(())); + } + + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::CodeActions(CodeActionsMenu { + buffer, + actions, + selected_item: Default::default(), + scroll_handle: UniformListScrollHandle::default(), + deployed_from, + })); + cx.notify(); + if spawn_straight_away + && let Some(task) = editor.confirm_code_action( + &ConfirmCodeAction { item_ix: Some(0) }, + window, + cx, + ) + { + return task; + } + + Task::ready(Ok(())) + }) + }); + self.runnables_for_selection_toggle = cx.background_spawn(async move { + match toggle_task.await { + Ok(code_action_spawn) => match code_action_spawn.await { + Ok(()) => {} + Err(e) => log::error!("failed to spawn a toggled code action: {e:#}"), + }, + Err(e) => log::error!("failed to toggle code actions: {e:#}"), + } + }) + } + + pub fn confirm_code_action( + &mut self, + action: &ConfirmCodeAction, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + + let actions_menu = + if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { + menu + } else { + return None; + }; + + let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); + let action = actions_menu.actions.get(action_ix)?; + let title = action.label(); + let buffer = actions_menu.buffer; + let workspace = self.workspace()?; + + match action { + CodeActionsItem::Task(task_source_kind, resolved_task) => { + workspace.update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + + Some(Task::ready(Ok(()))) + }) + } + CodeActionsItem::CodeAction { action, provider } => { + if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { + return Some(Task::ready(Ok(()))); + } + + let apply_code_action = + provider.apply_code_action(buffer, action, true, window, cx); + let workspace = workspace.downgrade(); + Some(cx.spawn_in(window, async move |editor, cx| { + let project_transaction = apply_code_action.await?; + Self::open_project_transaction( + &editor, + workspace, + project_transaction, + title, + cx, + ) + .await + })) + } + CodeActionsItem::DebugScenario(scenario) => { + let context = actions_menu.actions.context.into(); + + workspace.update(cx, |workspace, cx| { + dap::send_telemetry(&scenario, TelemetrySpawnLocation::Gutter, cx); + workspace.start_debug_session( + scenario, + context, + Some(buffer), + None, + window, + cx, + ); + }); + Some(Task::ready(Ok(()))) + } + } + } + + pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool { + !self.code_action_providers.is_empty() + && EditorSettings::get_global(cx).toolbar.code_actions + } + + pub fn has_available_code_actions_for_selection(&self) -> bool { + if let CodeActionsForSelection::Ready(ready) = &self.code_actions_for_selection { + !ready.actions.is_empty() + } else { + false + } + } + + pub fn context_menu(&self) -> &RefCell> { + &self.context_menu + } + + pub(super) fn render_inline_code_actions( + &self, + icon_size: ui::IconSize, + display_row: DisplayRow, + is_active: bool, + cx: &mut Context, + ) -> AnyElement { + let show_tooltip = !self.context_menu_visible(); + IconButton::new("inline_code_actions", ui::IconName::BoltFilled) + .icon_size(icon_size) + .shape(ui::IconButtonShape::Square) + .icon_color(ui::Color::Hidden) + .toggle_state(is_active) + .when(show_tooltip, |this| { + this.tooltip({ + let focus_handle = self.focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Toggle Code Actions", + &ToggleCodeActions { + deployed_from: None, + quick_launch: false, + }, + &focus_handle, + cx, + ) + } + }) + }) + .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { + window.focus(&editor.focus_handle(cx), cx); + editor.toggle_code_actions( + &crate::actions::ToggleCodeActions { + deployed_from: Some(crate::actions::CodeActionSource::Indicator( + display_row, + )), + quick_launch: false, + }, + window, + cx, + ); + })) + .into_any_element() + } + + pub(super) fn refresh_code_actions_for_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.code_actions_for_selection = CodeActionsForSelection::Fetching( + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor() + .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) + .await; + + let (start_buffer, start, _, end, _newest_selection) = editor + .update(cx, |editor, cx| { + let newest_selection = editor.selections.newest_anchor().clone(); + if newest_selection.head().diff_base_anchor().is_some() { + return None; + } + let display_snapshot = editor.display_snapshot(cx); + let newest_selection_adjusted = + editor.selections.newest_adjusted(&display_snapshot); + let buffer = editor.buffer.read(cx); + + let (start_buffer, start) = + buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; + let (end_buffer, end) = + buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; + + Some((start_buffer, start, end_buffer, end, newest_selection)) + }) + .ok() + .flatten() + .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer)?; + + let (providers, tasks) = editor + .update_in(cx, |editor, window, cx| { + let providers = editor.code_action_providers.clone(); + let tasks = editor + .code_action_providers + .iter() + .map(|provider| { + provider.code_actions(&start_buffer, start..end, window, cx) + }) + .collect::>(); + (providers, tasks) + }) + .ok()?; + + let mut actions = Vec::new(); + for (provider, provider_actions) in + providers.into_iter().zip(future::join_all(tasks).await) + { + if let Some(provider_actions) = provider_actions.log_err() { + actions.extend(provider_actions.into_iter().map(|action| { + AvailableCodeAction { + action, + provider: provider.clone(), + } + })); + } + } + + editor + .update(cx, |editor, cx| { + let new_actions = if actions.is_empty() { + editor.code_actions_for_selection = CodeActionsForSelection::None; + None + } else { + let new_actions = ActionFetchReady { + location: Location { + buffer: start_buffer, + range: start..end, + }, + actions: Rc::from(actions), + }; + editor.code_actions_for_selection = + CodeActionsForSelection::Ready(new_actions.clone()); + Some(new_actions) + }; + cx.notify(); + new_actions + }) + .ok() + .flatten() + }) + .shared(), + ); + } + + fn debug_scenarios( + &mut self, + resolved_tasks: &Option, + buffer: &Entity, + cx: &mut App, + ) -> Task> { + maybe!({ + let project = self.project()?; + let dap_store = project.read(cx).dap_store(); + let mut scenarios = vec![]; + let resolved_tasks = resolved_tasks.as_ref()?; + let buffer = buffer.read(cx); + let language = buffer.language()?; + let debug_adapter = LanguageSettings::for_buffer(&buffer, cx) + .debuggers + .first() + .map(SharedString::from) + .or_else(|| language.config().debuggers.first().map(SharedString::from))?; + + dap_store.update(cx, |dap_store, cx| { + for (_, task) in &resolved_tasks.templates { + let maybe_scenario = dap_store.debug_scenario_for_build_task( + task.original_task().clone(), + debug_adapter.clone().into(), + task.display_label().to_owned().into(), + cx, + ); + scenarios.push(maybe_scenario); + } + }); + Some(cx.background_spawn(async move { + futures::future::join_all(scenarios) + .await + .into_iter() + .flatten() + .collect::>() + })) + }) + .unwrap_or_else(|| Task::ready(vec![])) + } +} + +pub trait CodeActionProvider { + fn id(&self) -> Arc; + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + window: &mut Window, + cx: &mut App, + ) -> Task>>; + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + push_to_history: bool, + window: &mut Window, + cx: &mut App, + ) -> Task>; +} + +impl CodeActionProvider for Entity { + fn id(&self) -> Arc { + "project".into() + } + + fn code_actions( + &self, + buffer: &Entity, + range: Range, + _window: &mut Window, + cx: &mut App, + ) -> Task>> { + self.update(cx, |project, cx| { + let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { + Some(project.code_lens_actions(buffer, range.clone(), cx)) + } else { + None + }; + let code_actions = project.code_actions(buffer, range, None, cx); + cx.background_spawn(async move { + let code_lens_actions = match code_lens_actions { + Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), + None => Vec::new(), + }; + let code_actions = code_actions + .await + .context("code action fetch")? + .unwrap_or_default(); + Ok(code_lens_actions.into_iter().chain(code_actions).collect()) + }) + }) + } + + fn apply_code_action( + &self, + buffer_handle: Entity, + action: CodeAction, + push_to_history: bool, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + self.update(cx, |project, cx| { + project.apply_code_action(buffer_handle, action, push_to_history, cx) + }) + } +} diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 2c609e5ba81..904ebb1f810 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -2,8 +2,8 @@ use crate::scroll::ScrollAmount; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ AnyElement, Entity, Focusable, FontWeight, ListSizingBehavior, ScrollHandle, ScrollStrategy, - SharedString, Size, StrikethroughStyle, StyledText, Task, UniformListScrollHandle, div, px, - uniform_list, + SharedString, Size, StrikethroughStyle, StyledText, Task, TaskExt, UniformListScrollHandle, + div, px, uniform_list, }; use itertools::Itertools; use language::CodeLabel; diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs index c123eceea3d..73a8bb10063 100644 --- a/crates/editor/src/code_lens.rs +++ b/crates/editor/src/code_lens.rs @@ -1,13 +1,14 @@ use std::sync::Arc; use collections::{HashMap, HashSet}; -use futures::future::join_all; -use gpui::{MouseButton, SharedString, Task, WeakEntity}; +use futures::{StreamExt as _, future::join_all, stream::FuturesUnordered}; +use gpui::{MouseButton, SharedString, Task, TaskExt, WeakEntity}; use itertools::Itertools; use language::{BufferId, ClientCommand}; use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _}; -use project::{CodeAction, TaskSourceKind}; +use project::{CodeAction, TaskSourceKind, lsp_store::code_lens::CodeLensActions}; use task::TaskContext; +use text::ToOffset as _; use ui::{Context, Window, div, prelude::*}; @@ -27,7 +28,7 @@ struct CodeLensLine { #[derive(Clone, Debug)] struct CodeLensItem { - title: SharedString, + title: Option, action: CodeAction, } @@ -39,7 +40,7 @@ pub(super) struct CodeLensBlock { pub(super) struct CodeLensState { pub(super) blocks: HashMap>, - actions: HashMap>, + actions: HashMap, resolve_task: Task<()>, } @@ -203,7 +204,7 @@ impl Editor { .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT) .await; - let Some(tasks) = project + let Some(tasks_per_buffer) = project .update(cx, |project, cx| { project.lsp_store().update(cx, |lsp_store, cx| { buffers_to_query @@ -221,15 +222,15 @@ impl Editor { return; }; - let results = join_all(tasks).await; - if results.is_empty() { + let code_lens_per_buffer = join_all(tasks_per_buffer).await; + if code_lens_per_buffer.is_empty() { return; } editor .update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - for (buffer_id, result) in results { + for (buffer_id, result) in code_lens_per_buffer { let actions = match result { Ok(Some(actions)) => actions, Ok(None) => continue, @@ -248,58 +249,50 @@ impl Editor { }); } - /// Reconciles the set of blocks for `buffer_id` with `actions`. For each - /// existing block at row `R`: - /// - if the new fetch has no lens at `R` → remove the block (the lens is - /// gone, e.g. the function was deleted); - /// - if the new fetch has a titled lens at `R` whose rendered text - /// differs from the block's current line → swap the renderer in place - /// via [`Editor::replace_blocks`]; - /// - if the new fetch has a titled lens at `R` with the same rendered - /// text → keep the block as-is; - /// - if the new fetch has a lens at `R` but no `command` yet (the server - /// sent a shallow response that needs a separate `resolve`) → keep the - /// block as-is. The previously rendered (resolved) content stays on - /// screen until the next viewport-driven `resolve` produces a new - /// title; only then does the comparison-and-replace happen. This is - /// what keeps the post-edit screen from flickering for shallow servers - /// like `rust-analyzer`. + /// Reconcile blocks for `buffer_id` against the latest `actions`. /// - /// Rows present in the new fetch with a title but no existing block get - /// a fresh block inserted. + /// Lenses without a `command` keep a placeholder block so the line + /// stays reserved while the resolve is in flight — this is what avoids + /// the post-edit flicker on `rust-analyzer`-style servers. Lenses + /// whose resolve already came back without a usable title are dropped + /// (`resolve_visible_code_lenses` won't retry them), otherwise they'd + /// leave a permanent blank line. + /// + /// When the new fetch has only placeholders for a row but the old + /// block was already resolved we keep the old block, so the line + /// doesn't blank out until the fresh resolve lands. fn apply_lens_actions_for_buffer( &mut self, buffer_id: BufferId, - actions: Vec, + actions: CodeLensActions, snapshot: &MultiBufferSnapshot, cx: &mut Context, ) { - let mut rows_with_any_lens = HashSet::default(); - let mut titled_lenses = Vec::new(); - for action in &actions { + let mut all_lenses = Vec::new(); + for (_, action) in actions.iter().sorted_by_key(|(id, _)| **id) { let Some(position) = snapshot.anchor_in_excerpt(action.range.start) else { continue; }; - - rows_with_any_lens.insert(MultiBufferRow(position.to_point(snapshot).row)); if let project::LspAction::CodeLens(lens) = &action.lsp_action { - if let Some(title) = lens + let title = lens .command .as_ref() - .map(|cmd| SharedString::from(&cmd.title)) - { - titled_lenses.push(( - position, - CodeLensItem { - title, - action: action.clone(), - }, - )); + .filter(|cmd| !cmd.title.is_empty()) + .map(|cmd| SharedString::from(&cmd.title)); + if title.is_none() && action.resolved { + continue; } + all_lenses.push(( + position, + CodeLensItem { + title, + action: action.clone(), + }, + )); } } - let mut new_lines_by_row = group_lenses_by_row(titled_lenses, snapshot) + let mut new_lines_by_row = group_lenses_by_row(all_lenses, snapshot) .map(|line| (MultiBufferRow(line.position.to_point(snapshot).row), line)) .collect::>(); @@ -314,15 +307,17 @@ impl Editor { for old in old_blocks { let row = MultiBufferRow(old.anchor.to_point(snapshot).row); - if !rows_with_any_lens.contains(&row) { + let Some(new_line) = new_lines_by_row.remove(&row) else { blocks_to_remove.insert(old.block_id); continue; - } + }; covered_rows.insert(row); - let Some(new_line) = new_lines_by_row.remove(&row) else { + let new_all_unresolved = new_line.items.iter().all(|item| item.title.is_none()); + let old_has_resolved = old.line.items.iter().any(|item| item.title.is_some()); + if new_all_unresolved && old_has_resolved { kept_blocks.push(old); continue; - }; + } if rendered_text_matches(&old.line, &new_line) { kept_blocks.push(old); } else { @@ -436,61 +431,72 @@ impl Editor { return; }; - let resolve_tasks = self - .visible_buffer_ranges(cx) - .into_iter() - .filter_map(|(snapshot, visible_range, _)| { - let buffer_id = snapshot.remote_id(); - let buffer = self.buffer.read(cx).buffer(buffer_id)?; - let visible_anchor_range = snapshot.anchor_before(visible_range.start) - ..snapshot.anchor_after(visible_range.end); - let task = project.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.resolve_visible_code_lenses(&buffer, visible_anchor_range, cx) - }) + let lsp_store = project.read(cx).lsp_store(); + + let mut pending_resolves = Vec::new(); + for (buffer_snapshot, visible_range, _) in self.visible_buffer_ranges(cx) { + let buffer_id = buffer_snapshot.remote_id(); + let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { + continue; + }; + let Some(actions) = self + .code_lens + .as_ref() + .and_then(|state| state.actions.get(&buffer_id)) + else { + continue; + }; + for (lens_id, action) in actions { + if action.resolved { + continue; + } + if let project::LspAction::CodeLens(lens) = &action.lsp_action { + if lens.command.is_some() { + continue; + } + } + let action_offset = action.range.start.to_offset(&buffer_snapshot); + if action_offset < visible_range.start.0 || action_offset > visible_range.end.0 { + continue; + } + let resolve_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.resolve_code_lens(&buffer, action.server_id, *lens_id, cx) }); - Some((buffer_id, task)) - }) - .collect::>(); - if resolve_tasks.is_empty() { + pending_resolves.push((buffer_id, resolve_task)); + } + } + if pending_resolves.is_empty() { return; } let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default); code_lens.resolve_task = cx.spawn(async move |editor, cx| { - let resolved_per_buffer = join_all( - resolve_tasks - .into_iter() - .map(|(buffer_id, task)| async move { (buffer_id, task.await) }), - ) - .await; - editor - .update(cx, |editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); - for (buffer_id, newly_resolved) in resolved_per_buffer { - if newly_resolved.is_empty() { - continue; - } + let mut resolves_in_progress = pending_resolves + .into_iter() + .map(|(buffer_id, task)| async move { (buffer_id, task.await) }) + .collect::>(); + while let Some((buffer_id, resolve_result)) = resolves_in_progress.next().await { + let Some((resolved_id, resolved)) = resolve_result else { + continue; + }; + editor + .update(cx, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); let Some(mut actions) = editor .code_lens .as_ref() .and_then(|state| state.actions.get(&buffer_id)) .cloned() else { - continue; + return; }; - for resolved in newly_resolved { - if let Some(unresolved) = actions.iter_mut().find(|action| { - action.server_id == resolved.server_id - && action.range == resolved.range - }) { - *unresolved = resolved; - } + if let Some(slot) = actions.get_mut(&resolved_id) { + *slot = resolved; } editor.apply_lens_actions_for_buffer(buffer_id, actions, &snapshot, cx); - } - }) - .ok(); + }) + .ok(); + } }); } @@ -551,12 +557,17 @@ fn group_lenses_by_row( fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> RenderBlock { Arc::new(move |cx| { - let mut children = Vec::with_capacity((2 * line.items.len()).saturating_sub(1)); + let resolved_items = line + .items + .iter() + .filter_map(|item| item.title.as_ref().map(|title| (title, &item.action))) + .collect::>(); + let mut children = Vec::with_capacity((2 * resolved_items.len()).saturating_sub(1)); let text_style = &cx.editor_style.text; let font = text_style.font(); let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9; - for (i, item) in line.items.iter().enumerate() { + for (i, (title, action)) in resolved_items.iter().enumerate() { if i > 0 { children.push( div() @@ -568,8 +579,8 @@ fn build_code_lens_renderer(line: CodeLensLine, editor: WeakEntity) -> R ); } - let title = item.title.clone(); - let action = item.action.clone(); + let title = (*title).clone(); + let action = (*action).clone(); let position = line.position; let editor_handle = editor.clone(); @@ -928,6 +939,322 @@ mod tests { } } + #[gpui::test] + async fn test_code_lens_placeholder_block_before_resolve(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + let mut lenses = Vec::new(); + lenses.push(lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_1"})), + }); + Ok(Some(lenses)) + }); + + let (resolve_tx, resolve_rx) = futures::channel::oneshot::channel::<()>(); + let resolve_rx = std::sync::Mutex::new(Some(resolve_rx)); + cx.lsp + .set_request_handler::(move |lens, _| { + let rx = resolve_rx.lock().unwrap().take(); + async move { + if let Some(rx) = rx { + rx.await.ok(); + } + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: "1 reference".to_owned(), + command: "resolved_cmd".to_owned(), + arguments: None, + }), + ..lens + }) + } + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received the initial code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.blocks.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 1, + "a placeholder block should be reserved before the resolve completes" + ); + }); + + resolve_tx.send(()).ok(); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.blocks.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 1, + "the placeholder block should still be present after resolution" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_block_removed_when_resolve_yields_empty_title(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + let mut lenses = Vec::new(); + lenses.push(lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_1"})), + }); + Ok(Some(lenses)) + }); + + cx.lsp + .set_request_handler::(|lens, _| async move { + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: String::new(), + command: "noop".to_owned(), + arguments: None, + }), + ..lens + }) + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received the initial code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.blocks.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 0, + "placeholder block should be cleaned up when its lens resolves to a blank title" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_same_range_lenses_resolve_independently(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + // Two shallow lenses on the same range, distinguished only by `data` + // — exactly the shape vtsls/TypeScript-LS uses for the + // "references" + "implementations" pair on the same line. + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"kind": "references"})), + }, + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"kind": "implementations"})), + }, + ])) + }); + + let resolve_calls = Arc::new(Mutex::new(Vec::::new())); + cx.lsp + .set_request_handler::({ + let resolve_calls = resolve_calls.clone(); + move |lens, _| { + let resolve_calls = resolve_calls.clone(); + async move { + let kind = lens + .data + .as_ref() + .and_then(|d| d.get("kind")) + .cloned() + .unwrap_or(serde_json::Value::Null); + resolve_calls.lock().unwrap().push(kind.clone()); + let title = match kind.as_str() { + Some("references") => "2 references", + Some("implementations") => "1 implementation", + _ => "", + }; + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: title.to_owned(), + command: "noop".to_owned(), + arguments: None, + }), + ..lens + }) + } + } + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received the initial code lens request" + ); + cx.run_until_parked(); + + let calls = resolve_calls.lock().unwrap().clone(); + assert_eq!( + calls.len(), + 2, + "both same-range lenses should be resolved independently, got {calls:?}" + ); + let kinds: Vec<&str> = calls.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(kinds.contains(&"references"), true); + assert_eq!(kinds.contains(&"implementations"), true); + + cx.editor.read_with(&cx.cx.cx, |editor, _| { + let blocks = editor + .code_lens + .as_ref() + .map(|s| s.blocks.values().flatten().collect::>()) + .unwrap_or_default(); + assert_eq!( + blocks.len(), + 1, + "a single block should host both lens items" + ); + let titles: Vec = blocks[0] + .line + .items + .iter() + .filter_map(|item| item.title.as_ref().map(|t| t.to_string())) + .collect(); + assert_eq!(titles.len(), 2, "both lens titles should be resolved"); + assert_eq!(titles.contains(&"2 references".to_string()), true); + assert_eq!(titles.contains(&"1 implementation".to_string()), true); + }); + } + + #[gpui::test] + async fn test_code_lens_block_removed_when_resolve_yields_no_command(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_1"})), + }])) + }); + + // Server acknowledges the resolve but still returns no `command` — + // a real-world scenario for buggy/incomplete servers. Without + // cleanup the placeholder line would be reserved forever because + // `resolve_visible_code_lenses` skips actions with `resolved=true`. + cx.lsp + .set_request_handler::(|lens, _| async move { + Ok(lsp::CodeLens { + command: None, + ..lens + }) + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received the initial code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.blocks.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 0, + "placeholder block should be cleaned up when resolve yields no command" + ); + }); + } + #[gpui::test] async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -1254,9 +1581,14 @@ mod tests { .unwrap() .drain(..) .collect::>(); + // Once the lenses are first applied we insert a placeholder block per + // lens row so the line is reserved while the resolve is in flight. + // Those placeholder blocks add display height, so after scrolling to + // the end the visible buffer-row range is slightly smaller than it + // would be without them, and lens row 60 is just outside it. assert_eq!( after_scroll_resolved, - HashSet::from_iter([60, 70, 80, 90]), + HashSet::from_iter([70, 80, 90]), "Only newly visible lenses at the bottom should be resolved, not middle ones" ); } diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs new file mode 100644 index 00000000000..2be7f28c5bf --- /dev/null +++ b/crates/editor/src/completions.rs @@ -0,0 +1,1489 @@ +use super::*; + +impl Editor { + pub fn set_completion_provider(&mut self, provider: Option>) { + self.completion_provider = provider; + } + + pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option) { + self.show_completions_on_input_override = show_completions_on_input; + } + + pub fn text_layout_details(&self, window: &mut Window, cx: &mut App) -> TextLayoutDetails { + TextLayoutDetails { + text_system: window.text_system().clone(), + editor_style: self.style.clone().unwrap_or_else(|| self.create_style(cx)), + rem_size: window.rem_size(), + scroll_anchor: self.scroll_manager.shared_scroll_anchor(cx), + visible_rows: self.visible_line_count(), + vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, + } + } + + pub fn show_word_completions( + &mut self, + _: &ShowWordCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: true, + }), + None, + false, + window, + cx, + ); + } + + pub fn show_completions( + &mut self, + _: &ShowCompletions, + window: &mut Window, + cx: &mut Context, + ) { + self.open_or_update_completions_menu(None, None, false, window, cx); + } + + pub fn confirm_completion( + &mut self, + action: &ConfirmCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) + } + + pub fn confirm_completion_insert( + &mut self, + _: &ConfirmCompletionInsert, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) + } + + pub fn confirm_completion_replace( + &mut self, + _: &ConfirmCompletionReplace, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if self.read_only(cx) { + return None; + } + self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) + } + + pub fn compose_completion( + &mut self, + action: &ComposeCompletion, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) + } + + pub fn has_visible_completions_menu(&self) -> bool { + !self.edit_prediction_preview_is_active() + && self.context_menu.borrow().as_ref().is_some_and(|menu| { + menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) + }) + } + + pub(super) fn trigger_completion_on_input( + &mut self, + text: &str, + trigger_in_words: bool, + window: &mut Window, + cx: &mut Context, + ) { + let completions_source = self + .context_menu + .borrow() + .as_ref() + .and_then(|menu| match menu { + CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), + CodeContextMenu::CodeActions(_) => None, + }); + + match completions_source { + Some(CompletionsMenuSource::Words { .. }) => { + self.open_or_update_completions_menu( + Some(CompletionsMenuSource::Words { + ignore_threshold: false, + }), + None, + trigger_in_words, + window, + cx, + ); + } + _ => self.open_or_update_completions_menu( + None, + Some(text.to_owned()).filter(|x| !x.is_empty()), + trigger_in_words, + window, + cx, + ), + } + } + + pub(super) fn is_lsp_relevant(&self, file: Option<&Arc>, cx: &App) -> bool { + let Some(project) = self.project() else { + return false; + }; + let Some(buffer_file) = project::File::from_dyn(file) else { + return false; + }; + let Some(entry_id) = buffer_file.project_entry_id() else { + return false; + }; + let project = project.read(cx); + let Some(buffer_worktree) = project.worktree_for_id(buffer_file.worktree_id(cx), cx) else { + return false; + }; + let Some(worktree_entry) = buffer_worktree.read(cx).entry_for_id(entry_id) else { + return false; + }; + !worktree_entry.is_ignored + } + + pub(super) fn visible_buffers(&self, cx: &mut Context) -> Vec> { + let display_snapshot = self.display_snapshot(cx); + let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); + let multi_buffer = self.buffer().read(cx); + display_snapshot + .buffer_snapshot() + .range_to_buffer_ranges(visible_range) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) + .filter_map(|(buffer_snapshot, _, _)| multi_buffer.buffer(buffer_snapshot.remote_id())) + .collect() + } + + pub(super) fn visible_buffer_ranges( + &self, + cx: &mut Context, + ) -> Vec<( + BufferSnapshot, + Range, + ExcerptRange, + )> { + let display_snapshot = self.display_snapshot(cx); + let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); + display_snapshot + .buffer_snapshot() + .range_to_buffer_ranges(visible_range) + .into_iter() + .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) + .collect() + } + + pub(super) fn trigger_on_type_formatting( + &self, + input: String, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + if input.chars().count() != 1 { + return None; + } + + let project = self.project()?; + let position = self.selections.newest_anchor().head(); + let (buffer, buffer_position) = self + .buffer + .read(cx) + .text_anchor_for_position(position, cx)?; + + let settings = LanguageSettings::for_buffer_at(&buffer.read(cx), buffer_position, cx); + if !settings.use_on_type_format { + return None; + } + + // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, + // hence we do LSP request & edit on host side only — add formats to host's history. + let push_to_lsp_host_history = true; + // If this is not the host, append its history with new edits. + let push_to_client_history = project.read(cx).is_via_collab(); + + let on_type_formatting = project.update(cx, |project, cx| { + project.on_type_format( + buffer.clone(), + buffer_position, + input, + push_to_lsp_host_history, + cx, + ) + }); + Some(cx.spawn_in(window, async move |editor, cx| { + if let Some(transaction) = on_type_formatting.await? { + if push_to_client_history { + buffer.update(cx, |buffer, _| { + buffer.push_transaction(transaction, Instant::now()); + buffer.finalize_last_transaction(); + }); + } + editor.update(cx, |editor, cx| { + editor.refresh_document_highlights(cx); + })?; + } + Ok(()) + })) + } + + pub(super) fn open_or_update_completions_menu( + &mut self, + requested_source: Option, + trigger: Option, + trigger_in_words: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.pending_rename.is_some() { + return; + } + + let completions_source = self + .context_menu + .borrow() + .as_ref() + .and_then(|menu| match menu { + CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), + CodeContextMenu::CodeActions(_) => None, + }); + + let multibuffer_snapshot = self.buffer.read(cx).read(cx); + + // Typically `start` == `end`, but with snippet tabstop choices the default choice is + // inserted and selected. To handle that case, the start of the selection is used so that + // the menu starts with all choices. + let position = self + .selections + .newest_anchor() + .start + .bias_right(&multibuffer_snapshot); + + if position.diff_base_anchor().is_some() { + return; + } + let multibuffer_position = multibuffer_snapshot.anchor_before(position); + let Some((buffer_position, _)) = + multibuffer_snapshot.anchor_to_buffer_anchor(multibuffer_position) + else { + return; + }; + let Some(buffer) = self.buffer.read(cx).buffer(buffer_position.buffer_id) else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let menu_is_open = matches!( + self.context_menu.borrow().as_ref(), + Some(CodeContextMenu::Completions(_)) + ); + + let language = buffer_snapshot + .language_at(buffer_position) + .map(|language| language.name()); + let language_settings = multibuffer_snapshot.language_settings_at(multibuffer_position, cx); + let completion_settings = language_settings.completions.clone(); + + let show_completions_on_input = self + .show_completions_on_input_override + .unwrap_or(language_settings.show_completions_on_input); + if !menu_is_open && trigger.is_some() && !show_completions_on_input { + return; + } + + let query: Option> = + Self::completion_query(&multibuffer_snapshot, multibuffer_position) + .map(|query| query.into()); + + drop(multibuffer_snapshot); + + // Hide the current completions menu when query is empty. Without this, cached + // completions from before the trigger char may be reused (#32774). + if query.is_none() && menu_is_open { + self.hide_context_menu(window, cx); + } + + let mut ignore_word_threshold = false; + let provider = match requested_source { + Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(), + Some(CompletionsMenuSource::Words { ignore_threshold }) => { + ignore_word_threshold = ignore_threshold; + None + } + Some(CompletionsMenuSource::SnippetChoices) + | Some(CompletionsMenuSource::SnippetsOnly) => { + log::error!("bug: SnippetChoices requested_source is not handled"); + None + } + }; + + let sort_completions = provider + .as_ref() + .is_some_and(|provider| provider.sort_completions()); + + let filter_completions = provider + .as_ref() + .is_none_or(|provider| provider.filter_completions()); + + let was_snippets_only = matches!( + completions_source, + Some(CompletionsMenuSource::SnippetsOnly) + ); + + if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { + if filter_completions { + menu.filter( + query.clone().unwrap_or_default(), + buffer_position, + &buffer, + provider.clone(), + window, + cx, + ); + } + // When `is_incomplete` is false, no need to re-query completions when the current query + // is a suffix of the initial query. + let was_complete = !menu.is_incomplete; + if was_complete && !was_snippets_only { + // If the new query is a suffix of the old query (typing more characters) and + // the previous result was complete, the existing completions can be filtered. + // + // Note that snippet completions are always complete. + let query_matches = match (&menu.initial_query, &query) { + (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), + (None, _) => true, + _ => false, + }; + if query_matches { + let position_matches = if menu.initial_position == position { + true + } else { + let snapshot = self.buffer.read(cx).read(cx); + menu.initial_position.to_offset(&snapshot) == position.to_offset(&snapshot) + }; + if position_matches { + return; + } + } + } + }; + + let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) = + buffer_snapshot.surrounding_word(buffer_position, None) + { + let word_to_exclude = buffer_snapshot + .text_for_range(word_range.clone()) + .collect::(); + ( + buffer_snapshot.anchor_before(word_range.start) + ..buffer_snapshot.anchor_after(buffer_position), + Some(word_to_exclude), + ) + } else { + (buffer_position..buffer_position, None) + }; + + let show_completion_documentation = buffer_snapshot + .settings_at(buffer_position, cx) + .show_completion_documentation; + + // The document can be large, so stay in reasonable bounds when searching for words, + // otherwise completion pop-up might be slow to appear. + const WORD_LOOKUP_ROWS: u32 = 5_000; + let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; + let min_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), + Bias::Left, + ); + let max_word_search = buffer_snapshot.clip_point( + Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), + Bias::Right, + ); + let word_search_range = buffer_snapshot.point_to_offset(min_word_search) + ..buffer_snapshot.point_to_offset(max_word_search); + + let skip_digits = query + .as_ref() + .is_none_or(|query| !query.chars().any(|c| c.is_digit(10))); + + let load_provider_completions = provider.as_ref().is_some_and(|provider| { + trigger.as_ref().is_none_or(|trigger| { + provider.is_completion_trigger( + &buffer, + buffer_position, + trigger, + trigger_in_words, + cx, + ) + }) + }); + + let provider_responses = if let Some(provider) = &provider + && load_provider_completions + { + let trigger_character = trigger + .as_ref() + .filter(|trigger| { + buffer + .read(cx) + .completion_triggers() + .contains(trigger.as_str()) + }) + .cloned(); + let completion_context = CompletionContext { + trigger_kind: match &trigger_character { + Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, + None => CompletionTriggerKind::INVOKED, + }, + trigger_character, + }; + + provider.completions(&buffer, buffer_position, completion_context, window, cx) + } else { + Task::ready(Ok(Vec::new())) + }; + + let load_word_completions = if !self.word_completions_enabled { + false + } else if requested_source + == Some(CompletionsMenuSource::Words { + ignore_threshold: true, + }) + { + true + } else { + load_provider_completions + && completion_settings.words != WordsCompletionMode::Disabled + && (ignore_word_threshold || { + let words_min_length = completion_settings.words_min_length; + // check whether word has at least `words_min_length` characters + let query_chars = query.iter().flat_map(|q| q.chars()); + query_chars.take(words_min_length).count() == words_min_length + }) + }; + + let mut words = if load_word_completions { + cx.background_spawn({ + let buffer_snapshot = buffer_snapshot.clone(); + async move { + buffer_snapshot.words_in_range(WordsQuery { + fuzzy_contents: None, + range: word_search_range, + skip_digits, + }) + } + }) + } else { + Task::ready(BTreeMap::default()) + }; + + let snippet_char_classifier = buffer_snapshot + .char_classifier_at(buffer_position) + .scope_context(Some(CharScopeContext::Completion)); + + let snippets = if let Some(provider) = &provider + && provider.show_snippets() + && let Some(project) = self.project() + { + let word_trigger = trigger.as_ref().is_some_and(|trigger| { + !trigger.is_empty() + && trigger + .chars() + .all(|character| snippet_char_classifier.is_word(character)) + }); + let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; + let load_snippet_completions = !requires_strong_snippet_match + || query.as_ref().is_some_and(|query| { + let project = project.read(cx); + has_strong_snippet_prefix_match( + &project, + &buffer, + buffer_position, + &snippet_char_classifier, + query, + cx, + ) + }); + + if load_snippet_completions { + project.update(cx, |project, cx| { + snippet_completions( + project, + &buffer, + buffer_position, + snippet_char_classifier, + cx, + ) + }) + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + } + } else { + Task::ready(Ok(CompletionResponse { + completions: Vec::new(), + display_options: Default::default(), + is_incomplete: false, + })) + }; + + let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; + + let id = post_inc(&mut self.next_completion_id); + let task = cx.spawn_in(window, async move |editor, cx| { + let Ok(()) = editor.update(cx, |this, _| { + this.completion_tasks.retain(|(task_id, _)| *task_id >= id); + }) else { + return; + }; + + // TODO: Ideally completions from different sources would be selectively re-queried, so + // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. + let mut completions = Vec::new(); + let mut is_incomplete = false; + let mut display_options: Option = None; + if let Some(provider_responses) = provider_responses.await.log_err() + && !provider_responses.is_empty() + { + for response in provider_responses { + completions.extend(response.completions); + is_incomplete = is_incomplete || response.is_incomplete; + match display_options.as_mut() { + None => { + display_options = Some(response.display_options); + } + Some(options) => options.merge(&response.display_options), + } + } + if completion_settings.words == WordsCompletionMode::Fallback { + words = Task::ready(BTreeMap::default()); + } + } + let display_options = display_options.unwrap_or_default(); + + let mut words = words.await; + if let Some(word_to_exclude) = &word_to_exclude { + words.remove(word_to_exclude); + } + for lsp_completion in &completions { + words.remove(&lsp_completion.new_text); + } + completions.extend(words.into_iter().map(|(word, word_range)| Completion { + replace_range: word_replace_range.clone(), + new_text: word.clone(), + label: CodeLabel::plain(word, None), + match_start: None, + snippet_deduplication_key: None, + icon_path: None, + documentation: None, + source: CompletionSource::BufferWord { + word_range, + resolved: false, + }, + insert_text_mode: Some(InsertTextMode::AS_IS), + confirm: None, + })); + + completions.extend( + snippets + .await + .into_iter() + .flat_map(|response| response.completions), + ); + + let menu = if completions.is_empty() { + None + } else { + let Ok((mut menu, matches_task)) = editor.update(cx, |editor, cx| { + let languages = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .map(|workspace| workspace.read(cx).app_state().languages.clone()); + let menu = CompletionsMenu::new( + id, + requested_source.unwrap_or(if load_provider_completions { + CompletionsMenuSource::Normal + } else { + CompletionsMenuSource::SnippetsOnly + }), + sort_completions, + show_completion_documentation, + position, + query.clone(), + is_incomplete, + buffer.clone(), + completions.into(), + editor + .context_menu() + .borrow_mut() + .as_ref() + .map(|menu| menu.primary_scroll_handle()), + display_options, + snippet_sort_order, + languages, + language, + cx, + ); + + let query = if filter_completions { query } else { None }; + let matches_task = menu.do_async_filtering( + query.unwrap_or_default(), + buffer_position, + &buffer, + cx, + ); + (menu, matches_task) + }) else { + return; + }; + + let matches = matches_task.await; + + let Ok(()) = editor.update_in(cx, |editor, window, cx| { + // Newer menu already set, so exit. + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow().as_ref() + && prev_menu.id > id + { + return; + }; + + // Only valid to take prev_menu because either the new menu is immediately set + // below, or the menu is hidden. + if let Some(CodeContextMenu::Completions(prev_menu)) = + editor.context_menu.borrow_mut().take() + { + let position_matches = + if prev_menu.initial_position == menu.initial_position { + true + } else { + let snapshot = editor.buffer.read(cx).read(cx); + prev_menu.initial_position.to_offset(&snapshot) + == menu.initial_position.to_offset(&snapshot) + }; + if position_matches { + // Preserve markdown cache before `set_filter_results` because it will + // try to populate the documentation cache. + menu.preserve_markdown_cache(prev_menu); + } + }; + + menu.set_filter_results(matches, provider, window, cx); + }) else { + return; + }; + + menu.visible().then_some(menu) + }; + + editor + .update_in(cx, |editor, window, cx| { + if editor.focus_handle.is_focused(window) + && let Some(menu) = menu + { + *editor.context_menu.borrow_mut() = + Some(CodeContextMenu::Completions(menu)); + + crate::hover_popover::hide_hover(editor, cx); + if editor.show_edit_predictions_in_menu() { + editor.update_visible_edit_prediction(window, cx); + } else { + editor + .discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + } + + cx.notify(); + return; + } + + if editor.completion_tasks.len() <= 1 { + // If there are no more completion tasks and the last menu was empty, we should hide it. + let was_hidden = editor.hide_context_menu(window, cx).is_none(); + // If it was already hidden and we don't show edit predictions in the menu, + // we should also show the edit prediction when available. + if was_hidden && editor.show_edit_predictions_in_menu() { + editor.update_visible_edit_prediction(window, cx); + } + } + }) + .ok(); + }); + + self.completion_tasks.push((id, task)); + } + + pub(super) fn with_completions_menu_matching_id( + &self, + id: CompletionId, + f: impl FnOnce(Option<&mut CompletionsMenu>) -> R, + ) -> R { + let mut context_menu = self.context_menu.borrow_mut(); + let Some(CodeContextMenu::Completions(completions_menu)) = &mut *context_menu else { + return f(None); + }; + if completions_menu.id != id { + return f(None); + } + f(Some(completions_menu)) + } + + fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { + let offset = position.to_offset(buffer); + let (word_range, kind) = + buffer.surrounding_word(offset, Some(CharScopeContext::Completion)); + if offset > word_range.start && kind == Some(CharKind::Word) { + Some( + buffer + .text_for_range(word_range.start..offset) + .collect::(), + ) + } else { + None + } + } + + fn do_completion( + &mut self, + item_ix: Option, + intent: CompletionIntent, + window: &mut Window, + cx: &mut Context, + ) -> Option>> { + use language::ToOffset as _; + + let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? + else { + return None; + }; + + let candidate_id = { + let entries = completions_menu.entries.borrow(); + let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; + if self.show_edit_predictions_in_menu() { + self.discard_edit_prediction(EditPredictionDiscardReason::Rejected, cx); + } + mat.candidate_id + }; + + let completion = completions_menu + .completions + .borrow() + .get(candidate_id)? + .clone(); + cx.stop_propagation(); + + let buffer_handle = completions_menu.buffer.clone(); + let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); + let (initial_position, _) = + multibuffer_snapshot.anchor_to_buffer_anchor(completions_menu.initial_position)?; + + let CompletionEdit { + new_text, + snippet, + replace_range, + } = process_completion_for_edit(&completion, intent, &buffer_handle, &initial_position, cx); + + let buffer = buffer_handle.read(cx).snapshot(); + let newest_selection = self.selections.newest_anchor(); + + let Some(replace_range_multibuffer) = + multibuffer_snapshot.buffer_anchor_range_to_anchor_range(replace_range.clone()) + else { + return None; + }; + + let Some((buffer_snapshot, newest_range_buffer)) = + multibuffer_snapshot.anchor_range_to_buffer_anchor_range(newest_selection.range()) + else { + return None; + }; + + let old_text = buffer + .text_for_range(replace_range.clone()) + .collect::(); + let lookbehind = newest_range_buffer + .start + .to_offset(buffer_snapshot) + .saturating_sub(replace_range.start.to_offset(&buffer_snapshot)); + let lookahead = replace_range + .end + .to_offset(&buffer_snapshot) + .saturating_sub(newest_range_buffer.end.to_offset(&buffer)); + let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; + let suffix = &old_text[lookbehind.min(old_text.len())..]; + + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let mut ranges = Vec::new(); + let mut all_commit_ranges = Vec::new(); + let mut linked_edits = LinkedEdits::new(); + + let text: Arc = new_text.clone().into(); + for selection in &selections { + let range = if selection.id == newest_selection.id { + replace_range_multibuffer.clone() + } else { + let mut range = selection.range(); + + // if prefix is present, don't duplicate it + if multibuffer_snapshot + .contains_str_at(range.start.saturating_sub_usize(lookbehind), prefix) + { + range.start = range.start.saturating_sub_usize(lookbehind); + + // if suffix is also present, mimic the newest cursor and replace it + if selection.id != newest_selection.id + && multibuffer_snapshot.contains_str_at(range.end, suffix) + { + range.end += lookahead; + } + } + range.to_anchors(&multibuffer_snapshot) + }; + + ranges.push(range.clone()); + + let start_anchor = multibuffer_snapshot.anchor_before(range.start); + let end_anchor = multibuffer_snapshot.anchor_after(range.end); + + if let Some((buffer_snapshot_2, anchor_range)) = + multibuffer_snapshot.anchor_range_to_buffer_anchor_range(start_anchor..end_anchor) + && buffer_snapshot_2.remote_id() == buffer_snapshot.remote_id() + { + all_commit_ranges.push(anchor_range.clone()); + if !self.linked_edit_ranges.is_empty() { + linked_edits.push(&self, anchor_range, text.clone(), cx); + } + } + } + + let common_prefix_len = old_text + .chars() + .zip(new_text.chars()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum::(); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: new_text[common_prefix_len..].into(), + }); + + let tx_id = self.transact(window, cx, |editor, window, cx| { + if let Some(mut snippet) = snippet { + snippet.text = new_text.to_string(); + let offset_ranges = ranges + .iter() + .map(|range| range.to_offset(&multibuffer_snapshot)) + .collect::>(); + editor + .insert_snippet(&offset_ranges, snippet, window, cx) + .log_err(); + } else { + editor.buffer.update(cx, |multi_buffer, cx| { + let auto_indent = match completion.insert_text_mode { + Some(InsertTextMode::AS_IS) => None, + _ => editor.autoindent_mode.clone(), + }; + let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); + multi_buffer.edit(edits, auto_indent, cx); + }); + } + linked_edits.apply(cx); + editor.refresh_edit_prediction(true, false, window, cx); + }); + self.invalidate_autoclose_regions( + &self.selections.disjoint_anchors_arc(), + &multibuffer_snapshot, + ); + + let show_new_completions_on_confirm = completion + .confirm + .as_ref() + .is_some_and(|confirm| confirm(intent, window, cx)); + if show_new_completions_on_confirm { + self.open_or_update_completions_menu(None, None, false, window, cx); + } + + let provider = self.completion_provider.as_ref()?; + + let lsp_store = self.project().map(|project| project.read(cx).lsp_store()); + let command = lsp_store.as_ref().and_then(|lsp_store| { + let CompletionSource::Lsp { + lsp_completion, + server_id, + .. + } = &completion.source + else { + return None; + }; + let lsp_command = lsp_completion.command.as_ref()?; + let available_commands = lsp_store + .read(cx) + .lsp_server_capabilities + .get(server_id) + .and_then(|server_capabilities| { + server_capabilities + .execute_command_provider + .as_ref() + .map(|options| options.commands.as_slice()) + })?; + if available_commands.contains(&lsp_command.command) { + Some(CodeAction { + server_id: *server_id, + range: language::Anchor::min_min_range_for_buffer(buffer.remote_id()), + lsp_action: LspAction::Command(lsp_command.clone()), + resolved: false, + }) + } else { + None + } + }); + + drop(completion); + let apply_edits = provider.apply_additional_edits_for_completion( + buffer_handle.clone(), + completions_menu.completions.clone(), + candidate_id, + true, + all_commit_ranges, + cx, + ); + + let editor_settings = EditorSettings::get_global(cx); + if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { + // After the code completion is finished, users often want to know what signatures are needed. + // so we should automatically call signature_help + self.show_signature_help(&ShowSignatureHelp, window, cx); + } + + Some(cx.spawn_in(window, async move |editor, cx| { + let additional_edits_tx = apply_edits.await?; + + if let Some((lsp_store, command)) = lsp_store.zip(command) { + let title = command.lsp_action.title().to_owned(); + let project_transaction = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.apply_code_action(buffer_handle, command, false, cx) + }) + .await + .context("applying post-completion command")?; + if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? { + Self::open_project_transaction( + &editor, + workspace.downgrade(), + project_transaction, + title, + cx, + ) + .await?; + } + } + + if let Some(tx_id) = tx_id + && let Some(additional_edits_tx) = additional_edits_tx + { + editor + .update(cx, |editor, cx| { + editor.buffer.update(cx, |buffer, cx| { + buffer.merge_transactions(additional_edits_tx.id, tx_id, cx) + }); + }) + .context("merge transactions")?; + } + + Ok(()) + })) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl Editor { + pub fn completion_provider(&self) -> Option> { + self.completion_provider.clone() + } + + pub fn current_completions(&self) -> Option> { + let menu = self.context_menu.borrow(); + if let CodeContextMenu::Completions(menu) = menu.as_ref()? { + let completions = menu.completions.borrow(); + Some(completions.to_vec()) + } else { + None + } + } + + #[cfg(test)] + pub(super) fn disable_word_completions(&mut self) { + self.word_completions_enabled = false; + } +} + +pub trait CompletionProvider { + fn completions( + &self, + buffer: &Entity, + buffer_position: text::Anchor, + trigger: CompletionContext, + window: &mut Window, + cx: &mut Context, + ) -> Task>>; + + fn resolve_completions( + &self, + _buffer: Entity, + _completion_indices: Vec, + _completions: Rc>>, + _cx: &mut Context, + ) -> Task> { + Task::ready(Ok(false)) + } + + fn apply_additional_edits_for_completion( + &self, + _buffer: Entity, + _completions: Rc>>, + _completion_index: usize, + _push_to_history: bool, + _all_commit_ranges: Vec>, + _cx: &mut Context, + ) -> Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool; + + fn selection_changed(&self, _mat: Option<&StringMatch>, _window: &mut Window, _cx: &mut App) {} + + fn sort_completions(&self) -> bool { + true + } + + fn filter_completions(&self) -> bool { + true + } + + fn show_snippets(&self) -> bool { + false + } +} + +fn has_strong_snippet_prefix_match( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: &CharClassifier, + query: &str, + cx: &App, +) -> bool { + if query.chars().take(2).count() < 2 { + return false; + } + + let query = query.to_lowercase(); + let is_word_char = |character| classifier.is_word(character); + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + languages.iter().any(|language| { + snippet_store + .snippets_for(Some(language.lsp_id()), cx) + .iter() + .flat_map(|snippet| snippet.prefix.iter()) + .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) + .any(|candidate| candidate.to_lowercase().starts_with(&query)) + }) +} + +fn snippet_completions( + project: &Project, + buffer: &Entity, + buffer_anchor: text::Anchor, + classifier: CharClassifier, + cx: &mut App, +) -> Task> { + let languages = buffer.read(cx).languages_at(buffer_anchor); + let snippet_store = project.snippets().read(cx); + + let scopes: Vec<_> = languages + .iter() + .filter_map(|language| { + let language_name = language.lsp_id(); + let snippets = snippet_store.snippets_for(Some(language_name), cx); + + if snippets.is_empty() { + None + } else { + Some((language.default_scope(), snippets)) + } + }) + .collect(); + + if scopes.is_empty() { + return Task::ready(Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: false, + })); + } + + let snapshot = buffer.read(cx).text_snapshot(); + let executor = cx.background_executor().clone(); + + cx.background_spawn(async move { + let is_word_char = |c| classifier.is_word(c); + + let mut is_incomplete = false; + let mut completions: Vec = Vec::new(); + + const MAX_PREFIX_LEN: usize = 128; + let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); + let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); + let window_start = snapshot.clip_offset(window_start, Bias::Left); + + let max_buffer_window: String = snapshot + .text_for_range(window_start..buffer_offset) + .collect(); + + if max_buffer_window.is_empty() { + return Ok(CompletionResponse { + completions: vec![], + display_options: CompletionDisplayOptions::default(), + is_incomplete: true, + }); + } + + for (_scope, snippets) in scopes.into_iter() { + // Sort snippets by word count to match longer snippet prefixes first. + let mut sorted_snippet_candidates = snippets + .iter() + .enumerate() + .flat_map(|(snippet_ix, snippet)| { + snippet + .prefix + .iter() + .enumerate() + .map(move |(prefix_ix, prefix)| { + let word_count = + snippet_candidate_suffixes(prefix, &is_word_char).count(); + ((snippet_ix, prefix_ix), prefix, word_count) + }) + }) + .collect_vec(); + sorted_snippet_candidates + .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); + + // Each prefix may be matched multiple times; the completion menu must filter out duplicates. + + let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, &is_word_char) + .take( + sorted_snippet_candidates + .first() + .map(|(_, _, word_count)| *word_count) + .unwrap_or_default(), + ) + .collect_vec(); + + const MAX_RESULTS: usize = 100; + // Each match also remembers how many characters from the buffer it consumed + let mut matches: Vec<(StringMatch, usize)> = vec![]; + + let mut snippet_list_cutoff_index = 0; + for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { + let word_count = buffer_index + 1; + // Increase `snippet_list_cutoff_index` until we have all of the + // snippets with sufficiently many words. + while sorted_snippet_candidates + .get(snippet_list_cutoff_index) + .is_some_and(|(_ix, _prefix, snippet_word_count)| { + *snippet_word_count >= word_count + }) + { + snippet_list_cutoff_index += 1; + } + + // Take only the candidates with at least `word_count` many words + let snippet_candidates_at_word_len = + &sorted_snippet_candidates[..snippet_list_cutoff_index]; + + let candidates = snippet_candidates_at_word_len + .iter() + .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) + .enumerate() // index in `sorted_snippet_candidates` + // First char must match + .filter(|(_ix, prefix)| { + itertools::equal( + prefix + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + buffer_window + .chars() + .next() + .into_iter() + .flat_map(|c| c.to_lowercase()), + ) + }) + .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) + .collect::>(); + + matches.extend( + fuzzy::match_strings( + &candidates, + &buffer_window, + buffer_window.chars().any(|c| c.is_uppercase()), + true, + MAX_RESULTS - matches.len(), // always prioritize longer snippets + &Default::default(), + executor.clone(), + ) + .await + .into_iter() + .map(|string_match| (string_match, buffer_window.len())), + ); + + if matches.len() >= MAX_RESULTS { + break; + } + } + + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_anchor); + + if matches.len() >= MAX_RESULTS { + is_incomplete = true; + } + + completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { + let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = + sorted_snippet_candidates[string_match.candidate_id]; + let snippet = &snippets[snippet_index]; + let start = buffer_offset - buffer_window_len; + let start = snapshot.anchor_before(start); + let range = start..buffer_anchor; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Completion { + replace_range: range, + new_text: snippet.body.clone(), + source: CompletionSource::Lsp { + insert_range: None, + server_id: LanguageServerId(usize::MAX), + resolved: true, + lsp_completion: Box::new(lsp::CompletionItem { + label: matching_prefix.clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..lsp::CompletionItem::default() + }), + lsp_defaults: None, + }, + label: CodeLabel { + text: matching_prefix.clone(), + runs: Vec::new(), + filter_range: 0..matching_prefix.len(), + }, + icon_path: None, + documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { + single_line: snippet.name.clone().into(), + plain_text: snippet + .description + .clone() + .map(|description| description.into()), + }), + insert_text_mode: None, + confirm: None, + match_start: Some(start), + snippet_deduplication_key: Some((snippet_index, prefix_index)), + } + })); + } + + Ok(CompletionResponse { + completions, + display_options: CompletionDisplayOptions::default(), + is_incomplete, + }) + }) +} + +impl CompletionProvider for Entity { + fn completions( + &self, + buffer: &Entity, + buffer_position: text::Anchor, + options: CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>> { + self.update(cx, |project, cx| { + let task = project.completions(buffer, buffer_position, options, cx); + cx.background_spawn(task) + }) + } + + fn resolve_completions( + &self, + buffer: Entity, + completion_indices: Vec, + completions: Rc>>, + cx: &mut Context, + ) -> Task> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_completions(buffer, completion_indices, completions, cx) + }) + }) + } + + fn apply_additional_edits_for_completion( + &self, + buffer: Entity, + completions: Rc>>, + completion_index: usize, + push_to_history: bool, + all_commit_ranges: Vec>, + cx: &mut Context, + ) -> Task>> { + self.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.apply_additional_edits_for_completion( + buffer, + completions, + completion_index, + push_to_history, + all_commit_ranges, + cx, + ) + }) + }) + } + + fn is_completion_trigger( + &self, + buffer: &Entity, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut Context, + ) -> bool { + let mut chars = text.chars(); + let char = if let Some(char) = chars.next() { + char + } else { + return false; + }; + if chars.next().is_some() { + return false; + } + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + let classifier = snapshot + .char_classifier_at(position) + .scope_context(Some(CharScopeContext::Completion)); + if trigger_in_words && classifier.is_word(char) { + return true; + } + + buffer.completion_triggers().contains(text) + } + + fn show_snippets(&self) -> bool { + true + } +} + +pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { + let mut prev_index = 0; + let mut prev_codepoint: Option = None; + text.char_indices() + .chain([(text.len(), '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_codepoint = prev_codepoint.replace(codepoint)?; + let is_boundary = index == text.len() + || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() + || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); + if is_boundary { + let chunk = &text[prev_index..index]; + prev_index = index; + Some(chunk) + } else { + None + } + }) +} + +/// Given a string of text immediately before the cursor, iterates over possible +/// strings a snippet could match to. More precisely: returns an iterator over +/// suffixes of `text` created by splitting at word boundaries (before & after +/// every non-word character). +/// +/// Shorter suffixes are returned first. +pub(crate) fn snippet_candidate_suffixes<'a>( + text: &'a str, + is_word_char: &'a dyn Fn(char) -> bool, +) -> impl std::iter::Iterator + 'a { + let mut prev_index = text.len(); + let mut prev_codepoint = None; + text.char_indices() + .rev() + .chain([(0, '\0')]) + .filter_map(move |(index, codepoint)| { + let prev_index = std::mem::replace(&mut prev_index, index); + let prev_codepoint = prev_codepoint.replace(codepoint)?; + if is_word_char(prev_codepoint) && is_word_char(codepoint) { + None + } else { + let chunk = &text[prev_index..]; // go to end of string + Some(chunk) + } + }) +} diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs new file mode 100644 index 00000000000..02256fe87df --- /dev/null +++ b/crates/editor/src/config.rs @@ -0,0 +1,352 @@ +use super::*; + +impl Editor { + pub fn style(&mut self, cx: &App) -> &EditorStyle { + match self.style { + Some(ref style) => style, + None => { + let style = self.create_style(cx); + self.style.insert(style) + } + } + } + + pub fn set_soft_wrap_mode( + &mut self, + mode: language_settings::SoftWrap, + cx: &mut Context, + ) { + self.soft_wrap_mode_override = Some(mode); + cx.notify(); + } + + pub fn set_hard_wrap(&mut self, hard_wrap: Option, cx: &mut Context) { + self.hard_wrap = hard_wrap; + cx.notify(); + } + + pub fn set_text_style_refinement(&mut self, style: TextStyleRefinement) { + self.text_style_refinement = Some(style); + } + + /// called by the Element so we know what style we were most recently rendered with. + pub fn set_style(&mut self, style: EditorStyle, window: &mut Window, cx: &mut Context) { + // We intentionally do not inform the display map about the minimap style + // so that wrapping is not recalculated and stays consistent for the editor + // and its linked minimap. + if !self.mode.is_minimap() { + let font = style.text.font(); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let display_map = self + .placeholder_display_map + .as_ref() + .filter(|_| self.is_empty(cx)) + .unwrap_or(&self.display_map); + + display_map.update(cx, |map, cx| map.set_font(font, font_size, cx)); + } + self.style = Some(style); + } + + pub fn set_soft_wrap(&mut self) { + self.soft_wrap_mode_override = Some(language_settings::SoftWrap::EditorWidth) + } + + pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut Context) { + self.show_wrap_guides = Some(show_wrap_guides); + cx.notify(); + } + + pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut Context) { + self.show_indent_guides = Some(show_indent_guides); + cx.notify(); + } + + pub fn disable_indent_guides_for_buffer( + &mut self, + buffer_id: BufferId, + cx: &mut Context, + ) { + self.buffers_with_disabled_indent_guides.insert(buffer_id); + cx.notify(); + } + + pub fn toggle_line_numbers( + &mut self, + _: &ToggleLineNumbers, + _: &mut Window, + cx: &mut Context, + ) { + let mut editor_settings = EditorSettings::get_global(cx).clone(); + editor_settings.gutter.line_numbers = !editor_settings.gutter.line_numbers; + EditorSettings::override_global(editor_settings, cx); + } + + pub fn line_numbers_enabled(&self, cx: &App) -> bool { + if let Some(show_line_numbers) = self.show_line_numbers { + return show_line_numbers; + } + EditorSettings::get_global(cx).gutter.line_numbers + } + + pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers { + match ( + self.use_relative_line_numbers, + EditorSettings::get_global(cx).relative_line_numbers, + ) { + (None, setting) => setting, + (Some(false), _) => RelativeLineNumbers::Disabled, + (Some(true), RelativeLineNumbers::Wrapped) => RelativeLineNumbers::Wrapped, + (Some(true), _) => RelativeLineNumbers::Enabled, + } + } + + pub fn set_relative_line_number(&mut self, is_relative: Option, cx: &mut Context) { + self.use_relative_line_numbers = is_relative; + cx.notify(); + } + + pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut Context) { + self.show_gutter = show_gutter; + cx.notify(); + } + + pub fn set_show_vertical_scrollbar(&mut self, show: bool, cx: &mut Context) { + self.show_scrollbars.vertical = show; + cx.notify(); + } + + pub fn set_show_horizontal_scrollbar(&mut self, show: bool, cx: &mut Context) { + self.show_scrollbars.horizontal = show; + cx.notify(); + } + + pub fn set_minimap_visibility( + &mut self, + minimap_visibility: MinimapVisibility, + window: &mut Window, + cx: &mut Context, + ) { + if self.minimap_visibility != minimap_visibility { + if minimap_visibility.visible() && self.minimap.is_none() { + let minimap_settings = EditorSettings::get_global(cx).minimap; + self.minimap = + self.create_minimap(minimap_settings.with_show_override(), window, cx); + } + self.minimap_visibility = minimap_visibility; + cx.notify(); + } + } + + pub fn disable_scrollbars_and_minimap(&mut self, window: &mut Window, cx: &mut Context) { + self.set_show_scrollbars(false, cx); + self.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); + } + + pub fn hide_minimap_by_default(&mut self, window: &mut Window, cx: &mut Context) { + self.set_minimap_visibility(self.minimap_visibility.hidden(), window, cx); + } + + /// Normally the text in full mode and auto height editors is padded on the + /// left side by roughly half a character width for improved hit testing. + /// + /// Use this method to disable this for cases where this is not wanted (e.g. + /// if you want to align the editor text with some other text above or below) + /// or if you want to add this padding to single-line editors. + pub fn set_offset_content(&mut self, offset_content: bool, cx: &mut Context) { + self.offset_content = offset_content; + cx.notify(); + } + + pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context) { + self.show_line_numbers = Some(show_line_numbers); + cx.notify(); + } + + pub fn disable_expand_excerpt_buttons(&mut self, cx: &mut Context) { + self.disable_expand_excerpt_buttons = true; + cx.notify(); + } + + pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context) { + self.show_git_diff_gutter = Some(show_git_diff_gutter); + cx.notify(); + } + + pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context) { + self.show_code_actions = Some(show_code_actions); + cx.notify(); + } + + pub fn set_show_runnables(&mut self, show_runnables: bool, cx: &mut Context) { + self.show_runnables = Some(show_runnables); + cx.notify(); + } + + pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context) { + self.show_breakpoints = Some(show_breakpoints); + cx.notify(); + } + + pub fn set_show_diff_review_button(&mut self, show: bool, cx: &mut Context) { + self.show_diff_review_button = show; + cx.notify(); + } + + fn set_show_scrollbars(&mut self, show: bool, cx: &mut Context) { + self.show_scrollbars = ScrollbarAxes { + horizontal: show, + vertical: show, + }; + cx.notify(); + } + + pub(super) fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> { + let mut wrap_guides = smallvec![]; + + if self.show_wrap_guides == Some(false) { + return wrap_guides; + } + + let settings = self.buffer.read(cx).language_settings(cx); + if settings.show_wrap_guides { + match self.soft_wrap_mode(cx) { + SoftWrap::Bounded(soft_wrap) => { + wrap_guides.push((soft_wrap as usize, true)); + } + SoftWrap::GitDiff | SoftWrap::None | SoftWrap::EditorWidth => {} + } + wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false))) + } + + wrap_guides + } + + pub(super) fn soft_wrap_mode(&self, cx: &App) -> SoftWrap { + let settings = self.buffer.read(cx).language_settings(cx); + let mode = self.soft_wrap_mode_override.unwrap_or(settings.soft_wrap); + match mode { + language_settings::SoftWrap::PreferLine | language_settings::SoftWrap::None => { + SoftWrap::None + } + language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth, + language_settings::SoftWrap::Bounded => { + SoftWrap::Bounded(settings.preferred_line_length) + } + } + } + + // Called by the element. This method is not designed to be called outside of the editor + // element's layout code because it does not notify when rewrapping is computed synchronously. + pub(super) fn set_wrap_width(&self, width: Option, cx: &mut App) -> bool { + if self.is_empty(cx) { + self.placeholder_display_map + .as_ref() + .map_or(false, |display_map| { + display_map.update(cx, |map, cx| map.set_wrap_width(width, cx)) + }) + } else { + self.display_map + .update(cx, |map, cx| map.set_wrap_width(width, cx)) + } + } + + pub(super) fn toggle_soft_wrap( + &mut self, + _: &ToggleSoftWrap, + _: &mut Window, + cx: &mut Context, + ) { + if self.soft_wrap_mode_override.is_some() { + self.soft_wrap_mode_override.take(); + } else { + let soft_wrap = match self.soft_wrap_mode(cx) { + SoftWrap::GitDiff => return, + SoftWrap::None => language_settings::SoftWrap::EditorWidth, + SoftWrap::EditorWidth | SoftWrap::Bounded(_) => language_settings::SoftWrap::None, + }; + self.soft_wrap_mode_override = Some(soft_wrap); + } + cx.notify(); + } + + pub(super) fn toggle_tab_bar( + &mut self, + _: &ToggleTabBar, + _: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace() else { + return; + }; + let fs = workspace.read(cx).app_state().fs.clone(); + let current_show = TabBarSettings::get_global(cx).show; + update_settings_file(fs, cx, move |setting, _| { + setting.tab_bar.get_or_insert_default().show = Some(!current_show); + }); + } + + pub(super) fn toggle_indent_guides( + &mut self, + _: &ToggleIndentGuides, + _: &mut Window, + cx: &mut Context, + ) { + let currently_enabled = self.should_show_indent_guides().unwrap_or_else(|| { + self.buffer + .read(cx) + .language_settings(cx) + .indent_guides + .enabled + }); + self.show_indent_guides = Some(!currently_enabled); + cx.notify(); + } + + pub(super) fn should_show_indent_guides(&self) -> Option { + self.show_indent_guides + } + + pub(super) fn has_indent_guides_disabled_for_buffer(&self, buffer_id: BufferId) -> bool { + self.buffers_with_disabled_indent_guides + .contains(&buffer_id) + } + + pub(super) fn toggle_relative_line_numbers( + &mut self, + _: &ToggleRelativeLineNumbers, + _: &mut Window, + cx: &mut Context, + ) { + let is_relative = self.relative_line_numbers(cx); + self.set_relative_line_number(Some(!is_relative.enabled()), cx) + } + + pub(super) fn set_number_deleted_lines(&mut self, number: bool, cx: &mut Context) { + self.number_deleted_lines = number; + cx.notify(); + } + + pub fn set_delegate_open_excerpts(&mut self, delegate: bool) { + self.delegate_open_excerpts = delegate; + } + + pub(super) fn set_delegate_expand_excerpts(&mut self, delegate: bool) { + self.delegate_expand_excerpts = delegate; + } + + pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) { + self.delegate_stage_and_restore = delegate; + } + + pub(super) fn set_on_local_selections_changed( + &mut self, + callback: Option) + 'static>>, + ) { + self.on_local_selections_changed = callback; + } + + pub(super) fn set_suppress_selection_callback(&mut self, suppress: bool) { + self.suppress_selection_callback = suppress; + } +} diff --git a/crates/editor/src/editor/diagnostics.rs b/crates/editor/src/diagnostics.rs similarity index 100% rename from crates/editor/src/editor/diagnostics.rs rename to crates/editor/src/diagnostics.rs diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index db01bbb1786..f7433c96448 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -689,6 +689,10 @@ impl DisplayMap { } } + pub fn crease_snapshot(&self) -> CreaseSnapshot { + self.crease_map.snapshot() + } + #[instrument(skip_all)] pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 649ffbfae8a..7147677d91e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22,6 +22,7 @@ mod document_colors; mod document_symbols; mod editor_settings; mod element; +mod fold; mod folding_ranges; mod git; mod highlight_matching_bracket; @@ -57,10 +58,20 @@ mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; -#[path = "editor/diagnostics.rs"] +mod code_actions; +mod completions; +mod config; mod diagnostics; +mod input; +mod rewrap; +mod selection; pub(crate) use actions::*; +pub use code_actions::CodeActionProvider; +pub use completions::CompletionProvider; +#[cfg(test)] +pub(crate) use completions::snippet_candidate_suffixes; +pub(crate) use completions::split_words; use diagnostics::{ActiveDiagnostic, GlobalDiagnosticRenderer, InlineDiagnostic}; pub use diagnostics::{DiagnosticRenderer, set_diagnostic_renderer}; pub use display_map::{ @@ -1436,13 +1447,6 @@ impl GutterDimensions { pub fn full_width(&self) -> Pixels { self.margin + self.width } - - /// The width of the space reserved for the fold indicators, - /// use alongside 'justify_end' and `gutter_width` to - /// right align content with the line numbers - pub fn fold_area_width(&self) -> Pixels { - self.margin + self.right_padding - } } struct CharacterDimensions { @@ -2776,30 +2780,6 @@ impl Editor { .is_some_and(|menu| menu.context_menu.focus_handle(cx).is_focused(window)) } - pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { - if self - .selections - .pending_anchor() - .is_some_and(|pending_selection| { - let snapshot = self.buffer().read(cx).snapshot(cx); - pending_selection.range().includes(range, &snapshot) - }) - { - return true; - } - - self.selections - .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) - .into_iter() - .any(|selection| { - // This is needed to cover a corner case, if we just check for an existing - // selection in the fold range, having a cursor at the start of the fold - // marks it as selected. Non-empty selections don't cause this. - let length = selection.end - selection.start; - length > 0 - }) - } - pub fn key_context(&self, window: &mut Window, cx: &mut App) -> KeyContext { self.key_context_internal(self.has_active_edit_prediction(), window, cx) } @@ -3361,15 +3341,6 @@ impl Editor { self.custom_context_menu = Some(Box::new(f)) } - pub fn set_completion_provider(&mut self, provider: Option>) { - self.completion_provider = provider; - } - - #[cfg(any(test, feature = "test-support"))] - pub fn completion_provider(&self) -> Option> { - self.completion_provider.clone() - } - pub fn semantics_provider(&self) -> Option> { self.semantics_provider.clone() } @@ -3481,14 +3452,6 @@ impl Editor { } } - pub fn set_input_enabled(&mut self, input_enabled: bool) { - self.input_enabled = input_enabled; - } - - pub fn set_expects_character_input(&mut self, expects_character_input: bool) { - self.expects_character_input = expects_character_input; - } - pub fn set_edit_predictions_hidden_for_vim_mode( &mut self, hidden: bool, @@ -3509,14 +3472,6 @@ impl Editor { self.menu_edit_predictions_policy = value; } - pub fn set_autoindent(&mut self, autoindent: bool) { - if autoindent { - self.autoindent_mode = Some(AutoindentMode::EachLine); - } else { - self.autoindent_mode = None; - } - } - pub fn capability(&self, cx: &App) -> Capability { if self.read_only { Capability::ReadOnly @@ -3533,22 +3488,10 @@ impl Editor { self.read_only = read_only; } - pub fn set_use_autoclose(&mut self, autoclose: bool) { - self.use_autoclose = autoclose; - } - pub fn set_use_selection_highlight(&mut self, highlight: bool) { self.use_selection_highlight = highlight; } - pub fn set_use_auto_surround(&mut self, auto_surround: bool) { - self.use_auto_surround = auto_surround; - } - - pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) { - self.auto_replace_emoji_shortcode = auto_replace; - } - pub fn set_should_serialize(&mut self, should_serialize: bool, cx: &App) { self.buffer_serialization = should_serialize.then(|| { BufferSerialization::new( @@ -3577,10 +3520,6 @@ impl Editor { } } - pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option) { - self.show_completions_on_input_override = show_completions_on_input; - } - pub fn set_show_edit_predictions( &mut self, show_edit_predictions: Option, @@ -3626,449 +3565,6 @@ impl Editor { self.use_modal_editing } - fn selections_did_change( - &mut self, - local: bool, - old_cursor_position: &Anchor, - effects: SelectionEffects, - window: &mut Window, - cx: &mut Context, - ) { - self.last_selection_from_search = effects.from_search; - window.invalidate_character_coordinates(); - - // Copy selections to primary selection buffer - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - if local { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - let buffer_handle = self.buffer.read(cx).read(cx); - - let mut text = String::new(); - for (index, selection) in selections.iter().enumerate() { - let text_for_selection = buffer_handle - .text_for_range(selection.start..selection.end) - .collect::(); - - text.push_str(&text_for_selection); - if index != selections.len() - 1 { - text.push('\n'); - } - } - - if !text.is_empty() { - cx.write_to_primary(ClipboardItem::new_string(text)); - } - } - - let selection_anchors = self.selections.disjoint_anchors_arc(); - - if self.focus_handle.is_focused(window) && self.leader_id.is_none() { - self.buffer.update(cx, |buffer, cx| { - buffer.set_active_selections( - &selection_anchors, - self.selections.line_mode(), - self.cursor_shape, - cx, - ) - }); - } - let display_map = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - if self.selections.count() == 1 { - self.add_selections_state = None; - } - self.select_next_state = None; - self.select_prev_state = None; - self.select_syntax_node_history.try_clear(); - self.invalidate_autoclose_regions(&selection_anchors, buffer); - self.snippet_stack.invalidate(&selection_anchors, buffer); - self.take_rename(false, window, cx); - - let newest_selection = self.selections.newest_anchor(); - let new_cursor_position = newest_selection.head(); - let selection_start = newest_selection.start; - - if effects.nav_history.is_none() || effects.nav_history == Some(true) { - self.push_to_nav_history( - *old_cursor_position, - Some(new_cursor_position.to_point(buffer)), - false, - effects.nav_history == Some(true), - cx, - ); - } - - if local { - if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) { - self.register_buffer(anchor.buffer_id, cx); - } - - let mut context_menu = self.context_menu.borrow_mut(); - let completion_menu = match context_menu.as_ref() { - Some(CodeContextMenu::Completions(menu)) => Some(menu), - Some(CodeContextMenu::CodeActions(_)) => { - *context_menu = None; - None - } - None => None, - }; - let completion_position = completion_menu.map(|menu| menu.initial_position); - drop(context_menu); - - if effects.completions - && let Some(completion_position) = completion_position - { - let start_offset = selection_start.to_offset(buffer); - let position_matches = start_offset == completion_position.to_offset(buffer); - let continue_showing = if let Some((snap, ..)) = - buffer.point_to_buffer_offset(completion_position) - && !snap.capability.editable() - { - false - } else if position_matches { - if self.snippet_stack.is_empty() { - buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) - == Some(CharKind::Word) - } else { - // Snippet choices can be shown even when the cursor is in whitespace. - // Dismissing the menu with actions like backspace is handled by - // invalidation regions. - true - } - } else { - false - }; - - if continue_showing { - self.open_or_update_completions_menu(None, None, false, window, cx); - } else { - self.hide_context_menu(window, cx); - } - } - - hide_hover(self, cx); - - self.refresh_code_actions_for_selection(window, cx); - self.refresh_document_highlights(cx); - refresh_linked_ranges(self, window, cx); - - self.refresh_selected_text_highlights(&display_map, false, window, cx); - self.refresh_matching_bracket_highlights(&display_map, cx); - self.refresh_outline_symbols_at_cursor(cx); - self.update_visible_edit_prediction(window, cx); - self.hide_blame_popover(true, cx); - if self.git_blame_inline_enabled { - self.start_inline_blame_timer(window, cx); - } - } - - self.blink_manager.update(cx, BlinkManager::pause_blinking); - - if local && !self.suppress_selection_callback { - if let Some(callback) = self.on_local_selections_changed.as_ref() { - let cursor_position = self.selections.newest::(&display_map).head(); - callback(cursor_position, window, cx); - } - } - - cx.emit(EditorEvent::SelectionsChanged { local }); - - let selections = &self.selections.disjoint_anchors_arc(); - if local && let Some(buffer_snapshot) = buffer.as_singleton() { - let inmemory_selections = selections - .iter() - .map(|s| { - let start = s.range().start.text_anchor_in(buffer_snapshot); - let end = s.range().end.text_anchor_in(buffer_snapshot); - (start..end).to_point(buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.selections = inmemory_selections; - }); - - if WorkspaceSettings::get(None, cx).restore_on_startup - != RestoreOnStartupBehavior::EmptyTab - && let Some(workspace_id) = self.workspace_serialization_id(cx) - { - let snapshot = self.buffer().read(cx).snapshot(cx); - let selections = selections.clone(); - let background_executor = cx.background_executor().clone(); - let editor_id = cx.entity().entity_id().as_u64() as ItemId; - let db = EditorDb::global(cx); - self.serialize_selections = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - let db_selections = selections - .iter() - .map(|selection| { - ( - selection.start.to_offset(&snapshot).0, - selection.end.to_offset(&snapshot).0, - ) - }) - .collect(); - - db.save_editor_selections(editor_id, workspace_id, db_selections) - .await - .with_context(|| { - format!( - "persisting editor selections for editor {editor_id}, \ - workspace {workspace_id:?}" - ) - }) - .log_err(); - }); - } - } - - cx.notify(); - } - - fn folds_did_change(&mut self, cx: &mut Context) { - use text::ToOffset as _; - - if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup - == RestoreOnStartupBehavior::EmptyTab - { - return; - } - - let display_snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); - let Some(buffer_snapshot) = display_snapshot.buffer_snapshot().as_singleton() else { - return; - }; - let inmemory_folds = display_snapshot - .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) - .map(|fold| { - let start = fold.range.start.text_anchor_in(buffer_snapshot); - let end = fold.range.end.text_anchor_in(buffer_snapshot); - (start..end).to_point(buffer_snapshot) - }) - .collect(); - self.update_restoration_data(cx, |data| { - data.folds = inmemory_folds; - }); - - let Some(workspace_id) = self.workspace_serialization_id(cx) else { - return; - }; - - // Get file path for path-based fold storage (survives tab close) - let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| { - project::File::from_dyn(buffer.read(cx).file()) - .map(|file| Arc::::from(file.abs_path(cx))) - }) else { - return; - }; - - let background_executor = cx.background_executor().clone(); - const FINGERPRINT_LEN: usize = 32; - let db_folds = display_snapshot - .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) - .map(|fold| { - let start = fold - .range - .start - .text_anchor_in(buffer_snapshot) - .to_offset(buffer_snapshot); - let end = fold - .range - .end - .text_anchor_in(buffer_snapshot) - .to_offset(buffer_snapshot); - - // Extract fingerprints - content at fold boundaries for validation on restore - // Both fingerprints must be INSIDE the fold to avoid capturing surrounding - // content that might change independently. - // start_fp: first min(32, fold_len) bytes of fold content - // end_fp: last min(32, fold_len) bytes of fold content - // Clip to character boundaries to handle multibyte UTF-8 characters. - let fold_len = end - start; - let start_fp_end = buffer_snapshot - .clip_offset(start + std::cmp::min(FINGERPRINT_LEN, fold_len), Bias::Left); - let start_fp: String = buffer_snapshot - .text_for_range(start..start_fp_end) - .collect(); - let end_fp_start = buffer_snapshot - .clip_offset(end.saturating_sub(FINGERPRINT_LEN).max(start), Bias::Right); - let end_fp: String = buffer_snapshot.text_for_range(end_fp_start..end).collect(); - - (start, end, start_fp, end_fp) - }) - .collect::>(); - let db = EditorDb::global(cx); - self.serialize_folds = cx.background_spawn(async move { - background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; - if db_folds.is_empty() { - // No folds - delete any persisted folds for this file - db.delete_file_folds(workspace_id, file_path) - .await - .with_context(|| format!("deleting file folds for workspace {workspace_id:?}")) - .log_err(); - } else { - db.save_file_folds(workspace_id, file_path, db_folds) - .await - .with_context(|| { - format!("persisting file folds for workspace {workspace_id:?}") - }) - .log_err(); - } - }); - } - - pub fn sync_selections( - &mut self, - other: Entity, - cx: &mut Context, - ) -> gpui::Subscription { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if !other_selections.is_empty() { - self.selections - .change_with(&self.display_snapshot(cx), |selections| { - selections.select_anchors(other_selections); - }); - } - - let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = other_evt { - let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); - if other_selections.is_empty() { - return; - } - let snapshot = this.display_snapshot(cx); - this.selections.change_with(&snapshot, |selections| { - selections.select_anchors(other_selections); - }); - } - }); - - let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { - if let EditorEvent::SelectionsChanged { local: true } = this_evt { - let these_selections = this.selections.disjoint_anchors().to_vec(); - if these_selections.is_empty() { - return; - } - other.update(cx, |other_editor, cx| { - let snapshot = other_editor.display_snapshot(cx); - other_editor - .selections - .change_with(&snapshot, |selections| { - selections.select_anchors(these_selections); - }) - }); - } - }); - - Subscription::join(other_subscription, this_subscription) - } - - fn unfold_buffers_with_selections(&mut self, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() { - return; - } - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids: HashSet = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| snapshot.buffer_ids_for_range(range)) - .collect(); - for buffer_id in buffer_ids { - self.unfold_buffer(buffer_id, cx); - } - } - - /// Changes selections using the provided mutation function. Changes to `self.selections` occur - /// immediately, but when run within `transact` or `with_selection_effects_deferred` other - /// effects of selection change occur at the end of the transaction. - pub fn change_selections( - &mut self, - effects: SelectionEffects, - window: &mut Window, - cx: &mut Context, - change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, - ) -> R { - let snapshot = self.display_snapshot(cx); - if let Some(state) = &mut self.deferred_selection_effects_state { - state.effects.scroll = effects.scroll.or(state.effects.scroll); - state.effects.completions = effects.completions; - state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed |= changed; - return result; - } - let mut state = DeferredSelectionEffectsState { - changed: false, - effects, - old_cursor_position: self.selections.newest_anchor().head(), - history_entry: SelectionHistoryEntry { - selections: self.selections.disjoint_anchors_arc(), - select_next_state: self.select_next_state.clone(), - select_prev_state: self.select_prev_state.clone(), - add_selections_state: self.add_selections_state.clone(), - }, - }; - let (changed, result) = self.selections.change_with(&snapshot, change); - state.changed = state.changed || changed; - if self.defer_selection_effects { - self.deferred_selection_effects_state = Some(state); - } else { - self.apply_selection_effects(state, window, cx); - } - result - } - - /// Defers the effects of selection change, so that the effects of multiple calls to - /// `change_selections` are applied at the end. This way these intermediate states aren't added - /// to selection history and the state of popovers based on selection position aren't - /// erroneously updated. - pub fn with_selection_effects_deferred( - &mut self, - window: &mut Window, - cx: &mut Context, - update: impl FnOnce(&mut Self, &mut Window, &mut Context) -> R, - ) -> R { - let already_deferred = self.defer_selection_effects; - self.defer_selection_effects = true; - let result = update(self, window, cx); - if !already_deferred { - self.defer_selection_effects = false; - if let Some(state) = self.deferred_selection_effects_state.take() { - self.apply_selection_effects(state, window, cx); - } - } - result - } - - fn apply_selection_effects( - &mut self, - state: DeferredSelectionEffectsState, - window: &mut Window, - cx: &mut Context, - ) { - if state.changed { - self.selection_history.push(state.history_entry); - - if let Some(autoscroll) = state.effects.scroll { - self.request_autoscroll(autoscroll, cx); - } - - let old_cursor_position = &state.old_cursor_position; - - self.selections_did_change(true, old_cursor_position, state.effects, window, cx); - - if self.should_open_signature_help_automatically(old_cursor_position, cx) { - self.show_signature_help_auto(window, cx); - } - } - } - pub fn edit(&mut self, edits: I, cx: &mut Context) where I: IntoIterator, T)>, @@ -4123,480 +3619,6 @@ impl Editor { }); } - fn select(&mut self, phase: SelectPhase, window: &mut Window, cx: &mut Context) { - self.hide_context_menu(window, cx); - - match phase { - SelectPhase::Begin { - position, - add, - click_count, - } => self.begin_selection(position, add, click_count, window, cx), - SelectPhase::BeginColumnar { - position, - goal_column, - reset, - mode, - } => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx), - SelectPhase::Extend { - position, - click_count, - } => self.extend_selection(position, click_count, window, cx), - SelectPhase::Update { - position, - goal_column, - scroll_delta, - } => self.update_selection(position, goal_column, scroll_delta, window, cx), - SelectPhase::End => self.end_selection(window, cx), - } - } - - fn extend_selection( - &mut self, - position: DisplayPoint, - click_count: usize, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let tail = self - .selections - .newest::(&display_map) - .tail(); - let click_count = click_count.max(match self.selections.select_mode() { - SelectMode::Character => 1, - SelectMode::Word(_) => 2, - SelectMode::Line(_) => 3, - SelectMode::All => 4, - }); - self.begin_selection(position, false, click_count, window, cx); - - let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); - - let current_selection = match self.selections.select_mode() { - SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, - SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), - }; - - let mut pending_selection = self - .selections - .pending_anchor() - .cloned() - .expect("extend_selection not called with pending selection"); - - if pending_selection - .start - .cmp(¤t_selection.start, display_map.buffer_snapshot()) - == Ordering::Greater - { - pending_selection.start = current_selection.start; - } - if pending_selection - .end - .cmp(¤t_selection.end, display_map.buffer_snapshot()) - == Ordering::Less - { - pending_selection.end = current_selection.end; - pending_selection.reversed = true; - } - - let mut pending_mode = self.selections.pending_mode().unwrap(); - match &mut pending_mode { - SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, - _ => {} - } - - let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks { - SelectionEffects::scroll(Autoscroll::fit()) - } else { - SelectionEffects::no_scroll() - }; - - self.change_selections(effects, window, cx, |s| { - s.set_pending(pending_selection.clone(), pending_mode); - s.set_is_extending(true); - }); - } - - fn begin_selection( - &mut self, - position: DisplayPoint, - add: bool, - click_count: usize, - window: &mut Window, - cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle, cx); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - let position = display_map.clip_point(position, Bias::Left); - - let start; - let end; - let mode; - let mut auto_scroll; - match click_count { - 1 => { - start = buffer.anchor_before(position.to_point(&display_map)); - end = start; - mode = SelectMode::Character; - auto_scroll = true; - } - 2 => { - let position = display_map - .clip_point(position, Bias::Left) - .to_offset(&display_map, Bias::Left); - let (range, _) = buffer.surrounding_word(position, None); - start = buffer.anchor_before(range.start); - end = buffer.anchor_before(range.end); - mode = SelectMode::Word(start..end); - auto_scroll = true; - } - 3 => { - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - start = buffer.anchor_before(line_start); - end = buffer.anchor_before(next_line_start); - mode = SelectMode::Line(start..end); - auto_scroll = true; - } - _ => { - start = buffer.anchor_before(MultiBufferOffset(0)); - end = buffer.anchor_before(buffer.len()); - mode = SelectMode::All; - auto_scroll = false; - } - } - auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; - - let point_to_delete: Option = { - let selected_points: Vec> = - self.selections.disjoint_in_range(start..end, &display_map); - - if !add || click_count > 1 { - None - } else if !selected_points.is_empty() { - Some(selected_points[0].id) - } else { - let clicked_point_already_selected = - self.selections.disjoint_anchors().iter().find(|selection| { - selection.start.to_point(buffer) == start.to_point(buffer) - || selection.end.to_point(buffer) == end.to_point(buffer) - }); - - clicked_point_already_selected.map(|selection| selection.id) - } - }; - - let selections_count = self.selections.count(); - let effects = if auto_scroll { - SelectionEffects::default() - } else { - SelectionEffects::no_scroll() - }; - - self.change_selections(effects, window, cx, |s| { - if let Some(point_to_delete) = point_to_delete { - s.delete(point_to_delete); - - if selections_count == 1 { - s.set_pending_anchor_range(start..end, mode); - } - } else { - if !add { - s.clear_disjoint(); - } - - s.set_pending_anchor_range(start..end, mode); - } - }); - } - - fn begin_columnar_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - reset: bool, - mode: ColumnarMode, - window: &mut Window, - cx: &mut Context, - ) { - if !self.focus_handle.is_focused(window) { - self.last_focused_descendant = None; - window.focus(&self.focus_handle, cx); - } - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if reset { - let pointer_position = display_map - .buffer_snapshot() - .anchor_before(position.to_point(&display_map)); - - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |s| { - s.clear_disjoint(); - s.set_pending_anchor_range( - pointer_position..pointer_position, - SelectMode::Character, - ); - }, - ); - }; - - let tail = self.selections.newest::(&display_map).tail(); - let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); - self.columnar_selection_state = match mode { - ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { - selection_tail: selection_anchor, - display_point: if reset { - if position.column() != goal_column { - Some(DisplayPoint::new(position.row(), goal_column)) - } else { - None - } - } else { - None - }, - }), - ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection { - selection_tail: selection_anchor, - }), - }; - - if !reset { - self.select_columns(position, goal_column, &display_map, window, cx); - } - } - - fn update_selection( - &mut self, - position: DisplayPoint, - goal_column: u32, - scroll_delta: gpui::Point, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if self.columnar_selection_state.is_some() { - self.select_columns(position, goal_column, &display_map, window, cx); - } else if let Some(mut pending) = self.selections.pending_anchor().cloned() { - let buffer = display_map.buffer_snapshot(); - let head; - let tail; - let mode = self.selections.pending_mode().unwrap(); - match &mode { - SelectMode::Character => { - head = position.to_point(&display_map); - tail = pending.tail().to_point(buffer); - } - SelectMode::Word(original_range) => { - let offset = display_map - .clip_point(position, Bias::Left) - .to_offset(&display_map, Bias::Left); - let original_range = original_range.to_offset(buffer); - - let head_offset = if buffer.is_inside_word(offset, None) - || original_range.contains(&offset) - { - let (word_range, _) = buffer.surrounding_word(offset, None); - if word_range.start < original_range.start { - word_range.start - } else { - word_range.end - } - } else { - offset - }; - - head = head_offset.to_point(buffer); - if head_offset <= original_range.start { - tail = original_range.end.to_point(buffer); - } else { - tail = original_range.start.to_point(buffer); - } - } - SelectMode::Line(original_range) => { - let original_range = original_range.to_point(display_map.buffer_snapshot()); - - let position = display_map - .clip_point(position, Bias::Left) - .to_point(&display_map); - let line_start = display_map.prev_line_boundary(position).0; - let next_line_start = buffer.clip_point( - display_map.next_line_boundary(position).0 + Point::new(1, 0), - Bias::Left, - ); - - if line_start < original_range.start { - head = line_start - } else { - head = next_line_start - } - - if head <= original_range.start { - tail = original_range.end; - } else { - tail = original_range.start; - } - } - SelectMode::All => { - return; - } - }; - - if head < tail { - pending.start = buffer.anchor_before(head); - pending.end = buffer.anchor_before(tail); - pending.reversed = true; - } else { - pending.start = buffer.anchor_before(tail); - pending.end = buffer.anchor_before(head); - pending.reversed = false; - } - - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.set_pending(pending.clone(), mode); - }); - } else { - log::error!("update_selection dispatched with no pending selection"); - return; - } - - self.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } - - fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { - self.columnar_selection_state.take(); - if let Some(pending_mode) = self.selections.pending_mode() { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select(selections); - s.clear_pending(); - if s.is_extending() { - s.set_is_extending(false); - } else { - s.set_select_mode(pending_mode); - } - }); - } - } - - fn select_columns( - &mut self, - head: DisplayPoint, - goal_column: u32, - display_map: &DisplaySnapshot, - window: &mut Window, - cx: &mut Context, - ) { - let Some(columnar_state) = self.columnar_selection_state.as_ref() else { - return; - }; - - let tail = match columnar_state { - ColumnarSelectionState::FromMouse { - selection_tail, - display_point, - } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), - ColumnarSelectionState::FromSelection { selection_tail } => { - selection_tail.to_display_point(display_map) - } - }; - - let start_row = cmp::min(tail.row(), head.row()); - let end_row = cmp::max(tail.row(), head.row()); - let start_column = cmp::min(tail.column(), goal_column); - let end_column = cmp::max(tail.column(), goal_column); - let reversed = start_column < tail.column(); - - let selection_ranges = (start_row.0..=end_row.0) - .map(DisplayRow) - .filter_map(|row| { - if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. }) - || start_column <= display_map.line_len(row)) - && !display_map.is_block_line(row) - { - let start = display_map - .clip_point(DisplayPoint::new(row, start_column), Bias::Left) - .to_point(display_map); - let end = display_map - .clip_point(DisplayPoint::new(row, end_column), Bias::Right) - .to_point(display_map); - if reversed { - Some(end..start) - } else { - Some(start..end) - } - } else { - None - } - }) - .collect::>(); - if selection_ranges.is_empty() { - return; - } - - let ranges = match columnar_state { - ColumnarSelectionState::FromMouse { .. } => { - let mut non_empty_ranges = selection_ranges - .iter() - .filter(|selection_range| selection_range.start != selection_range.end) - .peekable(); - if non_empty_ranges.peek().is_some() { - non_empty_ranges.cloned().collect() - } else { - selection_ranges - } - } - _ => selection_ranges, - }; - - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(ranges); - }); - cx.notify(); - } - - pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { - self.selections - .all_adjusted(snapshot) - .iter() - .any(|selection| !selection.is_empty()) - } - - pub fn has_pending_nonempty_selection(&self) -> bool { - let pending_nonempty_selection = match self.selections.pending_anchor() { - Some(Selection { start, end, .. }) => start != end, - None => false, - }; - - pending_nonempty_selection - || (self.columnar_selection_state.is_some() - && self.selections.disjoint_anchors().len() > 1) - } - - pub fn has_pending_selection(&self) -> bool { - self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some() - } - pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { self.selection_mark_mode = false; self.selection_drag_state = SelectionDragState::None; @@ -4659,2536 +3681,6 @@ impl Editor { dismissed } - fn linked_editing_ranges_for( - &self, - query_range: Range, - cx: &App, - ) -> Option, Vec>>> { - use text::ToOffset as TO; - - if self.linked_edit_ranges.is_empty() { - return None; - } - if query_range.start.buffer_id != query_range.end.buffer_id { - return None; - }; - let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer = self.buffer.read(cx).buffer(query_range.end.buffer_id)?; - let buffer_snapshot = buffer.read(cx).snapshot(); - let (base_range, linked_ranges) = self.linked_edit_ranges.get( - buffer_snapshot.remote_id(), - query_range.clone(), - &buffer_snapshot, - )?; - // find offset from the start of current range to current cursor position - let start_byte_offset = TO::to_offset(&base_range.start, &buffer_snapshot); - - let start_offset = TO::to_offset(&query_range.start, &buffer_snapshot); - let start_difference = start_offset - start_byte_offset; - let end_offset = TO::to_offset(&query_range.end, &buffer_snapshot); - let end_difference = end_offset - start_byte_offset; - - // Current range has associated linked ranges. - let mut linked_edits = HashMap::<_, Vec<_>>::default(); - for range in linked_ranges.iter() { - let start_offset = TO::to_offset(&range.start, &buffer_snapshot); - let end_offset = start_offset + end_difference; - let start_offset = start_offset + start_difference; - if start_offset > buffer_snapshot.len() || end_offset > buffer_snapshot.len() { - continue; - } - if self.selections.disjoint_anchor_ranges().any(|s| { - let Some((selection_start, _)) = - multibuffer_snapshot.anchor_to_buffer_anchor(s.start) - else { - return false; - }; - let Some((selection_end, _)) = multibuffer_snapshot.anchor_to_buffer_anchor(s.end) - else { - return false; - }; - if selection_start.buffer_id != query_range.start.buffer_id - || selection_end.buffer_id != query_range.end.buffer_id - { - return false; - } - TO::to_offset(&selection_start, &buffer_snapshot) <= end_offset - && TO::to_offset(&selection_end, &buffer_snapshot) >= start_offset - }) { - continue; - } - let start = buffer_snapshot.anchor_after(start_offset); - let end = buffer_snapshot.anchor_after(end_offset); - linked_edits - .entry(buffer.clone()) - .or_default() - .push(start..end); - } - Some(linked_edits) - } - - pub fn handle_input(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - let text: Arc = text.into(); - - if self.read_only(cx) { - return; - } - - self.unfold_buffers_with_selections(cx); - - let selections = self.selections.all_adjusted(&self.display_snapshot(cx)); - let mut bracket_inserted = false; - let mut edits = Vec::new(); - let mut linked_edits = LinkedEdits::new(); - let mut new_selections = Vec::with_capacity(selections.len()); - let mut new_autoclose_regions = Vec::new(); - let snapshot = self.buffer.read(cx).read(cx); - let mut clear_linked_edit_ranges = false; - let mut all_selections_read_only = true; - let mut has_adjacent_edits = false; - let mut in_adjacent_group = false; - - let mut regions = self - .selections_with_autoclose_regions(selections, &snapshot) - .peekable(); - - while let Some((selection, autoclose_region)) = regions.next() { - if snapshot - .point_to_buffer_point(selection.head()) - .is_none_or(|(snapshot, ..)| !snapshot.capability.editable()) - { - continue; - } - if snapshot - .point_to_buffer_point(selection.tail()) - .is_none_or(|(snapshot, ..)| !snapshot.capability.editable()) - { - // note, ideally we'd clip the tail to the closest writeable region towards the head - continue; - } - all_selections_read_only = false; - - if let Some(scope) = snapshot.language_scope_at(selection.head()) { - // Determine if the inserted text matches the opening or closing - // bracket of any of this language's bracket pairs. - let mut bracket_pair = None; - let mut is_bracket_pair_start = false; - let mut is_bracket_pair_end = false; - if !text.is_empty() { - let mut bracket_pair_matching_end = None; - // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified) - // and they are removing the character that triggered IME popup. - for (pair, enabled) in scope.brackets() { - if !pair.close && !pair.surround { - continue; - } - - if enabled && pair.start.ends_with(text.as_ref()) { - let prefix_len = pair.start.len() - text.len(); - let preceding_text_matches_prefix = prefix_len == 0 - || (selection.start.column >= (prefix_len as u32) - && snapshot.contains_str_at( - Point::new( - selection.start.row, - selection.start.column - (prefix_len as u32), - ), - &pair.start[..prefix_len], - )); - if preceding_text_matches_prefix { - bracket_pair = Some(pair.clone()); - is_bracket_pair_start = true; - break; - } - } - if pair.end.as_str() == text.as_ref() && bracket_pair_matching_end.is_none() - { - // take first bracket pair matching end, but don't break in case a later bracket - // pair matches start - bracket_pair_matching_end = Some(pair.clone()); - } - } - if let Some(end) = bracket_pair_matching_end - && bracket_pair.is_none() - { - bracket_pair = Some(end); - is_bracket_pair_end = true; - } - } - - if let Some(bracket_pair) = bracket_pair { - let snapshot_settings = snapshot.language_settings_at(selection.start, cx); - let autoclose = self.use_autoclose && snapshot_settings.use_autoclose; - let auto_surround = - self.use_auto_surround && snapshot_settings.use_auto_surround; - if selection.is_empty() { - if is_bracket_pair_start { - // If the inserted text is a suffix of an opening bracket and the - // selection is preceded by the rest of the opening bracket, then - // insert the closing bracket. - let following_text_allows_autoclose = snapshot - .chars_at(selection.start) - .next() - .is_none_or(|c| scope.should_autoclose_before(c)); - - let preceding_text_allows_autoclose = selection.start.column == 0 - || snapshot - .reversed_chars_at(selection.start) - .next() - .is_none_or(|c| { - bracket_pair.start != bracket_pair.end - || !snapshot - .char_classifier_at(selection.start) - .is_word(c) - }); - - let is_closing_quote = if bracket_pair.end == bracket_pair.start - && bracket_pair.start.len() == 1 - { - let target = bracket_pair.start.chars().next().unwrap(); - let mut byte_offset = 0u32; - let current_line_count = snapshot - .reversed_chars_at(selection.start) - .take_while(|&c| c != '\n') - .filter(|c| { - byte_offset += c.len_utf8() as u32; - if *c != target { - return false; - } - - let point = Point::new( - selection.start.row, - selection.start.column.saturating_sub(byte_offset), - ); - - let is_enabled = snapshot - .language_scope_at(point) - .and_then(|scope| { - scope - .brackets() - .find(|(pair, _)| { - pair.start == bracket_pair.start - }) - .map(|(_, enabled)| enabled) - }) - .unwrap_or(true); - - let is_delimiter = snapshot - .language_scope_at(Point::new( - point.row, - point.column + 1, - )) - .and_then(|scope| { - scope - .brackets() - .find(|(pair, _)| { - pair.start == bracket_pair.start - }) - .map(|(_, enabled)| !enabled) - }) - .unwrap_or(false); - - is_enabled && !is_delimiter - }) - .count(); - current_line_count % 2 == 1 - } else { - false - }; - - if autoclose - && bracket_pair.close - && following_text_allows_autoclose - && preceding_text_allows_autoclose - && !is_closing_quote - { - let anchor = snapshot.anchor_before(selection.end); - new_selections.push((selection.map(|_| anchor), text.len())); - new_autoclose_regions.push(( - anchor, - text.len(), - selection.id, - bracket_pair.clone(), - )); - edits.push(( - selection.range(), - format!("{}{}", text, bracket_pair.end).into(), - )); - bracket_inserted = true; - continue; - } - } - - if let Some(region) = autoclose_region { - // If the selection is followed by an auto-inserted closing bracket, - // then don't insert that closing bracket again; just move the selection - // past the closing bracket. - let should_skip = selection.end == region.range.end.to_point(&snapshot) - && text.as_ref() == region.pair.end.as_str() - && snapshot.contains_str_at(region.range.end, text.as_ref()); - if should_skip { - let anchor = snapshot.anchor_after(selection.end); - new_selections - .push((selection.map(|_| anchor), region.pair.end.len())); - continue; - } - } - - let always_treat_brackets_as_autoclosed = snapshot - .language_settings_at(selection.start, cx) - .always_treat_brackets_as_autoclosed; - if always_treat_brackets_as_autoclosed - && is_bracket_pair_end - && snapshot.contains_str_at(selection.end, text.as_ref()) - { - // Otherwise, when `always_treat_brackets_as_autoclosed` is set to `true - // and the inserted text is a closing bracket and the selection is followed - // by the closing bracket then move the selection past the closing bracket. - let anchor = snapshot.anchor_after(selection.end); - new_selections.push((selection.map(|_| anchor), text.len())); - continue; - } - } - // If an opening bracket is 1 character long and is typed while - // text is selected, then surround that text with the bracket pair. - else if auto_surround - && bracket_pair.surround - && is_bracket_pair_start - && bracket_pair.start.chars().count() == 1 - { - edits.push((selection.start..selection.start, text.clone())); - edits.push(( - selection.end..selection.end, - bracket_pair.end.as_str().into(), - )); - bracket_inserted = true; - new_selections.push(( - Selection { - id: selection.id, - start: snapshot.anchor_after(selection.start), - end: snapshot.anchor_before(selection.end), - reversed: selection.reversed, - goal: selection.goal, - }, - 0, - )); - continue; - } - } - } - - if self.auto_replace_emoji_shortcode - && selection.is_empty() - && text.as_ref().ends_with(':') - && let Some(possible_emoji_short_code) = - Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start) - && !possible_emoji_short_code.is_empty() - && let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) - { - let emoji_shortcode_start = Point::new( - selection.start.row, - selection.start.column - possible_emoji_short_code.len() as u32 - 1, - ); - - // Remove shortcode from buffer - edits.push(( - emoji_shortcode_start..selection.start, - "".to_string().into(), - )); - new_selections.push(( - Selection { - id: selection.id, - start: snapshot.anchor_after(emoji_shortcode_start), - end: snapshot.anchor_before(selection.start), - reversed: selection.reversed, - goal: selection.goal, - }, - 0, - )); - - // Insert emoji - let selection_start_anchor = snapshot.anchor_after(selection.start); - new_selections.push((selection.map(|_| selection_start_anchor), 0)); - edits.push((selection.start..selection.end, emoji.to_string().into())); - - continue; - } - - let next_is_adjacent = regions - .peek() - .is_some_and(|(next, _)| selection.end == next.start); - - // If not handling any auto-close operation, then just replace the selected - // text with the given input and move the selection to the end of the - // newly inserted text. - let anchor = if in_adjacent_group || next_is_adjacent { - // After edits the right bias would shift those anchor to the next visible fragment - // but we want to resolve to the previous one - snapshot.anchor_before(selection.end) - } else { - snapshot.anchor_after(selection.end) - }; - - if !self.linked_edit_ranges.is_empty() { - let start_anchor = snapshot.anchor_before(selection.start); - let classifier = snapshot - .char_classifier_at(start_anchor) - .scope_context(Some(CharScopeContext::LinkedEdit)); - - if let Some((_, anchor_range)) = - snapshot.anchor_range_to_buffer_anchor_range(start_anchor..anchor) - { - let is_word_char = text - .chars() - .next() - .is_none_or(|char| classifier.is_word(char)); - - let is_dot = text.as_ref() == "."; - let should_apply_linked_edit = is_word_char || is_dot; - - if should_apply_linked_edit { - linked_edits.push(&self, anchor_range, text.clone(), cx); - } else { - clear_linked_edit_ranges = true; - } - } - } - - new_selections.push((selection.map(|_| anchor), 0)); - edits.push((selection.start..selection.end, text.clone())); - - has_adjacent_edits |= next_is_adjacent; - in_adjacent_group = next_is_adjacent; - } - - if all_selections_read_only { - return; - } - - drop(regions); - drop(snapshot); - - self.transact(window, cx, |this, window, cx| { - if clear_linked_edit_ranges { - this.linked_edit_ranges.clear(); - } - let initial_buffer_versions = - jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx); - - this.buffer.update(cx, |buffer, cx| { - if has_adjacent_edits { - buffer.edit_non_coalesce(edits, this.autoindent_mode.clone(), cx); - } else { - buffer.edit(edits, this.autoindent_mode.clone(), cx); - } - }); - linked_edits.apply(cx); - let new_anchor_selections = new_selections.iter().map(|e| &e.0); - let new_selection_deltas = new_selections.iter().map(|e| e.1); - let map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); - let new_selections = resolve_selections_wrapping_blocks::( - new_anchor_selections, - &map, - ) - .zip(new_selection_deltas) - .map(|(selection, delta)| Selection { - id: selection.id, - start: selection.start + delta, - end: selection.end + delta, - reversed: selection.reversed, - goal: SelectionGoal::None, - }) - .collect::>(); - - let mut i = 0; - for (position, delta, selection_id, pair) in new_autoclose_regions { - let position = position.to_offset(map.buffer_snapshot()) + delta; - let start = map.buffer_snapshot().anchor_before(position); - let end = map.buffer_snapshot().anchor_after(position); - while let Some(existing_state) = this.autoclose_regions.get(i) { - match existing_state - .range - .start - .cmp(&start, map.buffer_snapshot()) - { - Ordering::Less => i += 1, - Ordering::Greater => break, - Ordering::Equal => { - match end.cmp(&existing_state.range.end, map.buffer_snapshot()) { - Ordering::Less => i += 1, - Ordering::Equal => break, - Ordering::Greater => break, - } - } - } - } - this.autoclose_regions.insert( - i, - AutocloseRegion { - selection_id, - range: start..end, - pair, - }, - ); - } - - let had_active_edit_prediction = this.has_active_edit_prediction(); - this.change_selections( - SelectionEffects::scroll(Autoscroll::fit()).completions(false), - window, - cx, - |s| s.select(new_selections), - ); - - if !bracket_inserted - && let Some(on_type_format_task) = - this.trigger_on_type_formatting(text.to_string(), window, cx) - { - on_type_format_task.detach_and_log_err(cx); - } - - let editor_settings = EditorSettings::get_global(cx); - if bracket_inserted - && (editor_settings.auto_signature_help - || editor_settings.show_signature_help_after_edits) - { - this.show_signature_help(&ShowSignatureHelp, window, cx); - } - - let trigger_in_words = - this.show_edit_predictions_in_menu() || !had_active_edit_prediction; - if this.hard_wrap.is_some() { - let latest: Range = this.selections.newest(&map).range(); - if latest.is_empty() - && this - .buffer() - .read(cx) - .snapshot(cx) - .line_len(MultiBufferRow(latest.start.row)) - == latest.start.column - { - this.rewrap_impl( - RewrapOptions { - override_language_settings: true, - preserve_existing_whitespace: true, - line_length: None, - }, - cx, - ) - } - } - this.trigger_completion_on_input(&text, trigger_in_words, window, cx); - refresh_linked_ranges(this, window, cx); - this.refresh_edit_prediction(true, false, window, cx); - jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); - }); - } - - fn find_possible_emoji_shortcode_at_position( - snapshot: &MultiBufferSnapshot, - position: Point, - ) -> Option { - let mut chars = Vec::new(); - let mut found_colon = false; - for char in snapshot.reversed_chars_at(position).take(100) { - // Found a possible emoji shortcode in the middle of the buffer - if found_colon { - if char.is_whitespace() { - chars.reverse(); - return Some(chars.iter().collect()); - } - // If the previous character is not a whitespace, we are in the middle of a word - // and we only want to complete the shortcode if the word is made up of other emojis - let mut containing_word = String::new(); - for ch in snapshot - .reversed_chars_at(position) - .skip(chars.len() + 1) - .take(100) - { - if ch.is_whitespace() { - break; - } - containing_word.push(ch); - } - let containing_word = containing_word.chars().rev().collect::(); - if util::word_consists_of_emojis(containing_word.as_str()) { - chars.reverse(); - return Some(chars.iter().collect()); - } - } - - if char.is_whitespace() || !char.is_ascii() { - return None; - } - if char == ':' { - found_colon = true; - } else { - chars.push(char); - } - } - // Found a possible emoji shortcode at the beginning of the buffer - chars.reverse(); - Some(chars.iter().collect()) - } - - pub fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - - self.transact(window, cx, |this, window, cx| { - let (edits_with_flags, selection_info): (Vec<_>, Vec<_>) = { - let selections = this - .selections - .all::(&this.display_snapshot(cx)); - let multi_buffer = this.buffer.read(cx); - let buffer = multi_buffer.snapshot(cx); - selections - .iter() - .map(|selection| { - let start_point = selection.start.to_point(&buffer); - let mut existing_indent = - buffer.indent_size_for_line(MultiBufferRow(start_point.row)); - let full_indent_len = existing_indent.len; - existing_indent.len = cmp::min(existing_indent.len, start_point.column); - let mut start = selection.start; - let end = selection.end; - let selection_is_empty = start == end; - let language_scope = buffer.language_scope_at(start); - let (delimiter, newline_config) = if let Some(language) = &language_scope { - let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets( - &buffer, - start..end, - language, - ) - || NewlineConfig::insert_extra_newline_tree_sitter( - &buffer, - start..end, - ); - - let mut newline_config = NewlineConfig::Newline { - additional_indent: IndentSize::spaces(0), - extra_line_additional_indent: if needs_extra_newline { - Some(IndentSize::spaces(0)) - } else { - None - }, - prevent_auto_indent: false, - }; - - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_comment_on_newline { - return None; - } - - return comment_delimiter_for_newline( - &start_point, - &buffer, - language, - ); - }); - - let doc_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_comment_on_newline { - return None; - } - - return documentation_delimiter_for_newline( - &start_point, - &buffer, - language, - &mut newline_config, - ); - }); - - let list_delimiter = maybe!({ - if !selection_is_empty { - return None; - } - - if !multi_buffer.language_settings(cx).extend_list_on_newline { - return None; - } - - return list_delimiter_for_newline( - &start_point, - &buffer, - language, - &mut newline_config, - ); - }); - - ( - comment_delimiter.or(doc_delimiter).or(list_delimiter), - newline_config, - ) - } else { - ( - None, - NewlineConfig::Newline { - additional_indent: IndentSize::spaces(0), - extra_line_additional_indent: None, - prevent_auto_indent: false, - }, - ) - }; - - let (edit_start, new_text, prevent_auto_indent) = match &newline_config { - NewlineConfig::ClearCurrentLine => { - let row_start = - buffer.point_to_offset(Point::new(start_point.row, 0)); - (row_start, String::new(), false) - } - NewlineConfig::UnindentCurrentLine { continuation } => { - let row_start = - buffer.point_to_offset(Point::new(start_point.row, 0)); - let tab_size = buffer.language_settings_at(start, cx).tab_size; - let tab_size_indent = IndentSize::spaces(tab_size.get()); - let reduced_indent = - existing_indent.with_delta(Ordering::Less, tab_size_indent); - let mut new_text = String::new(); - new_text.extend(reduced_indent.chars()); - new_text.push_str(continuation); - (row_start, new_text, true) - } - NewlineConfig::Newline { - additional_indent, - extra_line_additional_indent, - prevent_auto_indent, - } => { - let auto_indent_mode = - buffer.language_settings_at(start, cx).auto_indent; - let preserve_indent = - auto_indent_mode != language::AutoIndentMode::None; - let apply_syntax_indent = - auto_indent_mode == language::AutoIndentMode::SyntaxAware; - let capacity_for_delimiter = - delimiter.as_deref().map(str::len).unwrap_or_default(); - let existing_indent_len = if preserve_indent { - existing_indent.len as usize - } else { - 0 - }; - let extra_line_len = extra_line_additional_indent - .map(|i| 1 + existing_indent_len + i.len as usize) - .unwrap_or(0); - let mut new_text = String::with_capacity( - 1 + capacity_for_delimiter - + existing_indent_len - + additional_indent.len as usize - + extra_line_len, - ); - new_text.push('\n'); - if preserve_indent { - new_text.extend(existing_indent.chars()); - } - new_text.extend(additional_indent.chars()); - if let Some(delimiter) = &delimiter { - new_text.push_str(delimiter); - } - if let Some(extra_indent) = extra_line_additional_indent { - new_text.push('\n'); - if preserve_indent { - new_text.extend(existing_indent.chars()); - } - new_text.extend(extra_indent.chars()); - } - // Extend the edit to the beginning of the line - // to clear auto-indent whitespace that would - // otherwise remain as trailing whitespace. This - // applies to blank lines and lines where only - // indentation remains before the cursor. - if selection_is_empty - && preserve_indent - && full_indent_len > 0 - && start_point.column == full_indent_len - { - start = buffer.point_to_offset(Point::new(start_point.row, 0)); - } - - ( - start, - new_text, - *prevent_auto_indent || !apply_syntax_indent, - ) - } - }; - - let anchor = buffer.anchor_after(end); - let new_selection = selection.map(|_| anchor); - ( - ((edit_start..end, new_text), prevent_auto_indent), - (newline_config.has_extra_line(), new_selection), - ) - }) - .unzip() - }; - - let mut auto_indent_edits = Vec::new(); - let mut edits = Vec::new(); - for (edit, prevent_auto_indent) in edits_with_flags { - if prevent_auto_indent { - edits.push(edit); - } else { - auto_indent_edits.push(edit); - } - } - if !edits.is_empty() { - this.edit(edits, cx); - } - if !auto_indent_edits.is_empty() { - this.edit_with_autoindent(auto_indent_edits, cx); - } - - let buffer = this.buffer.read(cx).snapshot(cx); - let new_selections = selection_info - .into_iter() - .map(|(extra_newline_inserted, new_selection)| { - let mut cursor = new_selection.end.to_point(&buffer); - if extra_newline_inserted { - cursor.row -= 1; - cursor.column = buffer.line_len(MultiBufferRow(cursor.row)); - } - new_selection.map(|_| cursor) - }) - .collect(); - - this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); - this.refresh_edit_prediction(true, false, window, cx); - if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { - task.detach_and_log_err(cx); - } - }); - } - - pub fn newline_above(&mut self, _: &NewlineAbove, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - - let buffer = self.buffer.read(cx); - let snapshot = buffer.snapshot(cx); - - let mut edits = Vec::new(); - let mut rows = Vec::new(); - - for (rows_inserted, selection) in self - .selections - .all_adjusted(&self.display_snapshot(cx)) - .into_iter() - .enumerate() - { - let cursor = selection.head(); - let row = cursor.row; - - let start_of_line = snapshot.clip_point(Point::new(row, 0), Bias::Left); - - let newline = "\n".to_string(); - edits.push((start_of_line..start_of_line, newline)); - - rows.push(row + rows_inserted as u32); - } - - self.transact(window, cx, |editor, window, cx| { - editor.edit(edits, cx); - - editor.change_selections(Default::default(), window, cx, |s| { - let mut index = 0; - s.move_cursors_with(&mut |map, _, _| { - let row = rows[index]; - index += 1; - - let point = Point::new(row, 0); - let boundary = map.next_line_boundary(point).1; - let clipped = map.clip_point(boundary, Bias::Left); - - (clipped, SelectionGoal::None) - }); - }); - - let mut indent_edits = Vec::new(); - let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - for row in rows { - let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); - for (row, indent) in indents { - if indent.len == 0 { - continue; - } - - let text = match indent.kind { - IndentKind::Space => " ".repeat(indent.len as usize), - IndentKind::Tab => "\t".repeat(indent.len as usize), - }; - let point = Point::new(row.0, 0); - indent_edits.push((point..point, text)); - } - } - editor.edit(indent_edits, cx); - if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { - format.detach_and_log_err(cx); - } - }); - } - - pub fn newline_below(&mut self, _: &NewlineBelow, window: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - - let mut buffer_edits: HashMap, Vec)> = HashMap::default(); - let mut rows = Vec::new(); - let mut rows_inserted = 0; - - for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { - let cursor = selection.head(); - let row = cursor.row; - - let point = Point::new(row, 0); - let Some((buffer_handle, buffer_point)) = - self.buffer.read(cx).point_to_buffer_point(point, cx) - else { - continue; - }; - - buffer_edits - .entry(buffer_handle.entity_id()) - .or_insert_with(|| (buffer_handle, Vec::new())) - .1 - .push(buffer_point); - - rows_inserted += 1; - rows.push(row + rows_inserted); - } - - self.transact(window, cx, |editor, window, cx| { - for (_, (buffer_handle, points)) in &buffer_edits { - buffer_handle.update(cx, |buffer, cx| { - let edits: Vec<_> = points - .iter() - .map(|point| { - let target = Point::new(point.row + 1, 0); - let start_of_line = buffer.point_to_offset(target).min(buffer.len()); - (start_of_line..start_of_line, "\n") - }) - .collect(); - buffer.edit(edits, None, cx); - }); - } - - editor.change_selections(Default::default(), window, cx, |s| { - let mut index = 0; - s.move_cursors_with(&mut |map, _, _| { - let row = rows[index]; - index += 1; - - let point = Point::new(row, 0); - let boundary = map.next_line_boundary(point).1; - let clipped = map.clip_point(boundary, Bias::Left); - - (clipped, SelectionGoal::None) - }); - }); - - let mut indent_edits = Vec::new(); - let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - for row in rows { - let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); - for (row, indent) in indents { - if indent.len == 0 { - continue; - } - - let text = match indent.kind { - IndentKind::Space => " ".repeat(indent.len as usize), - IndentKind::Tab => "\t".repeat(indent.len as usize), - }; - let point = Point::new(row.0, 0); - indent_edits.push((point..point, text)); - } - } - editor.edit(indent_edits, cx); - if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { - format.detach_and_log_err(cx); - } - }); - } - - pub fn insert(&mut self, text: &str, window: &mut Window, cx: &mut Context) { - let autoindent = text.is_empty().not().then(|| AutoindentMode::Block { - original_indent_columns: Vec::new(), - }); - self.replace_selections(text, autoindent, window, cx, false); - } - - /// Replaces the editor's selections with the provided `text`, applying the - /// given `autoindent_mode` (`None` will skip autoindentation). - /// - /// Early returns if the editor is in read-only mode, without applying any - /// edits. - fn replace_selections( - &mut self, - text: &str, - autoindent_mode: Option, - window: &mut Window, - cx: &mut Context, - apply_linked_edits: bool, - ) { - if self.read_only(cx) { - return; - } - - let text: Arc = text.into(); - self.transact(window, cx, |this, window, cx| { - let old_selections = this.selections.all_adjusted(&this.display_snapshot(cx)); - let linked_edits = if apply_linked_edits { - this.linked_edits_for_selections(text.clone(), cx) - } else { - LinkedEdits::new() - }; - - let selection_anchors = this.buffer.update(cx, |buffer, cx| { - let anchors = { - let snapshot = buffer.read(cx); - old_selections - .iter() - .map(|s| { - let anchor = snapshot.anchor_after(s.head()); - s.map(|_| anchor) - }) - .collect::>() - }; - buffer.edit( - old_selections - .iter() - .map(|s| (s.start..s.end, text.clone())), - autoindent_mode, - cx, - ); - anchors - }); - - linked_edits.apply(cx); - - this.change_selections(Default::default(), window, cx, |s| { - s.select_anchors(selection_anchors); - }); - - if apply_linked_edits { - refresh_linked_ranges(this, window, cx); - } - - cx.notify(); - }); - } - - /// Collects linked edits for the current selections, pairing each linked - /// range with `text`. - pub fn linked_edits_for_selections(&self, text: Arc, cx: &App) -> LinkedEdits { - let multibuffer_snapshot = self.buffer().read(cx).snapshot(cx); - let mut linked_edits = LinkedEdits::new(); - if !self.linked_edit_ranges.is_empty() { - for selection in self.selections.disjoint_anchors() { - let Some((_, range)) = - multibuffer_snapshot.anchor_range_to_buffer_anchor_range(selection.range()) - else { - continue; - }; - linked_edits.push(self, range, text.clone(), cx); - } - } - linked_edits - } - - /// Deletes the content covered by the current selections and applies - /// linked edits. - pub fn delete_selections_with_linked_edits( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - self.replace_selections("", None, window, cx, true); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn set_linked_edit_ranges_for_testing( - &mut self, - ranges: Vec<(Range, Vec>)>, - cx: &mut Context, - ) -> Option<()> { - let Some((buffer, _)) = self - .buffer - .read(cx) - .text_anchor_for_position(self.selections.newest_anchor().start, cx) - else { - return None; - }; - let buffer = buffer.read(cx); - let buffer_id = buffer.remote_id(); - let mut linked_ranges = Vec::with_capacity(ranges.len()); - for (base_range, linked_ranges_points) in ranges { - let base_anchor = - buffer.anchor_before(base_range.start)..buffer.anchor_after(base_range.end); - let linked_anchors = linked_ranges_points - .into_iter() - .map(|range| buffer.anchor_before(range.start)..buffer.anchor_after(range.end)) - .collect(); - linked_ranges.push((base_anchor, linked_anchors)); - } - let mut map = HashMap::default(); - map.insert(buffer_id, linked_ranges); - self.linked_edit_ranges = linked_editing_ranges::LinkedEditingRanges(map); - Some(()) - } - - fn trigger_completion_on_input( - &mut self, - text: &str, - trigger_in_words: bool, - window: &mut Window, - cx: &mut Context, - ) { - let completions_source = self - .context_menu - .borrow() - .as_ref() - .and_then(|menu| match menu { - CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), - CodeContextMenu::CodeActions(_) => None, - }); - - match completions_source { - Some(CompletionsMenuSource::Words { .. }) => { - self.open_or_update_completions_menu( - Some(CompletionsMenuSource::Words { - ignore_threshold: false, - }), - None, - trigger_in_words, - window, - cx, - ); - } - _ => self.open_or_update_completions_menu( - None, - Some(text.to_owned()).filter(|x| !x.is_empty()), - trigger_in_words, - window, - cx, - ), - } - } - - /// If any empty selections is touching the start of its innermost containing autoclose - /// region, expand it to select the brackets. - fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - let buffer = self.buffer.read(cx).read(cx); - let new_selections = self - .selections_with_autoclose_regions(selections, &buffer) - .map(|(mut selection, region)| { - if !selection.is_empty() { - return selection; - } - - if let Some(region) = region { - let mut range = region.range.to_offset(&buffer); - if selection.start == range.start && range.start.0 >= region.pair.start.len() { - range.start -= region.pair.start.len(); - if buffer.contains_str_at(range.start, ®ion.pair.start) - && buffer.contains_str_at(range.end, ®ion.pair.end) - { - range.end += region.pair.end.len(); - selection.start = range.start; - selection.end = range.end; - - return selection; - } - } - } - - let always_treat_brackets_as_autoclosed = buffer - .language_settings_at(selection.start, cx) - .always_treat_brackets_as_autoclosed; - - if !always_treat_brackets_as_autoclosed { - return selection; - } - - if let Some(scope) = buffer.language_scope_at(selection.start) { - for (pair, enabled) in scope.brackets() { - if !enabled || !pair.close { - continue; - } - - if buffer.contains_str_at(selection.start, &pair.end) { - let pair_start_len = pair.start.len(); - if buffer.contains_str_at( - selection.start.saturating_sub_usize(pair_start_len), - &pair.start, - ) { - selection.start -= pair_start_len; - selection.end += pair.end.len(); - - return selection; - } - } - } - } - - selection - }) - .collect(); - - drop(buffer); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select(new_selections) - }); - } - - /// Iterate the given selections, and for each one, find the smallest surrounding - /// autoclose region. This uses the ordering of the selections and the autoclose - /// regions to avoid repeated comparisons. - fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>( - &'a self, - selections: impl IntoIterator>, - buffer: &'a MultiBufferSnapshot, - ) -> impl Iterator, Option<&'a AutocloseRegion>)> { - let mut i = 0; - let mut regions = self.autoclose_regions.as_slice(); - selections.into_iter().map(move |selection| { - let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer); - - let mut enclosing = None; - while let Some(pair_state) = regions.get(i) { - if pair_state.range.end.to_offset(buffer) < range.start { - regions = ®ions[i + 1..]; - i = 0; - } else if pair_state.range.start.to_offset(buffer) > range.end { - break; - } else { - if pair_state.selection_id == selection.id { - enclosing = Some(pair_state); - } - i += 1; - } - } - - (selection, enclosing) - }) - } - - /// Remove any autoclose regions that no longer contain their selection or have invalid anchors in ranges. - fn invalidate_autoclose_regions( - &mut self, - mut selections: &[Selection], - buffer: &MultiBufferSnapshot, - ) { - self.autoclose_regions.retain(|state| { - if !state.range.start.is_valid(buffer) || !state.range.end.is_valid(buffer) { - return false; - } - - let mut i = 0; - while let Some(selection) = selections.get(i) { - if selection.end.cmp(&state.range.start, buffer).is_lt() { - selections = &selections[1..]; - continue; - } - if selection.start.cmp(&state.range.end, buffer).is_gt() { - break; - } - if selection.id == state.selection_id { - return true; - } else { - i += 1; - } - } - false - }); - } - - fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option { - let offset = position.to_offset(buffer); - let (word_range, kind) = - buffer.surrounding_word(offset, Some(CharScopeContext::Completion)); - if offset > word_range.start && kind == Some(CharKind::Word) { - Some( - buffer - .text_for_range(word_range.start..offset) - .collect::(), - ) - } else { - None - } - } - - pub fn is_lsp_relevant(&self, file: Option<&Arc>, cx: &App) -> bool { - let Some(project) = self.project() else { - return false; - }; - let Some(buffer_file) = project::File::from_dyn(file) else { - return false; - }; - let Some(entry_id) = buffer_file.project_entry_id() else { - return false; - }; - let project = project.read(cx); - let Some(buffer_worktree) = project.worktree_for_id(buffer_file.worktree_id(cx), cx) else { - return false; - }; - let Some(worktree_entry) = buffer_worktree.read(cx).entry_for_id(entry_id) else { - return false; - }; - !worktree_entry.is_ignored - } - - pub fn visible_buffers(&self, cx: &mut Context) -> Vec> { - let display_snapshot = self.display_snapshot(cx); - let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); - let multi_buffer = self.buffer().read(cx); - display_snapshot - .buffer_snapshot() - .range_to_buffer_ranges(visible_range) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) - .filter_map(|(buffer_snapshot, _, _)| multi_buffer.buffer(buffer_snapshot.remote_id())) - .collect() - } - - pub fn visible_buffer_ranges( - &self, - cx: &mut Context, - ) -> Vec<( - BufferSnapshot, - Range, - ExcerptRange, - )> { - let display_snapshot = self.display_snapshot(cx); - let visible_range = self.multi_buffer_visible_range(&display_snapshot, cx); - display_snapshot - .buffer_snapshot() - .range_to_buffer_ranges(visible_range) - .into_iter() - .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) - .collect() - } - - pub fn text_layout_details(&self, window: &mut Window, cx: &mut App) -> TextLayoutDetails { - TextLayoutDetails { - text_system: window.text_system().clone(), - editor_style: self.style.clone().unwrap(), - rem_size: window.rem_size(), - scroll_anchor: self.scroll_manager.shared_scroll_anchor(cx), - visible_rows: self.visible_line_count(), - vertical_scroll_margin: self.scroll_manager.vertical_scroll_margin, - } - } - - fn trigger_on_type_formatting( - &self, - input: String, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if input.chars().count() != 1 { - return None; - } - - let project = self.project()?; - let position = self.selections.newest_anchor().head(); - let (buffer, buffer_position) = self - .buffer - .read(cx) - .text_anchor_for_position(position, cx)?; - - let settings = LanguageSettings::for_buffer_at(&buffer.read(cx), buffer_position, cx); - if !settings.use_on_type_format { - return None; - } - - // OnTypeFormatting returns a list of edits, no need to pass them between Zed instances, - // hence we do LSP request & edit on host side only — add formats to host's history. - let push_to_lsp_host_history = true; - // If this is not the host, append its history with new edits. - let push_to_client_history = project.read(cx).is_via_collab(); - - let on_type_formatting = project.update(cx, |project, cx| { - project.on_type_format( - buffer.clone(), - buffer_position, - input, - push_to_lsp_host_history, - cx, - ) - }); - Some(cx.spawn_in(window, async move |editor, cx| { - if let Some(transaction) = on_type_formatting.await? { - if push_to_client_history { - buffer.update(cx, |buffer, _| { - buffer.push_transaction(transaction, Instant::now()); - buffer.finalize_last_transaction(); - }); - } - editor.update(cx, |editor, cx| { - editor.refresh_document_highlights(cx); - })?; - } - Ok(()) - })) - } - - pub fn show_word_completions( - &mut self, - _: &ShowWordCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_or_update_completions_menu( - Some(CompletionsMenuSource::Words { - ignore_threshold: true, - }), - None, - false, - window, - cx, - ); - } - - pub fn show_completions( - &mut self, - _: &ShowCompletions, - window: &mut Window, - cx: &mut Context, - ) { - self.open_or_update_completions_menu(None, None, false, window, cx); - } - - fn open_or_update_completions_menu( - &mut self, - requested_source: Option, - trigger: Option, - trigger_in_words: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.pending_rename.is_some() { - return; - } - - let completions_source = self - .context_menu - .borrow() - .as_ref() - .and_then(|menu| match menu { - CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source), - CodeContextMenu::CodeActions(_) => None, - }); - - let multibuffer_snapshot = self.buffer.read(cx).read(cx); - - // Typically `start` == `end`, but with snippet tabstop choices the default choice is - // inserted and selected. To handle that case, the start of the selection is used so that - // the menu starts with all choices. - let position = self - .selections - .newest_anchor() - .start - .bias_right(&multibuffer_snapshot); - - if position.diff_base_anchor().is_some() { - return; - } - let multibuffer_position = multibuffer_snapshot.anchor_before(position); - let Some((buffer_position, _)) = - multibuffer_snapshot.anchor_to_buffer_anchor(multibuffer_position) - else { - return; - }; - let Some(buffer) = self.buffer.read(cx).buffer(buffer_position.buffer_id) else { - return; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - - let menu_is_open = matches!( - self.context_menu.borrow().as_ref(), - Some(CodeContextMenu::Completions(_)) - ); - - let language = buffer_snapshot - .language_at(buffer_position) - .map(|language| language.name()); - let language_settings = multibuffer_snapshot.language_settings_at(multibuffer_position, cx); - let completion_settings = language_settings.completions.clone(); - - let show_completions_on_input = self - .show_completions_on_input_override - .unwrap_or(language_settings.show_completions_on_input); - if !menu_is_open && trigger.is_some() && !show_completions_on_input { - return; - } - - let query: Option> = - Self::completion_query(&multibuffer_snapshot, multibuffer_position) - .map(|query| query.into()); - - drop(multibuffer_snapshot); - - // Hide the current completions menu when query is empty. Without this, cached - // completions from before the trigger char may be reused (#32774). - if query.is_none() && menu_is_open { - self.hide_context_menu(window, cx); - } - - let mut ignore_word_threshold = false; - let provider = match requested_source { - Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(), - Some(CompletionsMenuSource::Words { ignore_threshold }) => { - ignore_word_threshold = ignore_threshold; - None - } - Some(CompletionsMenuSource::SnippetChoices) - | Some(CompletionsMenuSource::SnippetsOnly) => { - log::error!("bug: SnippetChoices requested_source is not handled"); - None - } - }; - - let sort_completions = provider - .as_ref() - .is_some_and(|provider| provider.sort_completions()); - - let filter_completions = provider - .as_ref() - .is_none_or(|provider| provider.filter_completions()); - - let was_snippets_only = matches!( - completions_source, - Some(CompletionsMenuSource::SnippetsOnly) - ); - - if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() { - if filter_completions { - menu.filter( - query.clone().unwrap_or_default(), - buffer_position, - &buffer, - provider.clone(), - window, - cx, - ); - } - // When `is_incomplete` is false, no need to re-query completions when the current query - // is a suffix of the initial query. - let was_complete = !menu.is_incomplete; - if was_complete && !was_snippets_only { - // If the new query is a suffix of the old query (typing more characters) and - // the previous result was complete, the existing completions can be filtered. - // - // Note that snippet completions are always complete. - let query_matches = match (&menu.initial_query, &query) { - (Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()), - (None, _) => true, - _ => false, - }; - if query_matches { - let position_matches = if menu.initial_position == position { - true - } else { - let snapshot = self.buffer.read(cx).read(cx); - menu.initial_position.to_offset(&snapshot) == position.to_offset(&snapshot) - }; - if position_matches { - return; - } - } - } - }; - - let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) = - buffer_snapshot.surrounding_word(buffer_position, None) - { - let word_to_exclude = buffer_snapshot - .text_for_range(word_range.clone()) - .collect::(); - ( - buffer_snapshot.anchor_before(word_range.start) - ..buffer_snapshot.anchor_after(buffer_position), - Some(word_to_exclude), - ) - } else { - (buffer_position..buffer_position, None) - }; - - let show_completion_documentation = buffer_snapshot - .settings_at(buffer_position, cx) - .show_completion_documentation; - - // The document can be large, so stay in reasonable bounds when searching for words, - // otherwise completion pop-up might be slow to appear. - const WORD_LOOKUP_ROWS: u32 = 5_000; - let buffer_row = text::ToPoint::to_point(&buffer_position, &buffer_snapshot).row; - let min_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row.saturating_sub(WORD_LOOKUP_ROWS), 0), - Bias::Left, - ); - let max_word_search = buffer_snapshot.clip_point( - Point::new(buffer_row + WORD_LOOKUP_ROWS, 0).min(buffer_snapshot.max_point()), - Bias::Right, - ); - let word_search_range = buffer_snapshot.point_to_offset(min_word_search) - ..buffer_snapshot.point_to_offset(max_word_search); - - let skip_digits = query - .as_ref() - .is_none_or(|query| !query.chars().any(|c| c.is_digit(10))); - - let load_provider_completions = provider.as_ref().is_some_and(|provider| { - trigger.as_ref().is_none_or(|trigger| { - provider.is_completion_trigger( - &buffer, - buffer_position, - trigger, - trigger_in_words, - cx, - ) - }) - }); - - let provider_responses = if let Some(provider) = &provider - && load_provider_completions - { - let trigger_character = trigger - .as_ref() - .filter(|trigger| { - buffer - .read(cx) - .completion_triggers() - .contains(trigger.as_str()) - }) - .cloned(); - let completion_context = CompletionContext { - trigger_kind: match &trigger_character { - Some(_) => CompletionTriggerKind::TRIGGER_CHARACTER, - None => CompletionTriggerKind::INVOKED, - }, - trigger_character, - }; - - provider.completions(&buffer, buffer_position, completion_context, window, cx) - } else { - Task::ready(Ok(Vec::new())) - }; - - let load_word_completions = if !self.word_completions_enabled { - false - } else if requested_source - == Some(CompletionsMenuSource::Words { - ignore_threshold: true, - }) - { - true - } else { - load_provider_completions - && completion_settings.words != WordsCompletionMode::Disabled - && (ignore_word_threshold || { - let words_min_length = completion_settings.words_min_length; - // check whether word has at least `words_min_length` characters - let query_chars = query.iter().flat_map(|q| q.chars()); - query_chars.take(words_min_length).count() == words_min_length - }) - }; - - let mut words = if load_word_completions { - cx.background_spawn({ - let buffer_snapshot = buffer_snapshot.clone(); - async move { - buffer_snapshot.words_in_range(WordsQuery { - fuzzy_contents: None, - range: word_search_range, - skip_digits, - }) - } - }) - } else { - Task::ready(BTreeMap::default()) - }; - - let snippet_char_classifier = buffer_snapshot - .char_classifier_at(buffer_position) - .scope_context(Some(CharScopeContext::Completion)); - - let snippets = if let Some(provider) = &provider - && provider.show_snippets() - && let Some(project) = self.project() - { - let word_trigger = trigger.as_ref().is_some_and(|trigger| { - !trigger.is_empty() - && trigger - .chars() - .all(|character| snippet_char_classifier.is_word(character)) - }); - let requires_strong_snippet_match = !menu_is_open && !trigger_in_words && word_trigger; - let load_snippet_completions = !requires_strong_snippet_match - || query.as_ref().is_some_and(|query| { - let project = project.read(cx); - has_strong_snippet_prefix_match( - &project, - &buffer, - buffer_position, - &snippet_char_classifier, - query, - cx, - ) - }); - - if load_snippet_completions { - project.update(cx, |project, cx| { - snippet_completions( - project, - &buffer, - buffer_position, - snippet_char_classifier, - cx, - ) - }) - } else { - Task::ready(Ok(CompletionResponse { - completions: Vec::new(), - display_options: Default::default(), - is_incomplete: false, - })) - } - } else { - Task::ready(Ok(CompletionResponse { - completions: Vec::new(), - display_options: Default::default(), - is_incomplete: false, - })) - }; - - let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - - let id = post_inc(&mut self.next_completion_id); - let task = cx.spawn_in(window, async move |editor, cx| { - let Ok(()) = editor.update(cx, |this, _| { - this.completion_tasks.retain(|(task_id, _)| *task_id >= id); - }) else { - return; - }; - - // TODO: Ideally completions from different sources would be selectively re-queried, so - // that having one source with `is_incomplete: true` doesn't cause all to be re-queried. - let mut completions = Vec::new(); - let mut is_incomplete = false; - let mut display_options: Option = None; - if let Some(provider_responses) = provider_responses.await.log_err() - && !provider_responses.is_empty() - { - for response in provider_responses { - completions.extend(response.completions); - is_incomplete = is_incomplete || response.is_incomplete; - match display_options.as_mut() { - None => { - display_options = Some(response.display_options); - } - Some(options) => options.merge(&response.display_options), - } - } - if completion_settings.words == WordsCompletionMode::Fallback { - words = Task::ready(BTreeMap::default()); - } - } - let display_options = display_options.unwrap_or_default(); - - let mut words = words.await; - if let Some(word_to_exclude) = &word_to_exclude { - words.remove(word_to_exclude); - } - for lsp_completion in &completions { - words.remove(&lsp_completion.new_text); - } - completions.extend(words.into_iter().map(|(word, word_range)| Completion { - replace_range: word_replace_range.clone(), - new_text: word.clone(), - label: CodeLabel::plain(word, None), - match_start: None, - snippet_deduplication_key: None, - icon_path: None, - documentation: None, - source: CompletionSource::BufferWord { - word_range, - resolved: false, - }, - insert_text_mode: Some(InsertTextMode::AS_IS), - confirm: None, - })); - - completions.extend( - snippets - .await - .into_iter() - .flat_map(|response| response.completions), - ); - - let menu = if completions.is_empty() { - None - } else { - let Ok((mut menu, matches_task)) = editor.update(cx, |editor, cx| { - let languages = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade()) - .map(|workspace| workspace.read(cx).app_state().languages.clone()); - let menu = CompletionsMenu::new( - id, - requested_source.unwrap_or(if load_provider_completions { - CompletionsMenuSource::Normal - } else { - CompletionsMenuSource::SnippetsOnly - }), - sort_completions, - show_completion_documentation, - position, - query.clone(), - is_incomplete, - buffer.clone(), - completions.into(), - editor - .context_menu() - .borrow_mut() - .as_ref() - .map(|menu| menu.primary_scroll_handle()), - display_options, - snippet_sort_order, - languages, - language, - cx, - ); - - let query = if filter_completions { query } else { None }; - let matches_task = menu.do_async_filtering( - query.unwrap_or_default(), - buffer_position, - &buffer, - cx, - ); - (menu, matches_task) - }) else { - return; - }; - - let matches = matches_task.await; - - let Ok(()) = editor.update_in(cx, |editor, window, cx| { - // Newer menu already set, so exit. - if let Some(CodeContextMenu::Completions(prev_menu)) = - editor.context_menu.borrow().as_ref() - && prev_menu.id > id - { - return; - }; - - // Only valid to take prev_menu because either the new menu is immediately set - // below, or the menu is hidden. - if let Some(CodeContextMenu::Completions(prev_menu)) = - editor.context_menu.borrow_mut().take() - { - let position_matches = - if prev_menu.initial_position == menu.initial_position { - true - } else { - let snapshot = editor.buffer.read(cx).read(cx); - prev_menu.initial_position.to_offset(&snapshot) - == menu.initial_position.to_offset(&snapshot) - }; - if position_matches { - // Preserve markdown cache before `set_filter_results` because it will - // try to populate the documentation cache. - menu.preserve_markdown_cache(prev_menu); - } - }; - - menu.set_filter_results(matches, provider, window, cx); - }) else { - return; - }; - - menu.visible().then_some(menu) - }; - - editor - .update_in(cx, |editor, window, cx| { - if editor.focus_handle.is_focused(window) - && let Some(menu) = menu - { - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::Completions(menu)); - - crate::hover_popover::hide_hover(editor, cx); - if editor.show_edit_predictions_in_menu() { - editor.update_visible_edit_prediction(window, cx); - } else { - editor - .discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); - } - - cx.notify(); - return; - } - - if editor.completion_tasks.len() <= 1 { - // If there are no more completion tasks and the last menu was empty, we should hide it. - let was_hidden = editor.hide_context_menu(window, cx).is_none(); - // If it was already hidden and we don't show edit predictions in the menu, - // we should also show the edit prediction when available. - if was_hidden && editor.show_edit_predictions_in_menu() { - editor.update_visible_edit_prediction(window, cx); - } - } - }) - .ok(); - }); - - self.completion_tasks.push((id, task)); - } - - #[cfg(any(test, feature = "test-support"))] - pub fn current_completions(&self) -> Option> { - let menu = self.context_menu.borrow(); - if let CodeContextMenu::Completions(menu) = menu.as_ref()? { - let completions = menu.completions.borrow(); - Some(completions.to_vec()) - } else { - None - } - } - - pub fn with_completions_menu_matching_id( - &self, - id: CompletionId, - f: impl FnOnce(Option<&mut CompletionsMenu>) -> R, - ) -> R { - let mut context_menu = self.context_menu.borrow_mut(); - let Some(CodeContextMenu::Completions(completions_menu)) = &mut *context_menu else { - return f(None); - }; - if completions_menu.id != id { - return f(None); - } - f(Some(completions_menu)) - } - - pub fn confirm_completion( - &mut self, - action: &ConfirmCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(action.item_ix, CompletionIntent::Complete, window, cx) - } - - pub fn confirm_completion_insert( - &mut self, - _: &ConfirmCompletionInsert, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(None, CompletionIntent::CompleteWithInsert, window, cx) - } - - pub fn confirm_completion_replace( - &mut self, - _: &ConfirmCompletionReplace, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - self.do_completion(None, CompletionIntent::CompleteWithReplace, window, cx) - } - - pub fn compose_completion( - &mut self, - action: &ComposeCompletion, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - self.do_completion(action.item_ix, CompletionIntent::Compose, window, cx) - } - - fn do_completion( - &mut self, - item_ix: Option, - intent: CompletionIntent, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - use language::ToOffset as _; - - let CodeContextMenu::Completions(completions_menu) = self.hide_context_menu(window, cx)? - else { - return None; - }; - - let candidate_id = { - let entries = completions_menu.entries.borrow(); - let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?; - if self.show_edit_predictions_in_menu() { - self.discard_edit_prediction(EditPredictionDiscardReason::Rejected, cx); - } - mat.candidate_id - }; - - let completion = completions_menu - .completions - .borrow() - .get(candidate_id)? - .clone(); - cx.stop_propagation(); - - let buffer_handle = completions_menu.buffer.clone(); - let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); - let (initial_position, _) = - multibuffer_snapshot.anchor_to_buffer_anchor(completions_menu.initial_position)?; - - let CompletionEdit { - new_text, - snippet, - replace_range, - } = process_completion_for_edit(&completion, intent, &buffer_handle, &initial_position, cx); - - let buffer = buffer_handle.read(cx).snapshot(); - let newest_selection = self.selections.newest_anchor(); - - let Some(replace_range_multibuffer) = - multibuffer_snapshot.buffer_anchor_range_to_anchor_range(replace_range.clone()) - else { - return None; - }; - - let Some((buffer_snapshot, newest_range_buffer)) = - multibuffer_snapshot.anchor_range_to_buffer_anchor_range(newest_selection.range()) - else { - return None; - }; - - let old_text = buffer - .text_for_range(replace_range.clone()) - .collect::(); - let lookbehind = newest_range_buffer - .start - .to_offset(buffer_snapshot) - .saturating_sub(replace_range.start.to_offset(&buffer_snapshot)); - let lookahead = replace_range - .end - .to_offset(&buffer_snapshot) - .saturating_sub(newest_range_buffer.end.to_offset(&buffer)); - let prefix = &old_text[..old_text.len().saturating_sub(lookahead)]; - let suffix = &old_text[lookbehind.min(old_text.len())..]; - - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - let mut ranges = Vec::new(); - let mut all_commit_ranges = Vec::new(); - let mut linked_edits = LinkedEdits::new(); - - let text: Arc = new_text.clone().into(); - for selection in &selections { - let range = if selection.id == newest_selection.id { - replace_range_multibuffer.clone() - } else { - let mut range = selection.range(); - - // if prefix is present, don't duplicate it - if multibuffer_snapshot - .contains_str_at(range.start.saturating_sub_usize(lookbehind), prefix) - { - range.start = range.start.saturating_sub_usize(lookbehind); - - // if suffix is also present, mimic the newest cursor and replace it - if selection.id != newest_selection.id - && multibuffer_snapshot.contains_str_at(range.end, suffix) - { - range.end += lookahead; - } - } - range.to_anchors(&multibuffer_snapshot) - }; - - ranges.push(range.clone()); - - let start_anchor = multibuffer_snapshot.anchor_before(range.start); - let end_anchor = multibuffer_snapshot.anchor_after(range.end); - - if let Some((buffer_snapshot_2, anchor_range)) = - multibuffer_snapshot.anchor_range_to_buffer_anchor_range(start_anchor..end_anchor) - && buffer_snapshot_2.remote_id() == buffer_snapshot.remote_id() - { - all_commit_ranges.push(anchor_range.clone()); - if !self.linked_edit_ranges.is_empty() { - linked_edits.push(&self, anchor_range, text.clone(), cx); - } - } - } - - let common_prefix_len = old_text - .chars() - .zip(new_text.chars()) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a.len_utf8()) - .sum::(); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: new_text[common_prefix_len..].into(), - }); - - let tx_id = self.transact(window, cx, |editor, window, cx| { - if let Some(mut snippet) = snippet { - snippet.text = new_text.to_string(); - let offset_ranges = ranges - .iter() - .map(|range| range.to_offset(&multibuffer_snapshot)) - .collect::>(); - editor - .insert_snippet(&offset_ranges, snippet, window, cx) - .log_err(); - } else { - editor.buffer.update(cx, |multi_buffer, cx| { - let auto_indent = match completion.insert_text_mode { - Some(InsertTextMode::AS_IS) => None, - _ => editor.autoindent_mode.clone(), - }; - let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); - multi_buffer.edit(edits, auto_indent, cx); - }); - } - linked_edits.apply(cx); - editor.refresh_edit_prediction(true, false, window, cx); - }); - self.invalidate_autoclose_regions( - &self.selections.disjoint_anchors_arc(), - &multibuffer_snapshot, - ); - - let show_new_completions_on_confirm = completion - .confirm - .as_ref() - .is_some_and(|confirm| confirm(intent, window, cx)); - if show_new_completions_on_confirm { - self.open_or_update_completions_menu(None, None, false, window, cx); - } - - let provider = self.completion_provider.as_ref()?; - - let lsp_store = self.project().map(|project| project.read(cx).lsp_store()); - let command = lsp_store.as_ref().and_then(|lsp_store| { - let CompletionSource::Lsp { - lsp_completion, - server_id, - .. - } = &completion.source - else { - return None; - }; - let lsp_command = lsp_completion.command.as_ref()?; - let available_commands = lsp_store - .read(cx) - .lsp_server_capabilities - .get(server_id) - .and_then(|server_capabilities| { - server_capabilities - .execute_command_provider - .as_ref() - .map(|options| options.commands.as_slice()) - })?; - if available_commands.contains(&lsp_command.command) { - Some(CodeAction { - server_id: *server_id, - range: language::Anchor::min_min_range_for_buffer(buffer.remote_id()), - lsp_action: LspAction::Command(lsp_command.clone()), - resolved: false, - }) - } else { - None - } - }); - - drop(completion); - let apply_edits = provider.apply_additional_edits_for_completion( - buffer_handle.clone(), - completions_menu.completions.clone(), - candidate_id, - true, - all_commit_ranges, - cx, - ); - - let editor_settings = EditorSettings::get_global(cx); - if editor_settings.show_signature_help_after_edits || editor_settings.auto_signature_help { - // After the code completion is finished, users often want to know what signatures are needed. - // so we should automatically call signature_help - self.show_signature_help(&ShowSignatureHelp, window, cx); - } - - Some(cx.spawn_in(window, async move |editor, cx| { - let additional_edits_tx = apply_edits.await?; - - if let Some((lsp_store, command)) = lsp_store.zip(command) { - let title = command.lsp_action.title().to_owned(); - let project_transaction = lsp_store - .update(cx, |lsp_store, cx| { - lsp_store.apply_code_action(buffer_handle, command, false, cx) - }) - .await - .context("applying post-completion command")?; - if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? { - Self::open_project_transaction( - &editor, - workspace.downgrade(), - project_transaction, - title, - cx, - ) - .await?; - } - } - - if let Some(tx_id) = tx_id - && let Some(additional_edits_tx) = additional_edits_tx - { - editor - .update(cx, |editor, cx| { - editor.buffer.update(cx, |buffer, cx| { - buffer.merge_transactions(additional_edits_tx.id, tx_id, cx) - }); - }) - .context("merge transactions")?; - } - - Ok(()) - })) - } - - /// Toggles an action selection menu for the latest selection. - /// May show LSP code actions, code lens' command, runnables and potentially more entities applicable as actions. - /// Previous menu toggled with this method will be closed. - pub fn toggle_code_actions( - &mut self, - action: &ToggleCodeActions, - window: &mut Window, - cx: &mut Context, - ) { - let quick_launch = action.quick_launch; - let mut context_menu = self.context_menu.borrow_mut(); - if let Some(CodeContextMenu::CodeActions(code_actions)) = context_menu.as_ref() { - if code_actions.deployed_from == action.deployed_from { - // Toggle if we're selecting the same one - *context_menu = None; - cx.notify(); - return; - } else { - // Otherwise, clear it and start a new one - *context_menu = None; - cx.notify(); - } - } - drop(context_menu); - let snapshot = self.snapshot(window, cx); - let deployed_from = action.deployed_from.clone(); - let action = action.clone(); - self.completion_tasks.clear(); - self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); - - let multibuffer_point = match &action.deployed_from { - Some(CodeActionSource::Indicator(row)) | Some(CodeActionSource::RunMenu(row)) => { - DisplayPoint::new(*row, 0).to_point(&snapshot) - } - _ => self - .selections - .newest::(&snapshot.display_snapshot) - .head(), - }; - let Some((buffer, buffer_row)) = snapshot - .buffer_snapshot() - .buffer_line_for_row(MultiBufferRow(multibuffer_point.row)) - .and_then(|(buffer_snapshot, range)| { - self.buffer() - .read(cx) - .buffer(buffer_snapshot.remote_id()) - .map(|buffer| (buffer, range.start.row)) - }) - else { - return; - }; - let buffer_id = buffer.read(cx).remote_id(); - let tasks = self - .runnables - .runnables((buffer_id, buffer_row)) - .map(|t| Arc::new(t.to_owned())); - - let project = self.project.clone(); - let runnable_task = match deployed_from { - Some(CodeActionSource::Indicator(_)) => Task::ready(Ok(Default::default())), - _ => { - let mut task_context_task = Task::ready(Ok(None)); - let workspace = self.workspace().map(|w| w.downgrade()); - if let Some(tasks) = &tasks - && let Some(project) = project - { - task_context_task = - Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx); - } - - cx.spawn_in(window, { - let buffer = buffer.clone(); - async move |editor, cx| { - let task_context = match workspace { - Some(ws) => task_context_task - .await - .notify_workspace_async_err(ws, cx) - .flatten(), - None => task_context_task.await.ok().flatten(), - }; - - let resolved_tasks = - tasks - .zip(task_context.clone()) - .map(|(tasks, task_context)| ResolvedTasks { - templates: tasks.resolve(&task_context).collect(), - position: snapshot.buffer_snapshot().anchor_before(Point::new( - multibuffer_point.row, - tasks.column, - )), - }); - let debug_scenarios = editor - .update(cx, |editor, cx| { - editor.debug_scenarios(&resolved_tasks, &buffer, cx) - })? - .await; - anyhow::Ok((resolved_tasks, debug_scenarios, task_context)) - } - }) - } - }; - - let toggle_task = cx.spawn_in(window, async move |editor, cx| { - let (resolved_tasks, debug_scenarios, task_context) = runnable_task.await?; - - let code_actions = if let Some(CodeActionSource::RunMenu(_)) = &deployed_from { - None - } else { - editor.update(cx, |editor, _cx| match &editor.code_actions_for_selection { - CodeActionsForSelection::None => None, - CodeActionsForSelection::Fetching(task) => Some(task.clone()), - CodeActionsForSelection::Ready(action_fetch_ready) => { - Some(Task::ready(Some(action_fetch_ready.clone())).shared()) - } - })? - }; - let code_actions = match code_actions { - Some(code_actions) => code_actions - .await - .filter(|ActionFetchReady { location, .. }| { - let snapshot = location.buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let point_range = location.range.to_point(&snapshot); - (point_range.start.row..=point_range.end.row).contains(&buffer_row) - }) - .map(|ActionFetchReady { actions, .. }| actions), - None => None, - }; - - editor.update_in(cx, |editor, window, cx| { - let spawn_straight_away = quick_launch - && resolved_tasks - .as_ref() - .is_some_and(|tasks| tasks.templates.len() == 1) - && code_actions - .as_ref() - .is_none_or(|actions| actions.is_empty()) - && debug_scenarios.is_empty(); - - crate::hover_popover::hide_hover(editor, cx); - let actions = CodeActionContents::new( - resolved_tasks, - code_actions, - debug_scenarios, - task_context.unwrap_or_default(), - ); - - // Don't show the menu if there are no actions available - if actions.is_empty() { - cx.notify(); - return Task::ready(Ok(())); - } - - *editor.context_menu.borrow_mut() = - Some(CodeContextMenu::CodeActions(CodeActionsMenu { - buffer, - actions, - selected_item: Default::default(), - scroll_handle: UniformListScrollHandle::default(), - deployed_from, - })); - cx.notify(); - if spawn_straight_away - && let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { item_ix: Some(0) }, - window, - cx, - ) - { - return task; - } - - Task::ready(Ok(())) - }) - }); - self.runnables_for_selection_toggle = cx.background_spawn(async move { - match toggle_task.await { - Ok(code_action_spawn) => match code_action_spawn.await { - Ok(()) => {} - Err(e) => log::error!("failed to spawn a toggled code action: {e:#}"), - }, - Err(e) => log::error!("failed to toggle code actions: {e:#}"), - } - }) - } - - fn debug_scenarios( - &mut self, - resolved_tasks: &Option, - buffer: &Entity, - cx: &mut App, - ) -> Task> { - maybe!({ - let project = self.project()?; - let dap_store = project.read(cx).dap_store(); - let mut scenarios = vec![]; - let resolved_tasks = resolved_tasks.as_ref()?; - let buffer = buffer.read(cx); - let language = buffer.language()?; - let debug_adapter = LanguageSettings::for_buffer(&buffer, cx) - .debuggers - .first() - .map(SharedString::from) - .or_else(|| language.config().debuggers.first().map(SharedString::from))?; - - dap_store.update(cx, |dap_store, cx| { - for (_, task) in &resolved_tasks.templates { - let maybe_scenario = dap_store.debug_scenario_for_build_task( - task.original_task().clone(), - debug_adapter.clone().into(), - task.display_label().to_owned().into(), - cx, - ); - scenarios.push(maybe_scenario); - } - }); - Some(cx.background_spawn(async move { - futures::future::join_all(scenarios) - .await - .into_iter() - .flatten() - .collect::>() - })) - }) - .unwrap_or_else(|| Task::ready(vec![])) - } - - pub fn confirm_code_action( - &mut self, - action: &ConfirmCodeAction, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - if self.read_only(cx) { - return None; - } - - let actions_menu = - if let CodeContextMenu::CodeActions(menu) = self.hide_context_menu(window, cx)? { - menu - } else { - return None; - }; - - let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item); - let action = actions_menu.actions.get(action_ix)?; - let title = action.label(); - let buffer = actions_menu.buffer; - let workspace = self.workspace()?; - - match action { - CodeActionsItem::Task(task_source_kind, resolved_task) => { - workspace.update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - - Some(Task::ready(Ok(()))) - }) - } - CodeActionsItem::CodeAction { action, provider } => { - if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { - return Some(Task::ready(Ok(()))); - } - - let apply_code_action = - provider.apply_code_action(buffer, action, true, window, cx); - let workspace = workspace.downgrade(); - Some(cx.spawn_in(window, async move |editor, cx| { - let project_transaction = apply_code_action.await?; - Self::open_project_transaction( - &editor, - workspace, - project_transaction, - title, - cx, - ) - .await - })) - } - CodeActionsItem::DebugScenario(scenario) => { - let context = actions_menu.actions.context.into(); - - workspace.update(cx, |workspace, cx| { - dap::send_telemetry(&scenario, TelemetrySpawnLocation::Gutter, cx); - workspace.start_debug_session( - scenario, - context, - Some(buffer), - None, - window, - cx, - ); - }); - Some(Task::ready(Ok(()))) - } - } - } - fn open_transaction_for_hidden_buffers( workspace: Entity, transaction: ProjectTransaction, @@ -7328,181 +3820,6 @@ impl Editor { Ok(()) } - pub fn add_code_action_provider( - &mut self, - provider: Rc, - window: &mut Window, - cx: &mut Context, - ) { - if self - .code_action_providers - .iter() - .any(|existing_provider| existing_provider.id() == provider.id()) - { - return; - } - - self.code_action_providers.push(provider); - self.refresh_code_actions_for_selection(window, cx); - } - - pub fn remove_code_action_provider( - &mut self, - id: Arc, - window: &mut Window, - cx: &mut Context, - ) { - self.code_action_providers - .retain(|provider| provider.id() != id); - self.refresh_code_actions_for_selection(window, cx); - } - - pub fn code_actions_enabled_for_toolbar(&self, cx: &App) -> bool { - !self.code_action_providers.is_empty() - && EditorSettings::get_global(cx).toolbar.code_actions - } - - pub fn has_available_code_actions_for_selection(&self) -> bool { - if let CodeActionsForSelection::Ready(ready) = &self.code_actions_for_selection { - !ready.actions.is_empty() - } else { - false - } - } - - fn render_inline_code_actions( - &self, - icon_size: ui::IconSize, - display_row: DisplayRow, - is_active: bool, - cx: &mut Context, - ) -> AnyElement { - let show_tooltip = !self.context_menu_visible(); - IconButton::new("inline_code_actions", ui::IconName::BoltFilled) - .icon_size(icon_size) - .shape(ui::IconButtonShape::Square) - .icon_color(ui::Color::Hidden) - .toggle_state(is_active) - .when(show_tooltip, |this| { - this.tooltip({ - let focus_handle = self.focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Code Actions", - &ToggleCodeActions { - deployed_from: None, - quick_launch: false, - }, - &focus_handle, - cx, - ) - } - }) - }) - .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { - window.focus(&editor.focus_handle(cx), cx); - editor.toggle_code_actions( - &crate::actions::ToggleCodeActions { - deployed_from: Some(crate::actions::CodeActionSource::Indicator( - display_row, - )), - quick_launch: false, - }, - window, - cx, - ); - })) - .into_any_element() - } - - pub fn context_menu(&self) -> &RefCell> { - &self.context_menu - } - - fn refresh_code_actions_for_selection(&mut self, window: &mut Window, cx: &mut Context) { - self.code_actions_for_selection = CodeActionsForSelection::Fetching( - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor() - .timer(CODE_ACTIONS_DEBOUNCE_TIMEOUT) - .await; - - let (start_buffer, start, _, end, _newest_selection) = editor - .update(cx, |editor, cx| { - let newest_selection = editor.selections.newest_anchor().clone(); - if newest_selection.head().diff_base_anchor().is_some() { - return None; - } - let display_snapshot = editor.display_snapshot(cx); - let newest_selection_adjusted = - editor.selections.newest_adjusted(&display_snapshot); - let buffer = editor.buffer.read(cx); - - let (start_buffer, start) = - buffer.text_anchor_for_position(newest_selection_adjusted.start, cx)?; - let (end_buffer, end) = - buffer.text_anchor_for_position(newest_selection_adjusted.end, cx)?; - - Some((start_buffer, start, end_buffer, end, newest_selection)) - }) - .ok() - .flatten() - .filter(|(start_buffer, _, end_buffer, _, _)| start_buffer == end_buffer)?; - - let (providers, tasks) = editor - .update_in(cx, |editor, window, cx| { - let providers = editor.code_action_providers.clone(); - let tasks = editor - .code_action_providers - .iter() - .map(|provider| { - provider.code_actions(&start_buffer, start..end, window, cx) - }) - .collect::>(); - (providers, tasks) - }) - .ok()?; - - let mut actions = Vec::new(); - for (provider, provider_actions) in - providers.into_iter().zip(future::join_all(tasks).await) - { - if let Some(provider_actions) = provider_actions.log_err() { - actions.extend(provider_actions.into_iter().map(|action| { - AvailableCodeAction { - action, - provider: provider.clone(), - } - })); - } - } - - editor - .update(cx, |editor, cx| { - let new_actions = if actions.is_empty() { - editor.code_actions_for_selection = CodeActionsForSelection::None; - None - } else { - let new_actions = ActionFetchReady { - location: Location { - buffer: start_buffer, - range: start..end, - }, - actions: Rc::from(actions), - }; - editor.code_actions_for_selection = - CodeActionsForSelection::Ready(new_actions.clone()); - Some(new_actions) - }; - cx.notify(); - new_actions - }) - .ok() - .flatten() - }) - .shared(), - ); - } - fn start_inline_blame_timer(&mut self, window: &mut Window, cx: &mut Context) { if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() { self.show_git_blame_inline = false; @@ -7889,78 +4206,6 @@ impl Editor { }) } - fn refresh_single_line_folds(&mut self, window: &mut Window, cx: &mut Context) { - struct NewlineFold; - let type_id = std::any::TypeId::of::(); - if !self.mode.is_single_line() { - return; - } - let snapshot = self.snapshot(window, cx); - if snapshot.buffer_snapshot().max_point().row == 0 { - return; - } - let task = cx.background_spawn(async move { - let new_newlines = snapshot - .buffer_chars_at(MultiBufferOffset(0)) - .filter_map(|(c, i)| { - if c == '\n' { - Some( - snapshot.buffer_snapshot().anchor_after(i) - ..snapshot.buffer_snapshot().anchor_before(i + 1usize), - ) - } else { - None - } - }) - .collect::>(); - let existing_newlines = snapshot - .folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) - .filter_map(|fold| { - if fold.placeholder.type_tag == Some(type_id) { - Some(fold.range.start..fold.range.end) - } else { - None - } - }) - .collect::>(); - - (new_newlines, existing_newlines) - }); - self.folding_newlines = cx.spawn(async move |this, cx| { - let (new_newlines, existing_newlines) = task.await; - if new_newlines == existing_newlines { - return; - } - let placeholder = FoldPlaceholder { - render: Arc::new(move |_, _, cx| { - div() - .bg(cx.theme().status().hint_background) - .border_b_1() - .size_full() - .font(ThemeSettings::get_global(cx).buffer_font.clone()) - .border_color(cx.theme().status().hint) - .child("\\n") - .into_any() - }), - constrain_width: false, - merge_adjacent: false, - type_tag: Some(type_id), - collapsed_text: None, - }; - let creases = new_newlines - .into_iter() - .map(|range| Crease::simple(range, placeholder.clone())) - .collect(); - this.update(cx, |this, cx| { - this.display_map.update(cx, |display_map, cx| { - display_map.remove_folds_with_type(existing_newlines, type_id, cx); - display_map.fold(creases, cx); - }); - }) - .ok(); - }); - } - #[ztracing::instrument(skip_all)] fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { if !self.lsp_data_enabled() { @@ -11344,7 +7589,11 @@ impl Editor { let settings = buffer.language_settings_at(cursor, cx); if settings.indent_list_on_tab { if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) { - if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) { + if input::is_list_prefix_row( + MultiBufferRow(cursor.row), + &snapshot, + &language, + ) { row_delta = Self::indent_selection( buffer, &snapshot, selection, &mut edits, row_delta, cx, ); @@ -13856,410 +10105,6 @@ impl Editor { }); } - pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context) { - if self.read_only(cx) { - return; - } - if self.mode.is_single_line() { - cx.propagate(); - return; - } - - self.rewrap_impl(RewrapOptions::default(), cx) - } - - pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { - if self.read_only(cx) { - return; - } - let buffer = self.buffer.read(cx).snapshot(cx); - let selections = self.selections.all::(&self.display_snapshot(cx)); - - #[derive(Clone, Debug, PartialEq)] - enum CommentFormat { - /// single line comment, with prefix for line - Line(String), - /// single line within a block comment, with prefix for line - BlockLine(String), - /// a single line of a block comment that includes the initial delimiter - BlockCommentWithStart(BlockCommentConfig), - /// a single line of a block comment that includes the ending delimiter - BlockCommentWithEnd(BlockCommentConfig), - } - - // Split selections to respect paragraph, indent, and comment prefix boundaries. - let wrap_ranges = selections.into_iter().flat_map(|selection| { - let language_settings = buffer.language_settings_at(selection.head(), cx); - let language_scope = buffer.language_scope_at(selection.head()); - - let indent_and_prefix_for_row = - |row: u32| -> (IndentSize, Option, Option) { - let indent = buffer.indent_size_for_line(MultiBufferRow(row)); - let (comment_prefix, rewrap_prefix) = if let Some(language_scope) = - &language_scope - { - let indent_end = Point::new(row, indent.len); - let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row))); - let line_text_after_indent = buffer - .text_for_range(indent_end..line_end) - .collect::(); - - let is_within_comment_override = buffer - .language_scope_at(indent_end) - .is_some_and(|scope| scope.override_name() == Some("comment")); - let comment_delimiters = if is_within_comment_override { - // we are within a comment syntax node, but we don't - // yet know what kind of comment: block, doc or line - match ( - language_scope.documentation_comment(), - language_scope.block_comment(), - ) { - (Some(config), _) | (_, Some(config)) - if buffer.contains_str_at(indent_end, &config.start) => - { - Some(CommentFormat::BlockCommentWithStart(config.clone())) - } - (Some(config), _) | (_, Some(config)) - if line_text_after_indent.ends_with(config.end.as_ref()) => - { - Some(CommentFormat::BlockCommentWithEnd(config.clone())) - } - (Some(config), _) | (_, Some(config)) - if !config.prefix.is_empty() - && buffer.contains_str_at(indent_end, &config.prefix) => - { - Some(CommentFormat::BlockLine(config.prefix.to_string())) - } - (_, _) => language_scope - .line_comment_prefixes() - .iter() - .find(|prefix| buffer.contains_str_at(indent_end, prefix)) - .map(|prefix| CommentFormat::Line(prefix.to_string())), - } - } else { - // we not in an overridden comment node, but we may - // be within a non-overridden line comment node - language_scope - .line_comment_prefixes() - .iter() - .find(|prefix| buffer.contains_str_at(indent_end, prefix)) - .map(|prefix| CommentFormat::Line(prefix.to_string())) - }; - - let rewrap_prefix = language_scope - .rewrap_prefixes() - .iter() - .find_map(|prefix_regex| { - prefix_regex.find(&line_text_after_indent).map(|mat| { - if mat.start() == 0 { - Some(mat.as_str().to_string()) - } else { - None - } - }) - }) - .flatten(); - (comment_delimiters, rewrap_prefix) - } else { - (None, None) - }; - (indent, comment_prefix, rewrap_prefix) - }; - - let mut start_row = selection.start.row; - let mut end_row = selection.end.row; - - if selection.is_empty() { - let cursor_row = selection.start.row; - - let (mut indent_size, comment_prefix, _) = indent_and_prefix_for_row(cursor_row); - let line_prefix = match &comment_prefix { - Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => { - Some(prefix.as_str()) - } - Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { - prefix, .. - })) => Some(prefix.as_ref()), - Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig { - start: _, - end: _, - prefix, - tab_size, - })) => { - indent_size.len += tab_size; - Some(prefix.as_ref()) - } - None => None, - }; - let indent_prefix = indent_size.chars().collect::(); - let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or("")); - - 'expand_upwards: while start_row > 0 { - let prev_row = start_row - 1; - if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix) - && buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len() - && !buffer.is_line_blank(MultiBufferRow(prev_row)) - { - start_row = prev_row; - } else { - break 'expand_upwards; - } - } - - 'expand_downwards: while end_row < buffer.max_point().row { - let next_row = end_row + 1; - if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix) - && buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len() - && !buffer.is_line_blank(MultiBufferRow(next_row)) - { - end_row = next_row; - } else { - break 'expand_downwards; - } - } - } - - let mut non_blank_rows_iter = (start_row..=end_row) - .filter(|row| !buffer.is_line_blank(MultiBufferRow(*row))) - .peekable(); - - let first_row = if let Some(&row) = non_blank_rows_iter.peek() { - row - } else { - return Vec::new(); - }; - - let mut ranges = Vec::new(); - - let mut current_range_start = first_row; - let mut prev_row = first_row; - let ( - mut current_range_indent, - mut current_range_comment_delimiters, - mut current_range_rewrap_prefix, - ) = indent_and_prefix_for_row(first_row); - - for row in non_blank_rows_iter.skip(1) { - let has_paragraph_break = row > prev_row + 1; - - let (row_indent, row_comment_delimiters, row_rewrap_prefix) = - indent_and_prefix_for_row(row); - - let has_indent_change = row_indent != current_range_indent; - let has_comment_change = row_comment_delimiters != current_range_comment_delimiters; - - let has_boundary_change = has_comment_change - || row_rewrap_prefix.is_some() - || (has_indent_change && current_range_comment_delimiters.is_some()); - - if has_paragraph_break || has_boundary_change { - ranges.push(( - language_settings.clone(), - Point::new(current_range_start, 0) - ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), - current_range_indent, - current_range_comment_delimiters.clone(), - current_range_rewrap_prefix.clone(), - )); - current_range_start = row; - current_range_indent = row_indent; - current_range_comment_delimiters = row_comment_delimiters; - current_range_rewrap_prefix = row_rewrap_prefix; - } - prev_row = row; - } - - ranges.push(( - language_settings.clone(), - Point::new(current_range_start, 0) - ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), - current_range_indent, - current_range_comment_delimiters, - current_range_rewrap_prefix, - )); - - ranges - }); - - let mut edits = Vec::new(); - let mut rewrapped_row_ranges = Vec::>::new(); - - for (language_settings, wrap_range, mut indent_size, comment_prefix, rewrap_prefix) in - wrap_ranges - { - let start_row = wrap_range.start.row; - let end_row = wrap_range.end.row; - - // Skip selections that overlap with a range that has already been rewrapped. - let selection_range = start_row..end_row; - if rewrapped_row_ranges - .iter() - .any(|range| range.overlaps(&selection_range)) - { - continue; - } - - let tab_size = language_settings.tab_size; - - let (line_prefix, inside_comment) = match &comment_prefix { - Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => { - (Some(prefix.as_str()), true) - } - Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => { - (Some(prefix.as_ref()), true) - } - Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig { - start: _, - end: _, - prefix, - tab_size, - })) => { - indent_size.len += tab_size; - (Some(prefix.as_ref()), true) - } - None => (None, false), - }; - let indent_prefix = indent_size.chars().collect::(); - let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or("")); - - let allow_rewrap_based_on_language = match language_settings.allow_rewrap { - RewrapBehavior::InComments => inside_comment, - RewrapBehavior::InSelections => !wrap_range.is_empty(), - RewrapBehavior::Anywhere => true, - }; - - let should_rewrap = options.override_language_settings - || allow_rewrap_based_on_language - || self.hard_wrap.is_some(); - if !should_rewrap { - continue; - } - - let start = Point::new(start_row, 0); - let start_offset = ToOffset::to_offset(&start, &buffer); - let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); - let selection_text = buffer.text_for_range(start..end).collect::(); - let mut first_line_delimiter = None; - let mut last_line_delimiter = None; - let Some(lines_without_prefixes) = selection_text - .lines() - .enumerate() - .map(|(ix, line)| { - let line_trimmed = line.trim_start(); - if rewrap_prefix.is_some() && ix > 0 { - Ok(line_trimmed) - } else if let Some( - CommentFormat::BlockCommentWithStart(BlockCommentConfig { - start, - prefix, - end, - tab_size, - }) - | CommentFormat::BlockCommentWithEnd(BlockCommentConfig { - start, - prefix, - end, - tab_size, - }), - ) = &comment_prefix - { - let line_trimmed = line_trimmed - .strip_prefix(start.as_ref()) - .map(|s| { - let mut indent_size = indent_size; - indent_size.len -= tab_size; - let indent_prefix: String = indent_size.chars().collect(); - first_line_delimiter = Some((indent_prefix, start)); - s.trim_start() - }) - .unwrap_or(line_trimmed); - let line_trimmed = line_trimmed - .strip_suffix(end.as_ref()) - .map(|s| { - last_line_delimiter = Some(end); - s.trim_end() - }) - .unwrap_or(line_trimmed); - let line_trimmed = line_trimmed - .strip_prefix(prefix.as_ref()) - .unwrap_or(line_trimmed); - Ok(line_trimmed) - } else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix { - line_trimmed.strip_prefix(prefix).with_context(|| { - format!("line did not start with prefix {prefix:?}: {line:?}") - }) - } else { - line_trimmed - .strip_prefix(&line_prefix.trim_start()) - .with_context(|| { - format!("line did not start with prefix {line_prefix:?}: {line:?}") - }) - } - }) - .collect::, _>>() - .log_err() - else { - continue; - }; - - let wrap_column = options.line_length.or(self.hard_wrap).unwrap_or_else(|| { - buffer - .language_settings_at(Point::new(start_row, 0), cx) - .preferred_line_length as usize - }); - - let subsequent_lines_prefix = if let Some(rewrap_prefix_str) = &rewrap_prefix { - format!("{}{}", indent_prefix, " ".repeat(rewrap_prefix_str.len())) - } else { - line_prefix.clone() - }; - - let wrapped_text = { - let mut wrapped_text = wrap_with_prefix( - line_prefix, - subsequent_lines_prefix, - lines_without_prefixes.join("\n"), - wrap_column, - tab_size, - options.preserve_existing_whitespace, - ); - - if let Some((indent, delimiter)) = first_line_delimiter { - wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}"); - } - if let Some(last_line) = last_line_delimiter { - wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}"); - } - - wrapped_text - }; - - // TODO: should always use char-based diff while still supporting cursor behavior that - // matches vim. - let mut diff_options = DiffOptions::default(); - if options.override_language_settings { - diff_options.max_word_diff_len = 0; - diff_options.max_word_diff_line_count = 0; - } else { - diff_options.max_word_diff_len = usize::MAX; - diff_options.max_word_diff_line_count = usize::MAX; - } - - for (old_range, new_text) in - text_diff_with_options(&selection_text, &wrapped_text, diff_options) - { - let edit_start = buffer.anchor_after(start_offset + old_range.start); - let edit_end = buffer.anchor_after(start_offset + old_range.end); - edits.push((edit_start..edit_end, new_text)); - } - - rewrapped_row_ranges.push(start_row..=end_row); - } - - self.buffer - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); - } - pub fn cut_common( &mut self, cut_no_selection_line: bool, @@ -18169,6 +14014,28 @@ impl Editor { range: Range, window: &mut Window, cx: &mut Context, + ) { + self.go_to_singleton_buffer_range_impl(range, true, window, cx); + } + + /// Like `go_to_singleton_buffer_point`, but does not push a navigation + /// history entry. Useful when the caller already recorded one (e.g. when + /// a file was just opened and we only need to move the cursor). + pub fn go_to_singleton_buffer_point_silently( + &mut self, + point: Point, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_singleton_buffer_range_impl(point..point, false, window, cx); + } + + fn go_to_singleton_buffer_range_impl( + &mut self, + range: Range, + record_nav_history: bool, + window: &mut Window, + cx: &mut Context, ) { let multibuffer = self.buffer().read(cx); if !multibuffer.is_singleton() { @@ -18180,7 +14047,7 @@ impl Editor { self.cursor_top_offset(cx), cx, )) - .nav_history(true), + .nav_history(record_nav_history), window, cx, |s| s.select_anchor_ranges([anchor_range]), @@ -18643,12 +14510,14 @@ impl Editor { cx.spawn_in(window, async move |_, cx| { let result = find_file(&buffer, project, buffer_position, cx).await; - if let Some((_, path)) = result { - workspace + if let Some((_, file_target)) = result { + let item = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_resolved_path(path, window, cx) + workspace.open_resolved_path(file_target.resolved_path.clone(), window, cx) })? .await?; + + file_target.navigate_item_to_position(item, cx); } anyhow::Ok(()) }) @@ -18679,8 +14548,8 @@ impl Editor { first_url_or_file = Some(Either::Left(url)); None } - HoverLink::File(path) => { - first_url_or_file = Some(Either::Right(path)); + HoverLink::File(file_target) => { + first_url_or_file = Some(Either::Right(file_target)); None } }) @@ -18814,18 +14683,25 @@ impl Editor { })?; Ok(Navigated::Yes) } - Some(Either::Right(path)) => { + Some(Either::Right(file_target)) => { // TODO(andrew): respect preview tab settings // `enable_keep_preview_on_code_navigation` and // `enable_preview_file_from_code_navigation` let Some(workspace) = workspace else { return Ok(Navigated::No); }; - workspace + let item = workspace .update_in(cx, |workspace, window, cx| { - workspace.open_resolved_path(path, window, cx) + workspace.open_resolved_path( + file_target.resolved_path.clone(), + window, + cx, + ) })? .await?; + + file_target.navigate_item_to_position(item, cx); + Ok(Navigated::Yes) } None => Ok(Navigated::No), @@ -20124,10 +16000,6 @@ impl Editor { window.show_character_palette(); } - pub fn disable_word_completions(&mut self) { - self.word_completions_enabled = false; - } - pub fn toggle_minimap( &mut self, _: &ToggleMinimap, @@ -20139,32 +16011,6 @@ impl Editor { } } - pub fn set_selections_from_remote( - &mut self, - selections: Vec>, - pending_selection: Option>, - window: &mut Window, - cx: &mut Context, - ) { - let old_cursor_position = self.selections.newest_anchor().head(); - self.selections - .change_with(&self.display_snapshot(cx), |s| { - s.select_anchors(selections); - if let Some(pending_selection) = pending_selection { - s.set_pending(pending_selection, SelectMode::Character); - } else { - s.clear_pending(); - } - }); - self.selections_did_change( - false, - &old_cursor_position, - SelectionEffects::default(), - window, - cx, - ); - } - pub fn transact( &mut self, window: &mut Window, @@ -20235,35 +16081,6 @@ impl Editor { .is_some() } - pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { - if self.selection_mark_mode { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.move_with(&mut |_, sel| { - sel.collapse_to(sel.head(), SelectionGoal::None); - }); - }) - } - self.selection_mark_mode = true; - cx.notify(); - } - - pub fn swap_selection_ends( - &mut self, - _: &actions::SwapSelectionEnds, - window: &mut Window, - cx: &mut Context, - ) { - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.move_with(&mut |_, sel| { - if sel.start != sel.end { - sel.reversed = !sel.reversed - } - }); - }); - self.request_autoscroll(Autoscroll::newest(), cx); - cx.notify(); - } - pub fn toggle_focus( workspace: &mut Workspace, _: &actions::ToggleFocus, @@ -20276,705 +16093,6 @@ impl Editor { workspace.activate_item(&item, true, true, window, cx); } - pub fn toggle_fold( - &mut self, - _: &actions::ToggleFold, - window: &mut Window, - cx: &mut Context, - ) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selection = self.selections.newest::(&display_map); - - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_lines(&Default::default(), window, cx) - } else { - self.fold(&Default::default(), window, cx) - } - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids: HashSet<_> = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect(); - - let should_unfold = buffer_ids - .iter() - .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); - - for buffer_id in buffer_ids { - if should_unfold { - self.unfold_buffer(buffer_id, cx); - } else { - self.fold_buffer(buffer_id, cx); - } - } - } - } - - pub fn toggle_fold_recursive( - &mut self, - _: &actions::ToggleFoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let selection = self.selections.newest::(&self.display_snapshot(cx)); - - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_recursive(&Default::default(), window, cx) - } else { - self.fold_recursive(&Default::default(), window, cx) - } - } - - pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - let mut row = range.start.row; - while row <= range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) - { - found = true; - row = crease.range().end.row + 1; - to_fold.push(crease); - } else { - row += 1 - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) - && crease.range().end.row >= buffer_start_row - { - to_fold.push(crease); - if row <= range.start.row { - break; - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.fold_buffer(buffer_id, cx); - } - } - } - - pub fn toggle_fold_all( - &mut self, - _: &actions::ToggleFoldAll, - window: &mut Window, - cx: &mut Context, - ) { - let has_folds = if self.buffer.read(cx).is_singleton() { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let has_folds = display_map - .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len()) - .next() - .is_some(); - has_folds - } else { - let snapshot = self.buffer.read(cx).snapshot(cx); - let has_folds = snapshot - .all_buffer_ids() - .any(|buffer_id| self.is_buffer_folded(buffer_id, cx)); - has_folds - }; - - if has_folds { - self.unfold_all(&actions::UnfoldAll, window, cx); - } else { - self.fold_all(&actions::FoldAll, window, cx); - } - } - - fn fold_at_level( - &mut self, - fold_at: &FoldAtLevel, - window: &mut Window, - cx: &mut Context, - ) { - if !self.buffer.read(cx).is_singleton() { - return; - } - - let fold_at_level = fold_at.0; - let snapshot = self.buffer.read(cx).snapshot(cx); - let mut to_fold = Vec::new(); - let mut stack = vec![(0, snapshot.max_row().0, 1)]; - - let row_ranges_to_keep: Vec> = self - .selections - .all::(&self.display_snapshot(cx)) - .into_iter() - .map(|sel| sel.start.row..sel.end.row) - .collect(); - - while let Some((mut start_row, end_row, current_level)) = stack.pop() { - while start_row < end_row { - match self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(start_row)) - { - Some(crease) => { - let nested_start_row = crease.range().start.row + 1; - let nested_end_row = crease.range().end.row; - - if current_level < fold_at_level { - stack.push((nested_start_row, nested_end_row, current_level + 1)); - } else if current_level == fold_at_level { - // Fold iff there is no selection completely contained within the fold region - if !row_ranges_to_keep.iter().any(|selection| { - selection.end >= nested_start_row - && selection.start <= nested_end_row - }) { - to_fold.push(crease); - } - } - - start_row = nested_end_row + 1; - } - None => start_row += 1, - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_at_level_1( - &mut self, - _: &actions::FoldAtLevel1, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(1), window, cx); - } - - pub fn fold_at_level_2( - &mut self, - _: &actions::FoldAtLevel2, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(2), window, cx); - } - - pub fn fold_at_level_3( - &mut self, - _: &actions::FoldAtLevel3, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(3), window, cx); - } - - pub fn fold_at_level_4( - &mut self, - _: &actions::FoldAtLevel4, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(4), window, cx); - } - - pub fn fold_at_level_5( - &mut self, - _: &actions::FoldAtLevel5, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(5), window, cx); - } - - pub fn fold_at_level_6( - &mut self, - _: &actions::FoldAtLevel6, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(6), window, cx); - } - - pub fn fold_at_level_7( - &mut self, - _: &actions::FoldAtLevel7, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(7), window, cx); - } - - pub fn fold_at_level_8( - &mut self, - _: &actions::FoldAtLevel8, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(8), window, cx); - } - - pub fn fold_at_level_9( - &mut self, - _: &actions::FoldAtLevel9, - window: &mut Window, - cx: &mut Context, - ) { - self.fold_at_level(&actions::FoldAtLevel(9), window, cx); - } - - pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { - if self.buffer.read(cx).is_singleton() { - let mut fold_ranges = Vec::new(); - let snapshot = self.buffer.read(cx).snapshot(cx); - - for row in 0..snapshot.max_row().0 { - if let Some(foldable_range) = self - .snapshot(window, cx) - .crease_for_buffer_row(MultiBufferRow(row)) - { - fold_ranges.push(foldable_range); - } - } - - self.fold_creases(fold_ranges, true, window, cx); - } else { - self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { - editor - .update_in(cx, |editor, _, cx| { - let snapshot = editor.buffer.read(cx).snapshot(cx); - for buffer_id in snapshot.all_buffer_ids() { - editor.fold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_function_bodies( - &mut self, - _: &actions::FoldFunctionBodies, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - - let ranges = snapshot - .text_object_ranges( - MultiBufferOffset(0)..snapshot.len(), - TreeSitterOptions::default(), - ) - .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) - .collect::>(); - - let creases = ranges - .into_iter() - .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) - .collect(); - - self.fold_creases(creases, true, window, cx); - } - - pub fn fold_recursive( - &mut self, - _: &actions::FoldRecursive, - window: &mut Window, - cx: &mut Context, - ) { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; - - if range.start.row != range.end.row { - let mut found = false; - for row in range.start.row..=range.end.row { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - found = true; - to_fold.push(crease); - } - } - if found { - continue; - } - } - - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - } else { - break; - } - } - } - } - - self.fold_creases(to_fold, true, window, cx); - } - - pub fn fold_at( - &mut self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { - let autoscroll = self - .selections - .all::(&display_map) - .iter() - .any(|selection| crease.range().overlaps(&selection.range())); - - self.fold_creases(vec![crease], autoscroll, window, cx); - } - } - - pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { - if self.buffer_kind(cx) == ItemBufferKind::Singleton { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = display_map.buffer_snapshot(); - let selections = self.selections.all::(&display_map); - let ranges = selections - .iter() - .map(|s| { - let range = s.display_range(&display_map).sorted(); - let mut start = range.start.to_point(&display_map); - let mut end = range.end.to_point(&display_map); - start.column = 0; - end.column = buffer.line_len(MultiBufferRow(end.row)); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } else { - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_ids = self - .selections - .disjoint_anchor_ranges() - .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) - .collect::>(); - for buffer_id in buffer_ids { - self.unfold_buffer(buffer_id, cx); - } - } - } - - pub fn unfold_recursive( - &mut self, - _: &UnfoldRecursive, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all::(&display_map); - let ranges = selections - .iter() - .map(|s| { - let mut range = s.display_range(&display_map).sorted(); - *range.start.column_mut() = 0; - *range.end.column_mut() = display_map.line_len(range.end.row()); - let start = range.start.to_point(&display_map); - let end = range.end.to_point(&display_map); - start..end - }) - .collect::>(); - - self.unfold_ranges(&ranges, true, true, cx); - } - - pub fn unfold_at( - &mut self, - buffer_row: MultiBufferRow, - _window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - - let intersection_range = Point::new(buffer_row.0, 0) - ..Point::new( - buffer_row.0, - display_map.buffer_snapshot().line_len(buffer_row), - ); - - let autoscroll = self - .selections - .all::(&display_map) - .iter() - .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); - - self.unfold_ranges(&[intersection_range], true, autoscroll, cx); - } - - pub fn unfold_all( - &mut self, - _: &actions::UnfoldAll, - _window: &mut Window, - cx: &mut Context, - ) { - if self.buffer.read(cx).is_singleton() { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges( - &[MultiBufferOffset(0)..display_map.buffer_snapshot().len()], - true, - true, - cx, - ); - } else { - self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { - editor - .update(cx, |editor, cx| { - let snapshot = editor.buffer.read(cx).snapshot(cx); - for buffer_id in snapshot.all_buffer_ids() { - editor.unfold_buffer(buffer_id, cx); - } - }) - .ok(); - }); - } - } - - pub fn fold_selected_ranges( - &mut self, - _: &FoldSelectedRanges, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(&display_map); - let ranges = selections - .into_iter() - .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, true, window, cx); - } - - pub fn fold_ranges( - &mut self, - ranges: Vec>, - auto_scroll: bool, - window: &mut Window, - cx: &mut Context, - ) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let ranges = ranges - .into_iter() - .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) - .collect::>(); - self.fold_creases(ranges, auto_scroll, window, cx); - } - - pub fn fold_creases( - &mut self, - creases: Vec>, - auto_scroll: bool, - window: &mut Window, - cx: &mut Context, - ) { - if creases.is_empty() { - return; - } - - self.display_map.update(cx, |map, cx| map.fold(creases, cx)); - - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); - } - - cx.notify(); - - self.scrollbar_marker_state.dirty = true; - self.update_data_on_scroll(false, window, cx); - self.folds_did_change(cx); - } - - /// Removes any folds whose ranges intersect any of the given ranges. - pub fn unfold_ranges( - &mut self, - ranges: &[Range], - inclusive: bool, - auto_scroll: bool, - cx: &mut Context, - ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx); - }); - self.folds_did_change(cx); - } - - pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - self.fold_buffers([buffer_id], cx); - } - - pub fn fold_buffers( - &mut self, - buffer_ids: impl IntoIterator, - cx: &mut Context, - ) { - if self.buffer().read(cx).is_singleton() { - return; - } - - let ids_to_fold: Vec = buffer_ids - .into_iter() - .filter(|id| !self.is_buffer_folded(*id, cx)) - .collect(); - - if ids_to_fold.is_empty() { - return; - } - - self.display_map.update(cx, |display_map, cx| { - display_map.fold_buffers(ids_to_fold.clone(), cx) - }); - - let snapshot = self.display_snapshot(cx); - self.selections.change_with(&snapshot, |selections| { - for buffer_id in ids_to_fold.iter().copied() { - selections.remove_selections_from_buffer(buffer_id); - } - }); - - cx.emit(EditorEvent::BufferFoldToggled { - ids: ids_to_fold, - folded: true, - }); - cx.notify(); - } - - pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { - return; - } - self.display_map.update(cx, |display_map, cx| { - display_map.unfold_buffers([buffer_id], cx); - }); - cx.emit(EditorEvent::BufferFoldToggled { - ids: vec![buffer_id], - folded: false, - }); - cx.notify(); - } - - pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { - self.display_map.read(cx).is_buffer_folded(buffer) - } - - pub fn has_any_buffer_folded(&self, cx: &App) -> bool { - if self.buffer().read(cx).is_singleton() { - return false; - } - !self.folded_buffers(cx).is_empty() - } - - pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { - self.display_map.read(cx).folded_buffers() - } - - pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - self.display_map.update(cx, |display_map, cx| { - display_map.disable_header_for_buffer(buffer_id, cx); - }); - cx.notify(); - } - - /// Removes any folds with the given ranges. - pub fn remove_folds_with_type( - &mut self, - ranges: &[Range], - type_id: TypeId, - auto_scroll: bool, - cx: &mut Context, - ) { - self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { - map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) - }); - self.folds_did_change(cx); - } - - fn remove_folds_with( - &mut self, - ranges: &[Range], - auto_scroll: bool, - cx: &mut Context, - update: impl FnOnce(&mut DisplayMap, &mut Context), - ) { - if ranges.is_empty() { - return; - } - - self.display_map.update(cx, update); - - if auto_scroll { - self.request_autoscroll(Autoscroll::fit(), cx); - } - - cx.notify(); - self.scrollbar_marker_state.dirty = true; - self.active_indent_guides_state.dirty = true; - } - - pub fn update_renderer_widths( - &mut self, - widths: impl IntoIterator, - cx: &mut Context, - ) -> bool { - self.display_map - .update(cx, |map, cx| map.update_fold_widths(widths, cx)) - } - - pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { - self.display_map.read(cx).fold_placeholder.clone() - } - pub fn set_expand_all_diff_hunks(&mut self, cx: &mut App) { self.buffer.update(cx, |buffer, cx| { buffer.set_all_diff_hunks_expanded(cx); @@ -21018,354 +16136,6 @@ impl Editor { self.toggle_diff_hunks_in_ranges(ranges, cx); } - pub fn diff_hunks_in_ranges<'a>( - &'a self, - ranges: &'a [Range], - buffer: &'a MultiBufferSnapshot, - ) -> impl 'a + Iterator { - ranges.iter().flat_map(move |range| { - let end_excerpt = buffer.excerpt_containing(range.end..range.end); - let range = range.to_point(buffer); - let mut peek_end = range.end; - if range.end.row < buffer.max_row().0 { - peek_end = Point::new(range.end.row + 1, 0); - } - buffer - .diff_hunks_in_range(range.start..peek_end) - .filter(move |hunk| { - if let Some((_, excerpt_range)) = &end_excerpt - && let Some(end_anchor) = - buffer.anchor_in_excerpt(excerpt_range.context.end) - && let Some(hunk_end_anchor) = - buffer.anchor_in_excerpt(hunk.excerpt_range.context.end) - && hunk_end_anchor.cmp(&end_anchor, buffer).is_gt() - { - false - } else { - true - } - }) - }) - } - - pub fn has_stageable_diff_hunks_in_ranges( - &self, - ranges: &[Range], - snapshot: &MultiBufferSnapshot, - ) -> bool { - let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot); - hunks.any(|hunk| hunk.status().has_secondary_hunk()) - } - - pub fn toggle_staged_selected_diff_hunks( - &mut self, - _: &::git::ToggleStaged, - _: &mut Window, - cx: &mut Context, - ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - let ranges: Vec<_> = self - .selections - .disjoint_anchors() - .iter() - .map(|s| s.range()) - .collect(); - let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - } - - pub fn set_render_diff_hunk_controls( - &mut self, - render_diff_hunk_controls: RenderDiffHunkControlsFn, - cx: &mut Context, - ) { - self.render_diff_hunk_controls = render_diff_hunk_controls; - cx.notify(); - } - - pub fn stage_and_next( - &mut self, - _: &::git::StageAndNext, - window: &mut Window, - cx: &mut Context, - ) { - self.do_stage_or_unstage_and_next(true, window, cx); - } - - pub fn unstage_and_next( - &mut self, - _: &::git::UnstageAndNext, - window: &mut Window, - cx: &mut Context, - ) { - self.do_stage_or_unstage_and_next(false, window, cx); - } - - pub fn stage_or_unstage_diff_hunks( - &mut self, - stage: bool, - ranges: Vec>, - cx: &mut Context, - ) { - if self.delegate_stage_and_restore { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); - if !hunks.is_empty() { - cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); - } - return; - } - let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); - cx.spawn(async move |this, cx| { - task.await?; - this.update(cx, |this, cx| { - let snapshot = this.buffer.read(cx).snapshot(cx); - let chunk_by = this - .diff_hunks_in_ranges(&ranges, &snapshot) - .chunk_by(|hunk| hunk.buffer_id); - for (buffer_id, hunks) in &chunk_by { - this.do_stage_or_unstage(stage, buffer_id, hunks, cx); - } - }) - }) - .detach_and_log_err(cx); - } - - fn save_buffers_for_ranges_if_needed( - &mut self, - ranges: &[Range], - cx: &mut Context, - ) -> Task> { - let multibuffer = self.buffer.read(cx); - let snapshot = multibuffer.read(cx); - let buffer_ids: HashSet<_> = ranges - .iter() - .flat_map(|range| snapshot.buffer_ids_for_range(range.clone())) - .collect(); - drop(snapshot); - - let mut buffers = HashSet::default(); - for buffer_id in buffer_ids { - if let Some(buffer_entity) = multibuffer.buffer(buffer_id) { - let buffer = buffer_entity.read(cx); - if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty() - { - buffers.insert(buffer_entity); - } - } - } - - if let Some(project) = &self.project { - project.update(cx, |project, cx| project.save_buffers(buffers, cx)) - } else { - Task::ready(Ok(())) - } - } - - fn do_stage_or_unstage_and_next( - &mut self, - stage: bool, - window: &mut Window, - cx: &mut Context, - ) { - let ranges = self.selections.disjoint_anchor_ranges().collect::>(); - - if ranges.iter().any(|range| range.start != range.end) { - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - return; - } - - self.stage_or_unstage_diff_hunks(stage, ranges, cx); - - let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); - let wrap_around = !all_diff_hunks_expanded; - let snapshot = self.snapshot(window, cx); - let position = self - .selections - .newest::(&snapshot.display_snapshot) - .head(); - - self.go_to_hunk_before_or_after_position( - &snapshot, - position, - Direction::Next, - wrap_around, - window, - cx, - ); - } - - pub(crate) fn do_stage_or_unstage( - &self, - stage: bool, - buffer_id: BufferId, - hunks: impl Iterator, - cx: &mut App, - ) -> Option<()> { - let project = self.project()?; - let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; - let diff = self.buffer.read(cx).diff_for(buffer_id)?; - let buffer_snapshot = buffer.read(cx).snapshot(); - let file_exists = buffer_snapshot - .file() - .is_some_and(|file| file.disk_state().exists()); - diff.update(cx, |diff, cx| { - diff.stage_or_unstage_hunks( - stage, - &hunks - .map(|hunk| buffer_diff::DiffHunk { - buffer_range: hunk.buffer_range, - // We don't need to pass in word diffs here because they're only used for rendering and - // this function changes internal state - base_word_diffs: Vec::default(), - buffer_word_diffs: Vec::default(), - diff_base_byte_range: hunk.diff_base_byte_range.start.0 - ..hunk.diff_base_byte_range.end.0, - secondary_status: hunk.status.secondary, - range: Point::zero()..Point::zero(), // unused - }) - .collect::>(), - &buffer_snapshot, - file_exists, - cx, - ) - }); - None - } - - pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { - let ranges: Vec<_> = self - .selections - .disjoint_anchors() - .iter() - .map(|s| s.range()) - .collect(); - self.buffer - .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) - } - - pub fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { - self.buffer.update(cx, |buffer, cx| { - let ranges = vec![Anchor::Min..Anchor::Max]; - if !buffer.all_diff_hunks_expanded() - && buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx) - { - buffer.collapse_diff_hunks(ranges, cx); - true - } else { - false - } - }) - } - - fn has_any_expanded_diff_hunks(&self, cx: &App) -> bool { - if self.buffer.read(cx).all_diff_hunks_expanded() { - return true; - } - let ranges = vec![Anchor::Min..Anchor::Max]; - self.buffer - .read(cx) - .has_expanded_diff_hunks_in_ranges(&ranges, cx) - } - - fn toggle_diff_hunks_in_ranges( - &mut self, - ranges: Vec>, - cx: &mut Context, - ) { - self.buffer.update(cx, |buffer, cx| { - let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); - buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); - }) - } - - fn toggle_single_diff_hunk(&mut self, range: Range, cx: &mut Context) { - self.buffer.update(cx, |buffer, cx| { - buffer.toggle_single_diff_hunk(range, cx); - }) - } - - pub(crate) fn apply_all_diff_hunks( - &mut self, - _: &ApplyAllDiffHunks, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - - let buffers = self.buffer.read(cx).all_buffers(); - for branch_buffer in buffers { - branch_buffer.update(cx, |branch_buffer, cx| { - branch_buffer.merge_into_base(Vec::new(), cx); - }); - } - - if let Some(project) = self.project.clone() { - self.save( - SaveOptions { - format: true, - force_format: false, - autosave: false, - }, - project, - window, - cx, - ) - .detach_and_log_err(cx); - } - } - - pub(crate) fn apply_selected_diff_hunks( - &mut self, - _: &ApplyDiffHunk, - window: &mut Window, - cx: &mut Context, - ) { - if self.read_only(cx) { - return; - } - let snapshot = self.snapshot(window, cx); - let hunks = snapshot.hunks_for_ranges( - self.selections - .all(&snapshot.display_snapshot) - .into_iter() - .map(|selection| selection.range()), - ); - let mut ranges_by_buffer = HashMap::default(); - self.transact(window, cx, |editor, _window, cx| { - for hunk in hunks { - if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { - ranges_by_buffer - .entry(buffer.clone()) - .or_insert_with(Vec::new) - .push(hunk.buffer_range.to_offset(buffer.read(cx))); - } - } - - for (buffer, ranges) in ranges_by_buffer { - buffer.update(cx, |buffer, cx| { - buffer.merge_into_base(ranges, cx); - }); - } - }); - - if let Some(project) = self.project.clone() { - self.save( - SaveOptions { - format: true, - force_format: false, - autosave: false, - }, - project, - window, - cx, - ) - .detach_and_log_err(cx); - } - } - pub fn set_gutter_hovered(&mut self, hovered: bool, cx: &mut Context) { if hovered != self.gutter_hovered { self.gutter_hovered = hovered; @@ -21449,24 +16219,6 @@ impl Editor { self.focused_block.take() } - pub fn insert_creases( - &mut self, - creases: impl IntoIterator>, - cx: &mut Context, - ) -> Vec { - self.display_map - .update(cx, |map, cx| map.insert_creases(creases, cx)) - } - - pub fn remove_creases( - &mut self, - ids: impl IntoIterator, - cx: &mut Context, - ) -> Vec<(CreaseId, Range)> { - self.display_map - .update(cx, |map, cx| map.remove_creases(ids, cx)) - } - pub fn longest_row(&self, cx: &mut App) -> DisplayRow { self.display_map .update(cx, |map, cx| map.snapshot(cx)) @@ -21579,332 +16331,6 @@ impl Editor { .filter(|_| self.minimap_visibility.visible()) } - pub fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> { - let mut wrap_guides = smallvec![]; - - if self.show_wrap_guides == Some(false) { - return wrap_guides; - } - - let settings = self.buffer.read(cx).language_settings(cx); - if settings.show_wrap_guides { - match self.soft_wrap_mode(cx) { - SoftWrap::Bounded(soft_wrap) => { - wrap_guides.push((soft_wrap as usize, true)); - } - SoftWrap::GitDiff | SoftWrap::None | SoftWrap::EditorWidth => {} - } - wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false))) - } - - wrap_guides - } - - pub fn soft_wrap_mode(&self, cx: &App) -> SoftWrap { - let settings = self.buffer.read(cx).language_settings(cx); - let mode = self.soft_wrap_mode_override.unwrap_or(settings.soft_wrap); - match mode { - language_settings::SoftWrap::PreferLine | language_settings::SoftWrap::None => { - SoftWrap::None - } - language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth, - language_settings::SoftWrap::Bounded => { - SoftWrap::Bounded(settings.preferred_line_length) - } - } - } - - pub fn set_soft_wrap_mode( - &mut self, - mode: language_settings::SoftWrap, - cx: &mut Context, - ) { - self.soft_wrap_mode_override = Some(mode); - cx.notify(); - } - - pub fn set_hard_wrap(&mut self, hard_wrap: Option, cx: &mut Context) { - self.hard_wrap = hard_wrap; - cx.notify(); - } - - pub fn set_text_style_refinement(&mut self, style: TextStyleRefinement) { - self.text_style_refinement = Some(style); - } - - /// called by the Element so we know what style we were most recently rendered with. - pub fn set_style(&mut self, style: EditorStyle, window: &mut Window, cx: &mut Context) { - // We intentionally do not inform the display map about the minimap style - // so that wrapping is not recalculated and stays consistent for the editor - // and its linked minimap. - if !self.mode.is_minimap() { - let font = style.text.font(); - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let display_map = self - .placeholder_display_map - .as_ref() - .filter(|_| self.is_empty(cx)) - .unwrap_or(&self.display_map); - - display_map.update(cx, |map, cx| map.set_font(font, font_size, cx)); - } - self.style = Some(style); - } - - pub fn style(&mut self, cx: &App) -> &EditorStyle { - if self.style.is_none() { - self.style = Some(self.create_style(cx)); - } - self.style.as_ref().unwrap() - } - - // Called by the element. This method is not designed to be called outside of the editor - // element's layout code because it does not notify when rewrapping is computed synchronously. - pub(crate) fn set_wrap_width(&self, width: Option, cx: &mut App) -> bool { - if self.is_empty(cx) { - self.placeholder_display_map - .as_ref() - .map_or(false, |display_map| { - display_map.update(cx, |map, cx| map.set_wrap_width(width, cx)) - }) - } else { - self.display_map - .update(cx, |map, cx| map.set_wrap_width(width, cx)) - } - } - - pub fn set_soft_wrap(&mut self) { - self.soft_wrap_mode_override = Some(language_settings::SoftWrap::EditorWidth) - } - - pub fn toggle_soft_wrap(&mut self, _: &ToggleSoftWrap, _: &mut Window, cx: &mut Context) { - if self.soft_wrap_mode_override.is_some() { - self.soft_wrap_mode_override.take(); - } else { - let soft_wrap = match self.soft_wrap_mode(cx) { - SoftWrap::GitDiff => return, - SoftWrap::None => language_settings::SoftWrap::EditorWidth, - SoftWrap::EditorWidth | SoftWrap::Bounded(_) => language_settings::SoftWrap::None, - }; - self.soft_wrap_mode_override = Some(soft_wrap); - } - cx.notify(); - } - - pub fn toggle_tab_bar(&mut self, _: &ToggleTabBar, _: &mut Window, cx: &mut Context) { - let Some(workspace) = self.workspace() else { - return; - }; - let fs = workspace.read(cx).app_state().fs.clone(); - let current_show = TabBarSettings::get_global(cx).show; - update_settings_file(fs, cx, move |setting, _| { - setting.tab_bar.get_or_insert_default().show = Some(!current_show); - }); - } - - pub fn toggle_indent_guides( - &mut self, - _: &ToggleIndentGuides, - _: &mut Window, - cx: &mut Context, - ) { - let currently_enabled = self.should_show_indent_guides().unwrap_or_else(|| { - self.buffer - .read(cx) - .language_settings(cx) - .indent_guides - .enabled - }); - self.show_indent_guides = Some(!currently_enabled); - cx.notify(); - } - - fn should_show_indent_guides(&self) -> Option { - self.show_indent_guides - } - - pub fn disable_indent_guides_for_buffer( - &mut self, - buffer_id: BufferId, - cx: &mut Context, - ) { - self.buffers_with_disabled_indent_guides.insert(buffer_id); - cx.notify(); - } - - pub fn has_indent_guides_disabled_for_buffer(&self, buffer_id: BufferId) -> bool { - self.buffers_with_disabled_indent_guides - .contains(&buffer_id) - } - - pub fn toggle_line_numbers( - &mut self, - _: &ToggleLineNumbers, - _: &mut Window, - cx: &mut Context, - ) { - let mut editor_settings = EditorSettings::get_global(cx).clone(); - editor_settings.gutter.line_numbers = !editor_settings.gutter.line_numbers; - EditorSettings::override_global(editor_settings, cx); - } - - pub fn line_numbers_enabled(&self, cx: &App) -> bool { - if let Some(show_line_numbers) = self.show_line_numbers { - return show_line_numbers; - } - EditorSettings::get_global(cx).gutter.line_numbers - } - - pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers { - match ( - self.use_relative_line_numbers, - EditorSettings::get_global(cx).relative_line_numbers, - ) { - (None, setting) => setting, - (Some(false), _) => RelativeLineNumbers::Disabled, - (Some(true), RelativeLineNumbers::Wrapped) => RelativeLineNumbers::Wrapped, - (Some(true), _) => RelativeLineNumbers::Enabled, - } - } - - pub fn toggle_relative_line_numbers( - &mut self, - _: &ToggleRelativeLineNumbers, - _: &mut Window, - cx: &mut Context, - ) { - let is_relative = self.relative_line_numbers(cx); - self.set_relative_line_number(Some(!is_relative.enabled()), cx) - } - - pub fn set_relative_line_number(&mut self, is_relative: Option, cx: &mut Context) { - self.use_relative_line_numbers = is_relative; - cx.notify(); - } - - pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut Context) { - self.show_gutter = show_gutter; - cx.notify(); - } - - pub fn set_show_scrollbars(&mut self, show: bool, cx: &mut Context) { - self.show_scrollbars = ScrollbarAxes { - horizontal: show, - vertical: show, - }; - cx.notify(); - } - - pub fn set_show_vertical_scrollbar(&mut self, show: bool, cx: &mut Context) { - self.show_scrollbars.vertical = show; - cx.notify(); - } - - pub fn set_show_horizontal_scrollbar(&mut self, show: bool, cx: &mut Context) { - self.show_scrollbars.horizontal = show; - cx.notify(); - } - - pub fn set_minimap_visibility( - &mut self, - minimap_visibility: MinimapVisibility, - window: &mut Window, - cx: &mut Context, - ) { - if self.minimap_visibility != minimap_visibility { - if minimap_visibility.visible() && self.minimap.is_none() { - let minimap_settings = EditorSettings::get_global(cx).minimap; - self.minimap = - self.create_minimap(minimap_settings.with_show_override(), window, cx); - } - self.minimap_visibility = minimap_visibility; - cx.notify(); - } - } - - pub fn disable_scrollbars_and_minimap(&mut self, window: &mut Window, cx: &mut Context) { - self.set_show_scrollbars(false, cx); - self.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); - } - - pub fn hide_minimap_by_default(&mut self, window: &mut Window, cx: &mut Context) { - self.set_minimap_visibility(self.minimap_visibility.hidden(), window, cx); - } - - /// Normally the text in full mode and auto height editors is padded on the - /// left side by roughly half a character width for improved hit testing. - /// - /// Use this method to disable this for cases where this is not wanted (e.g. - /// if you want to align the editor text with some other text above or below) - /// or if you want to add this padding to single-line editors. - pub fn set_offset_content(&mut self, offset_content: bool, cx: &mut Context) { - self.offset_content = offset_content; - cx.notify(); - } - - pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context) { - self.show_line_numbers = Some(show_line_numbers); - cx.notify(); - } - - pub fn disable_expand_excerpt_buttons(&mut self, cx: &mut Context) { - self.disable_expand_excerpt_buttons = true; - cx.notify(); - } - - pub fn set_number_deleted_lines(&mut self, number: bool, cx: &mut Context) { - self.number_deleted_lines = number; - cx.notify(); - } - - pub fn set_delegate_expand_excerpts(&mut self, delegate: bool) { - self.delegate_expand_excerpts = delegate; - } - - pub fn set_delegate_stage_and_restore(&mut self, delegate: bool) { - self.delegate_stage_and_restore = delegate; - } - - pub fn set_delegate_open_excerpts(&mut self, delegate: bool) { - self.delegate_open_excerpts = delegate; - } - - pub fn set_on_local_selections_changed( - &mut self, - callback: Option) + 'static>>, - ) { - self.on_local_selections_changed = callback; - } - - pub fn set_suppress_selection_callback(&mut self, suppress: bool) { - self.suppress_selection_callback = suppress; - } - - pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context) { - self.show_git_diff_gutter = Some(show_git_diff_gutter); - cx.notify(); - } - - pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context) { - self.show_code_actions = Some(show_code_actions); - cx.notify(); - } - - pub fn set_show_runnables(&mut self, show_runnables: bool, cx: &mut Context) { - self.show_runnables = Some(show_runnables); - cx.notify(); - } - - pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context) { - self.show_breakpoints = Some(show_breakpoints); - cx.notify(); - } - - pub fn set_show_diff_review_button(&mut self, show: bool, cx: &mut Context) { - self.show_diff_review_button = show; - cx.notify(); - } - pub fn show_diff_review_button(&self) -> bool { self.show_diff_review_button } @@ -23124,370 +17550,6 @@ impl Editor { cx.notify() } - pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut Context) { - self.show_wrap_guides = Some(show_wrap_guides); - cx.notify(); - } - - pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut Context) { - self.show_indent_guides = Some(show_indent_guides); - cx.notify(); - } - - pub fn working_directory(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer().read(cx).as_singleton() { - if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) - && let Some(dir) = file.abs_path(cx).parent() - { - return Some(dir.to_owned()); - } - } - - None - } - - fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { - self.active_buffer(cx)? - .read(cx) - .file() - .and_then(|f| f.as_local()) - } - - pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { - self.active_buffer(cx).and_then(|buffer| { - let buffer = buffer.read(cx); - if let Some(project_path) = buffer.project_path(cx) { - let project = self.project()?.read(cx); - project.absolute_path(&project_path, cx) - } else { - buffer - .file() - .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) - } - }) - } - - pub fn reveal_in_finder( - &mut self, - _: &RevealInFileManager, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(path) = self.target_file_abs_path(cx) { - if let Some(project) = self.project() { - project.update(cx, |project, cx| project.reveal_path(&path, cx)); - } else { - cx.reveal_path(&path); - } - } - } - - pub fn copy_path( - &mut self, - _: &zed_actions::workspace::CopyPath, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(path) = self.target_file_abs_path(cx) - && let Some(path) = path.to_str() - { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } else { - cx.propagate(); - } - } - - pub fn copy_relative_path( - &mut self, - _: &zed_actions::workspace::CopyRelativePath, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(path) = self.active_buffer(cx).and_then(|buffer| { - let project = self.project()?.read(cx); - let path = buffer.read(cx).file()?.path(); - let path = path.display(project.path_style(cx)); - Some(path) - }) { - cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); - } else { - cx.propagate(); - } - } - - /// Returns the project path for the editor's buffer, if any buffer is - /// opened in the editor. - pub fn project_path(&self, cx: &App) -> Option { - if let Some(buffer) = self.buffer.read(cx).as_singleton() { - buffer.read(cx).project_path(cx) - } else { - None - } - } - - // Returns true if the editor handled a go-to-line request - pub fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) -> bool { - maybe!({ - let breakpoint_store = self.breakpoint_store.as_ref()?; - - let (active_stack_frame, debug_line_pane_id) = { - let store = breakpoint_store.read(cx); - let active_stack_frame = store.active_position().cloned(); - let debug_line_pane_id = store.active_debug_line_pane_id(); - (active_stack_frame, debug_line_pane_id) - }; - - let Some(active_stack_frame) = active_stack_frame else { - self.clear_row_highlights::(); - return None; - }; - - if let Some(debug_line_pane_id) = debug_line_pane_id { - if let Some(workspace) = self - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade()) - { - let editor_pane_id = workspace - .read(cx) - .pane_for_item_id(cx.entity_id()) - .map(|pane| pane.entity_id()); - - if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) { - self.clear_row_highlights::(); - return None; - } - } - } - - let position = active_stack_frame.position; - - let snapshot = self.buffer.read(cx).snapshot(cx); - let multibuffer_anchor = snapshot.anchor_in_excerpt(position)?; - - self.clear_row_highlights::(); - - self.go_to_line::( - multibuffer_anchor, - Some(cx.theme().colors().editor_debugger_active_line_background), - window, - cx, - ); - - cx.notify(); - - Some(()) - }) - .is_some() - } - - pub fn copy_file_name_without_extension( - &mut self, - _: &CopyFileNameWithoutExtension, - _: &mut Window, - cx: &mut Context, - ) { - if let Some(file_stem) = self.active_buffer(cx).and_then(|buffer| { - let file = buffer.read(cx).file()?; - file.path().file_stem() - }) { - cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); - } - } - - pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { - if let Some(file_name) = self.active_buffer(cx).and_then(|buffer| { - let file = buffer.read(cx).file()?; - Some(file.file_name(cx)) - }) { - cx.write_to_clipboard(ClipboardItem::new_string(file_name.to_string())); - } - } - - pub fn toggle_git_blame( - &mut self, - _: &::git::Blame, - window: &mut Window, - cx: &mut Context, - ) { - self.show_git_blame_gutter = !self.show_git_blame_gutter; - - if self.show_git_blame_gutter && !self.has_blame_entries(cx) { - self.start_git_blame(true, window, cx); - } - - cx.notify(); - } - - pub fn toggle_git_blame_inline( - &mut self, - _: &ToggleGitBlameInline, - window: &mut Window, - cx: &mut Context, - ) { - self.toggle_git_blame_inline_internal(true, window, cx); - cx.notify(); - } - - pub fn open_git_blame_commit( - &mut self, - _: &OpenGitBlameCommit, - window: &mut Window, - cx: &mut Context, - ) { - self.open_git_blame_commit_internal(window, cx); - } - - fn open_git_blame_commit_internal( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let blame = self.blame.as_ref()?; - let snapshot = self.snapshot(window, cx); - let cursor = self - .selections - .newest::(&snapshot.display_snapshot) - .head(); - let (buffer, point) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?; - let (_, blame_entry) = blame - .update(cx, |blame, cx| { - blame - .blame_for_rows( - &[RowInfo { - buffer_id: Some(buffer.remote_id()), - buffer_row: Some(point.row), - ..Default::default() - }], - cx, - ) - .next() - }) - .flatten()?; - let renderer = cx.global::().0.clone(); - let repo = blame.read(cx).repository(cx, buffer.remote_id())?; - let workspace = self.workspace()?.downgrade(); - renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); - None - } - - pub fn git_blame_inline_enabled(&self) -> bool { - self.git_blame_inline_enabled - } - - pub fn toggle_selection_menu( - &mut self, - _: &ToggleSelectionMenu, - _: &mut Window, - cx: &mut Context, - ) { - self.show_selection_menu = self - .show_selection_menu - .map(|show_selections_menu| !show_selections_menu) - .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); - - cx.notify(); - } - - pub fn selection_menu_enabled(&self, cx: &App) -> bool { - self.show_selection_menu - .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) - } - - fn start_git_blame( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(project) = self.project() { - if let Some(buffer) = self.buffer().read(cx).as_singleton() - && buffer.read(cx).file().is_none() - { - return; - } - - let focused = self.focus_handle(cx).contains_focused(window, cx); - - let project = project.clone(); - let blame = cx - .new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx)); - self.blame_subscription = - Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); - self.blame = Some(blame); - } - } - - fn toggle_git_blame_inline_internal( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - if self.git_blame_inline_enabled { - self.git_blame_inline_enabled = false; - self.show_git_blame_inline = false; - self.show_git_blame_inline_delay_task.take(); - } else { - self.git_blame_inline_enabled = true; - self.start_git_blame_inline(user_triggered, window, cx); - } - - cx.notify(); - } - - fn start_git_blame_inline( - &mut self, - user_triggered: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.start_git_blame(user_triggered, window, cx); - - if ProjectSettings::get_global(cx) - .git - .inline_blame_delay() - .is_some() - { - self.start_inline_blame_timer(window, cx); - } else { - self.show_git_blame_inline = true - } - } - - pub fn blame(&self) -> Option<&Entity> { - self.blame.as_ref() - } - - pub fn show_git_blame_gutter(&self) -> bool { - self.show_git_blame_gutter - } - - pub fn render_git_blame_gutter(&self, cx: &App) -> bool { - !self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx) - } - - pub fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { - self.show_git_blame_inline - && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) - && !self.newest_selection_head_on_empty_line(cx) - && self.has_blame_entries(cx) - } - - fn has_blame_entries(&self, cx: &App) -> bool { - self.blame() - .is_some_and(|blame| blame.read(cx).has_generated_entries()) - } - - fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool { - let cursor_anchor = self.selections.newest_anchor().head(); - - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_row = MultiBufferRow(cursor_anchor.to_point(&snapshot).row); - - snapshot.line_len(buffer_row) == 0 - } - fn get_permalink_to_line(&self, cx: &mut Context) -> Task> { let buffer_and_selection = maybe!({ let selection = self.selections.newest::(&self.display_snapshot(cx)); @@ -25112,19 +19174,6 @@ impl Editor { }); } - fn marked_text_ranges(&self, cx: &App) -> Option>> { - let snapshot = self.buffer.read(cx).read(cx); - let (_, ranges) = self.text_highlights(HighlightKey::InputComposition, cx)?; - Some( - ranges - .iter() - .map(move |range| { - range.start.to_offset_utf16(&snapshot)..range.end.to_offset_utf16(&snapshot) - }) - .collect(), - ) - } - fn selection_replacement_ranges( &self, range: Range, @@ -25318,46 +19367,6 @@ impl Editor { mouse_context_menu::deploy_context_menu(self, None, position, window, cx); } - pub fn replay_insert_event( - &mut self, - text: &str, - relative_utf16_range: Option>, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - cx.emit(EditorEvent::InputIgnored { text: text.into() }); - return; - } - if let Some(relative_utf16_range) = relative_utf16_range { - let selections = self - .selections - .all::(&self.display_snapshot(cx)); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - let new_ranges = selections.into_iter().map(|range| { - let start = MultiBufferOffsetUtf16(OffsetUtf16( - range - .head() - .0 - .0 - .saturating_add_signed(relative_utf16_range.start), - )); - let end = MultiBufferOffsetUtf16(OffsetUtf16( - range - .head() - .0 - .0 - .saturating_add_signed(relative_utf16_range.end), - )); - start..end - }); - s.select_ranges(new_ranges); - }); - } - - self.handle_input(text, window, cx); - } - pub fn is_focused(&self, window: &Window) -> bool { self.focus_handle.is_focused(window) } @@ -25450,90 +19459,6 @@ impl Editor { cx.notify(); } - pub fn observe_pending_input(&mut self, window: &mut Window, cx: &mut Context) { - let mut pending: String = window - .pending_input_keystrokes() - .into_iter() - .flatten() - .filter_map(|keystroke| keystroke.key_char.clone()) - .collect(); - - if !self.input_enabled || self.read_only || !self.focus_handle.is_focused(window) { - pending = "".to_string(); - } - - let existing_pending = self - .text_highlights(HighlightKey::PendingInput, cx) - .map(|(_, ranges)| ranges.to_vec()); - if existing_pending.is_none() && pending.is_empty() { - return; - } - let transaction = - self.transact(window, cx, |this, window, cx| { - let selections = this - .selections - .all::(&this.display_snapshot(cx)); - let edits = selections - .iter() - .map(|selection| (selection.end..selection.end, pending.clone())); - this.edit(edits, cx); - this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(selections.into_iter().enumerate().map(|(ix, sel)| { - sel.start + ix * pending.len()..sel.end + ix * pending.len() - })); - }); - if let Some(existing_ranges) = existing_pending { - let edits = existing_ranges.iter().map(|range| (range.clone(), "")); - this.edit(edits, cx); - } - }); - - let snapshot = self.snapshot(window, cx); - let ranges = self - .selections - .all::(&snapshot.display_snapshot) - .into_iter() - .map(|selection| { - snapshot.buffer_snapshot().anchor_after(selection.end) - ..snapshot - .buffer_snapshot() - .anchor_before(selection.end + pending.len()) - }) - .collect(); - - if pending.is_empty() { - self.clear_highlights(HighlightKey::PendingInput, cx); - } else { - self.highlight_text( - HighlightKey::PendingInput, - ranges, - HighlightStyle { - underline: Some(UnderlineStyle { - thickness: px(1.), - color: None, - wavy: false, - }), - ..Default::default() - }, - cx, - ); - } - - self.ime_transaction = self.ime_transaction.or(transaction); - if let Some(transaction) = self.ime_transaction { - self.buffer.update(cx, |buffer, cx| { - buffer.group_until_transaction(transaction, cx); - }); - } - - if self - .text_highlights(HighlightKey::PendingInput, cx) - .is_none() - { - self.ime_transaction.take(); - } - } - pub fn register_action_renderer( &mut self, listener: impl Fn(&Editor, &mut Window, &mut Context) + 'static, @@ -25640,13 +19565,6 @@ impl Editor { Some(gpui::Point::new(source_x, source_y)) } - pub fn has_visible_completions_menu(&self) -> bool { - !self.edit_prediction_preview_is_active() - && self.context_menu.borrow().as_ref().is_some_and(|menu| { - menu.visible() && matches!(menu, CodeContextMenu::Completions(_)) - }) - } - pub fn register_addon(&mut self, instance: T) { if self.mode.is_minimap() { return; @@ -25861,103 +19779,6 @@ impl Editor { self.read_scroll_position_from_db(item_id, workspace_id, window, cx); } - /// Load folds from the file_folds database table by file path. - /// Used when manually opening a file that was previously closed. - fn load_folds_from_db( - &mut self, - workspace_id: WorkspaceId, - file_path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup - == RestoreOnStartupBehavior::EmptyTab - { - return; - } - - let Some(folds) = EditorDb::global(cx) - .get_file_folds(workspace_id, &file_path) - .log_err() - else { - return; - }; - if folds.is_empty() { - return; - } - - let snapshot = self.buffer.read(cx).snapshot(cx); - let snapshot_len = snapshot.len().0; - - // Helper: search for fingerprint in buffer, return offset if found - let find_fingerprint = |fingerprint: &str, search_start: usize| -> Option { - let search_start = snapshot - .clip_offset(MultiBufferOffset(search_start), Bias::Left) - .0; - let search_end = snapshot_len.saturating_sub(fingerprint.len()); - - let mut byte_offset = search_start; - for ch in snapshot.chars_at(MultiBufferOffset(search_start)) { - if byte_offset > search_end { - break; - } - if snapshot.contains_str_at(MultiBufferOffset(byte_offset), fingerprint) { - return Some(byte_offset); - } - byte_offset += ch.len_utf8(); - } - None - }; - - let mut search_start = 0usize; - - let valid_folds: Vec<_> = folds - .into_iter() - .filter_map(|(stored_start, stored_end, start_fp, end_fp)| { - let sfp = start_fp?; - let efp = end_fp?; - let efp_len = efp.len(); - - let start_matches = stored_start < snapshot_len - && snapshot.contains_str_at(MultiBufferOffset(stored_start), &sfp); - let efp_check_pos = stored_end.saturating_sub(efp_len); - let end_matches = efp_check_pos >= stored_start - && stored_end <= snapshot_len - && snapshot.contains_str_at(MultiBufferOffset(efp_check_pos), &efp); - - let (new_start, new_end) = if start_matches && end_matches { - (stored_start, stored_end) - } else if sfp == efp { - let new_start = find_fingerprint(&sfp, search_start)?; - let fold_len = stored_end - stored_start; - let new_end = new_start + fold_len; - (new_start, new_end) - } else { - let new_start = find_fingerprint(&sfp, search_start)?; - let efp_pos = find_fingerprint(&efp, new_start + sfp.len())?; - let new_end = efp_pos + efp_len; - (new_start, new_end) - }; - - search_start = new_end; - - if new_end <= new_start { - return None; - } - - Some( - snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left) - ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right), - ) - }) - .collect(); - - if !valid_folds.is_empty() { - self.fold_ranges(valid_folds, false, window, cx); - } - } - fn lsp_data_enabled(&self) -> bool { self.enable_lsp_data && self.mode().is_full() } @@ -26346,484 +20167,6 @@ struct CompletionEdit { snippet: Option, } -fn comment_delimiter_for_newline( - start_point: &Point, - buffer: &MultiBufferSnapshot, - language: &LanguageScope, -) -> Option> { - let delimiters = language.line_comment_prefixes(); - let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?; - let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - let comment_candidate = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(max_len_of_delimiter + 2) - .collect::(); - let (delimiter, trimmed_len, is_repl) = delimiters - .iter() - .filter_map(|delimiter| { - let prefix = delimiter.trim_end(); - if comment_candidate.starts_with(prefix) { - let is_repl = if let Some(stripped_comment) = comment_candidate.strip_prefix(prefix) - { - stripped_comment.starts_with(" %%") - } else { - false - }; - Some((delimiter, prefix.len(), is_repl)) - } else { - None - } - }) - .max_by_key(|(_, len, _)| *len)?; - - if let Some(BlockCommentConfig { - start: block_start, .. - }) = language.block_comment() - { - let block_start_trimmed = block_start.trim_end(); - if block_start_trimmed.starts_with(delimiter.trim_end()) { - let line_content = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(block_start_trimmed.len()) - .collect::(); - - if line_content.starts_with(block_start_trimmed) { - return None; - } - } - } - - let cursor_is_placed_after_comment_marker = - num_of_whitespaces + trimmed_len <= start_point.column as usize; - if cursor_is_placed_after_comment_marker { - if !is_repl { - return Some(delimiter.clone()); - } - - let line_content_after_cursor: String = snapshot - .chars_for_range(range) - .skip(start_point.column as usize) - .collect(); - - if line_content_after_cursor.trim().is_empty() { - return None; - } else { - return Some(delimiter.clone()); - } - } else { - None - } -} - -fn documentation_delimiter_for_newline( - start_point: &Point, - buffer: &MultiBufferSnapshot, - language: &LanguageScope, - newline_config: &mut NewlineConfig, -) -> Option> { - let BlockCommentConfig { - start: start_tag, - end: end_tag, - prefix: delimiter, - tab_size: len, - } = language.documentation_comment()?; - let is_within_block_comment = buffer - .language_scope_at(*start_point) - .is_some_and(|scope| scope.override_name() == Some("comment")); - if !is_within_block_comment { - return None; - } - - let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - - // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. - let column = start_point.column; - let cursor_is_after_start_tag = { - let start_tag_len = start_tag.len(); - let start_tag_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(start_tag_len) - .collect::(); - if start_tag_line.starts_with(start_tag.as_ref()) { - num_of_whitespaces + start_tag_len <= column as usize - } else { - false - } - }; - - let cursor_is_after_delimiter = { - let delimiter_trim = delimiter.trim_end(); - let delimiter_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(delimiter_trim.len()) - .collect::(); - if delimiter_line.starts_with(delimiter_trim) { - num_of_whitespaces + delimiter_trim.len() <= column as usize - } else { - false - } - }; - - let mut needs_extra_line = false; - let mut extra_line_additional_indent = IndentSize::spaces(0); - - let cursor_is_before_end_tag_if_exists = { - let mut char_position = 0u32; - let mut end_tag_offset = None; - - 'outer: for chunk in snapshot.text_for_range(range) { - if let Some(byte_pos) = chunk.find(&**end_tag) { - let chars_before_match = chunk[..byte_pos].chars().count() as u32; - end_tag_offset = Some(char_position + chars_before_match); - break 'outer; - } - char_position += chunk.chars().count() as u32; - } - - if let Some(end_tag_offset) = end_tag_offset { - let cursor_is_before_end_tag = column <= end_tag_offset; - if cursor_is_after_start_tag { - if cursor_is_before_end_tag { - needs_extra_line = true; - } - let cursor_is_at_start_of_end_tag = column == end_tag_offset; - if cursor_is_at_start_of_end_tag { - extra_line_additional_indent.len = *len; - } - } - cursor_is_before_end_tag - } else { - true - } - }; - - if (cursor_is_after_start_tag || cursor_is_after_delimiter) - && cursor_is_before_end_tag_if_exists - { - let additional_indent = if cursor_is_after_start_tag { - IndentSize::spaces(*len) - } else { - IndentSize::spaces(0) - }; - - *newline_config = NewlineConfig::Newline { - additional_indent, - extra_line_additional_indent: if needs_extra_line { - Some(extra_line_additional_indent) - } else { - None - }, - prevent_auto_indent: true, - }; - Some(delimiter.clone()) - } else { - None - } -} - -const ORDERED_LIST_MAX_MARKER_LEN: usize = 16; - -fn list_delimiter_for_newline( - start_point: &Point, - buffer: &MultiBufferSnapshot, - language: &LanguageScope, - newline_config: &mut NewlineConfig, -) -> Option> { - let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - - let task_list_entries: Vec<_> = language - .task_list() - .into_iter() - .flat_map(|config| { - config - .prefixes - .iter() - .map(|prefix| (prefix.as_ref(), config.continuation.as_ref())) - }) - .collect(); - let unordered_list_entries: Vec<_> = language - .unordered_list() - .iter() - .map(|marker| (marker.as_ref(), marker.as_ref())) - .collect(); - - let all_entries: Vec<_> = task_list_entries - .into_iter() - .chain(unordered_list_entries) - .collect(); - - if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() { - let candidate: String = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(max_prefix_len) - .collect(); - - if let Some((prefix, continuation)) = all_entries - .iter() - .filter(|(prefix, _)| candidate.starts_with(*prefix)) - .max_by_key(|(prefix, _)| prefix.len()) - { - let end_of_prefix = num_of_whitespaces + prefix.len(); - let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; - let has_content_after_marker = snapshot - .chars_for_range(range) - .skip(end_of_prefix) - .any(|c| !c.is_whitespace()); - - if has_content_after_marker && cursor_is_after_prefix { - return Some((*continuation).into()); - } - - if start_point.column as usize == end_of_prefix { - if num_of_whitespaces == 0 { - *newline_config = NewlineConfig::ClearCurrentLine; - } else { - *newline_config = NewlineConfig::UnindentCurrentLine { - continuation: (*continuation).into(), - }; - } - } - - return None; - } - } - - let candidate: String = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(ORDERED_LIST_MAX_MARKER_LEN) - .collect(); - - for ordered_config in language.ordered_list() { - let regex = match Regex::new(&ordered_config.pattern) { - Ok(r) => r, - Err(_) => continue, - }; - - if let Some(captures) = regex.captures(&candidate) { - let full_match = captures.get(0)?; - let marker_len = full_match.len(); - let end_of_prefix = num_of_whitespaces + marker_len; - let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; - - let has_content_after_marker = snapshot - .chars_for_range(range) - .skip(end_of_prefix) - .any(|c| !c.is_whitespace()); - - if has_content_after_marker && cursor_is_after_prefix { - let number: u32 = captures.get(1)?.as_str().parse().ok()?; - let continuation = ordered_config - .format - .replace("{1}", &(number + 1).to_string()); - return Some(continuation.into()); - } - - if start_point.column as usize == end_of_prefix { - let continuation = ordered_config.format.replace("{1}", "1"); - if num_of_whitespaces == 0 { - *newline_config = NewlineConfig::ClearCurrentLine; - } else { - *newline_config = NewlineConfig::UnindentCurrentLine { - continuation: continuation.into(), - }; - } - } - - return None; - } - } - - None -} - -fn is_list_prefix_row( - row: MultiBufferRow, - buffer: &MultiBufferSnapshot, - language: &LanguageScope, -) -> bool { - let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { - return false; - }; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - - let task_list_prefixes: Vec<_> = language - .task_list() - .into_iter() - .flat_map(|config| { - config - .prefixes - .iter() - .map(|p| p.as_ref()) - .collect::>() - }) - .collect(); - let unordered_list_markers: Vec<_> = language - .unordered_list() - .iter() - .map(|marker| marker.as_ref()) - .collect(); - let all_prefixes: Vec<_> = task_list_prefixes - .into_iter() - .chain(unordered_list_markers) - .collect(); - if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() { - let candidate: String = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(max_prefix_len) - .collect(); - if all_prefixes - .iter() - .any(|prefix| candidate.starts_with(*prefix)) - { - return true; - } - } - - let ordered_list_candidate: String = snapshot - .chars_for_range(range) - .skip(num_of_whitespaces) - .take(ORDERED_LIST_MAX_MARKER_LEN) - .collect(); - for ordered_config in language.ordered_list() { - let regex = match Regex::new(&ordered_config.pattern) { - Ok(r) => r, - Err(_) => continue, - }; - if let Some(captures) = regex.captures(&ordered_list_candidate) { - return captures.get(0).is_some(); - } - } - - false -} - -#[derive(Debug)] -enum NewlineConfig { - /// Insert newline with optional additional indent and optional extra blank line - Newline { - additional_indent: IndentSize, - extra_line_additional_indent: Option, - prevent_auto_indent: bool, - }, - /// Clear the current line - ClearCurrentLine, - /// Unindent the current line and add continuation - UnindentCurrentLine { continuation: Arc }, -} - -impl NewlineConfig { - fn has_extra_line(&self) -> bool { - matches!( - self, - Self::Newline { - extra_line_additional_indent: Some(_), - .. - } - ) - } - - fn insert_extra_newline_brackets( - buffer: &MultiBufferSnapshot, - range: Range, - language: &language::LanguageScope, - ) -> bool { - let leading_whitespace_len = buffer - .reversed_chars_at(range.start) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let trailing_whitespace_len = buffer - .chars_at(range.end) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; - - language.brackets().any(|(pair, enabled)| { - let pair_start = pair.start.trim_end(); - let pair_end = pair.end.trim_start(); - - enabled - && pair.newline - && buffer.contains_str_at(range.end, pair_end) - && buffer.contains_str_at( - range.start.saturating_sub_usize(pair_start.len()), - pair_start, - ) - }) - } - - fn insert_extra_newline_tree_sitter( - buffer: &MultiBufferSnapshot, - range: Range, - ) -> bool { - let (buffer, range) = match buffer - .range_to_buffer_ranges(range.start..range.end) - .as_slice() - { - [(buffer_snapshot, range, _)] => (buffer_snapshot.clone(), range.clone()), - _ => return false, - }; - let pair = { - let mut result: Option> = None; - - for pair in buffer - .all_bracket_ranges(range.start.0..range.end.0) - .filter(move |pair| { - pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 - }) - { - let len = pair.close_range.end - pair.open_range.start; - - if let Some(existing) = &result { - let existing_len = existing.close_range.end - existing.open_range.start; - if len > existing_len { - continue; - } - } - - result = Some(pair); - } - - result - }; - let Some(pair) = pair else { - return false; - }; - pair.newline_only - && buffer - .chars_for_range(pair.open_range.end..range.start.0) - .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) - .all(|c| c.is_whitespace() && c != '\n') - } -} - fn update_uncommitted_diff_for_buffer( editor: Entity, project: &Entity, @@ -26853,393 +20196,6 @@ fn update_uncommitted_diff_for_buffer( }) } -fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { - let tab_size = tab_size.get() as usize; - let mut width = offset; - - for ch in text.chars() { - width += if ch == '\t' { - tab_size - (width % tab_size) - } else { - 1 - }; - } - - width - offset -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_size_with_expanded_tabs() { - let nz = |val| NonZeroU32::new(val).unwrap(); - assert_eq!(char_len_with_expanded_tabs(0, "", nz(4)), 0); - assert_eq!(char_len_with_expanded_tabs(0, "hello", nz(4)), 5); - assert_eq!(char_len_with_expanded_tabs(0, "\thello", nz(4)), 9); - assert_eq!(char_len_with_expanded_tabs(0, "abc\tab", nz(4)), 6); - assert_eq!(char_len_with_expanded_tabs(0, "hello\t", nz(4)), 8); - assert_eq!(char_len_with_expanded_tabs(0, "\t\t", nz(8)), 16); - assert_eq!(char_len_with_expanded_tabs(0, "x\t", nz(8)), 8); - assert_eq!(char_len_with_expanded_tabs(7, "x\t", nz(8)), 9); - } -} - -/// Tokenizes a string into runs of text that should stick together, or that is whitespace. -struct WordBreakingTokenizer<'a> { - input: &'a str, -} - -impl<'a> WordBreakingTokenizer<'a> { - fn new(input: &'a str) -> Self { - Self { input } - } -} - -fn is_char_ideographic(ch: char) -> bool { - use unicode_script::Script::*; - use unicode_script::UnicodeScript; - matches!(ch.script(), Han | Tangut | Yi) -} - -fn is_grapheme_ideographic(text: &str) -> bool { - text.chars().any(is_char_ideographic) -} - -fn is_grapheme_whitespace(text: &str) -> bool { - text.chars().any(|x| x.is_whitespace()) -} - -fn should_stay_with_preceding_ideograph(text: &str) -> bool { - text.chars() - .next() - .is_some_and(|ch| matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…')) -} - -#[derive(PartialEq, Eq, Debug, Clone, Copy)] -enum WordBreakToken<'a> { - Word { token: &'a str, grapheme_len: usize }, - InlineWhitespace { token: &'a str, grapheme_len: usize }, - Newline, -} - -impl<'a> Iterator for WordBreakingTokenizer<'a> { - /// Yields a span, the count of graphemes in the token, and whether it was - /// whitespace. Note that it also breaks at word boundaries. - type Item = WordBreakToken<'a>; - - fn next(&mut self) -> Option { - use unicode_segmentation::UnicodeSegmentation; - if self.input.is_empty() { - return None; - } - - let mut iter = self.input.graphemes(true).peekable(); - let mut offset = 0; - let mut grapheme_len = 0; - if let Some(first_grapheme) = iter.next() { - let is_newline = first_grapheme == "\n"; - let is_whitespace = is_grapheme_whitespace(first_grapheme); - offset += first_grapheme.len(); - grapheme_len += 1; - if is_grapheme_ideographic(first_grapheme) && !is_whitespace { - if let Some(grapheme) = iter.peek().copied() - && should_stay_with_preceding_ideograph(grapheme) - { - offset += grapheme.len(); - grapheme_len += 1; - } - } else { - let mut words = self.input[offset..].split_word_bound_indices().peekable(); - let mut next_word_bound = words.peek().copied(); - if next_word_bound.is_some_and(|(i, _)| i == 0) { - next_word_bound = words.next(); - } - while let Some(grapheme) = iter.peek().copied() { - if next_word_bound.is_some_and(|(i, _)| i == offset) { - break; - }; - if is_grapheme_whitespace(grapheme) != is_whitespace - || (grapheme == "\n") != is_newline - { - break; - }; - offset += grapheme.len(); - grapheme_len += 1; - iter.next(); - } - } - let token = &self.input[..offset]; - self.input = &self.input[offset..]; - if token == "\n" { - Some(WordBreakToken::Newline) - } else if is_whitespace { - Some(WordBreakToken::InlineWhitespace { - token, - grapheme_len, - }) - } else { - Some(WordBreakToken::Word { - token, - grapheme_len, - }) - } - } else { - None - } - } -} - -#[test] -fn test_word_breaking_tokenizer() { - let tests: &[(&str, &[WordBreakToken<'static>])] = &[ - ("", &[]), - (" ", &[whitespace(" ", 2)]), - ("Ʒ", &[word("Ʒ", 1)]), - ("Ǽ", &[word("Ǽ", 1)]), - ("⋑", &[word("⋑", 1)]), - ("⋑⋑", &[word("⋑⋑", 2)]), - ( - "原理,进而", - &[word("原", 1), word("理,", 2), word("进", 1), word("而", 1)], - ), - ( - "hello world", - &[word("hello", 5), whitespace(" ", 1), word("world", 5)], - ), - ( - "hello, world", - &[word("hello,", 6), whitespace(" ", 1), word("world", 5)], - ), - ( - " hello world", - &[ - whitespace(" ", 2), - word("hello", 5), - whitespace(" ", 1), - word("world", 5), - ], - ), - ( - "这是什么 \n 钢笔", - &[ - word("这", 1), - word("是", 1), - word("什", 1), - word("么", 1), - whitespace(" ", 1), - newline(), - whitespace(" ", 1), - word("钢", 1), - word("笔", 1), - ], - ), - (" mutton", &[whitespace(" ", 1), word("mutton", 6)]), - ]; - - fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { - WordBreakToken::Word { - token, - grapheme_len, - } - } - - fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { - WordBreakToken::InlineWhitespace { - token, - grapheme_len, - } - } - - fn newline() -> WordBreakToken<'static> { - WordBreakToken::Newline - } - - for (input, result) in tests { - assert_eq!( - WordBreakingTokenizer::new(input) - .collect::>() - .as_slice(), - *result, - ); - } -} - -fn wrap_with_prefix( - first_line_prefix: String, - subsequent_lines_prefix: String, - unwrapped_text: String, - wrap_column: usize, - tab_size: NonZeroU32, - preserve_existing_whitespace: bool, -) -> String { - let first_line_prefix_len = char_len_with_expanded_tabs(0, &first_line_prefix, tab_size); - let subsequent_lines_prefix_len = - char_len_with_expanded_tabs(0, &subsequent_lines_prefix, tab_size); - let mut wrapped_text = String::new(); - let mut current_line = first_line_prefix; - let mut is_first_line = true; - - let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); - let mut current_line_len = first_line_prefix_len; - let mut in_whitespace = false; - for token in tokenizer { - let have_preceding_whitespace = in_whitespace; - match token { - WordBreakToken::Word { - token, - grapheme_len, - } => { - in_whitespace = false; - let current_prefix_len = if is_first_line { - first_line_prefix_len - } else { - subsequent_lines_prefix_len - }; - if current_line_len + grapheme_len > wrap_column - && current_line_len != current_prefix_len - { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - is_first_line = false; - current_line = subsequent_lines_prefix.clone(); - current_line_len = subsequent_lines_prefix_len; - } - current_line.push_str(token); - current_line_len += grapheme_len; - } - WordBreakToken::InlineWhitespace { - mut token, - mut grapheme_len, - } => { - in_whitespace = true; - if have_preceding_whitespace && !preserve_existing_whitespace { - continue; - } - if !preserve_existing_whitespace { - // Keep a single whitespace grapheme as-is - if let Some(first) = - unicode_segmentation::UnicodeSegmentation::graphemes(token, true).next() - { - token = first; - } else { - token = " "; - } - grapheme_len = 1; - } - let current_prefix_len = if is_first_line { - first_line_prefix_len - } else { - subsequent_lines_prefix_len - }; - if current_line_len + grapheme_len > wrap_column { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - is_first_line = false; - current_line = subsequent_lines_prefix.clone(); - current_line_len = subsequent_lines_prefix_len; - } else if current_line_len != current_prefix_len || preserve_existing_whitespace { - current_line.push_str(token); - current_line_len += grapheme_len; - } - } - WordBreakToken::Newline => { - in_whitespace = true; - let current_prefix_len = if is_first_line { - first_line_prefix_len - } else { - subsequent_lines_prefix_len - }; - if preserve_existing_whitespace { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - is_first_line = false; - current_line = subsequent_lines_prefix.clone(); - current_line_len = subsequent_lines_prefix_len; - } else if have_preceding_whitespace { - continue; - } else if current_line_len + 1 > wrap_column - && current_line_len != current_prefix_len - { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - is_first_line = false; - current_line = subsequent_lines_prefix.clone(); - current_line_len = subsequent_lines_prefix_len; - } else if current_line_len != current_prefix_len { - current_line.push(' '); - current_line_len += 1; - } - } - } - } - - if !current_line.is_empty() { - wrapped_text.push_str(¤t_line); - } - wrapped_text -} - -#[test] -fn test_wrap_with_prefix() { - assert_eq!( - wrap_with_prefix( - "# ".to_string(), - "# ".to_string(), - "abcdefg".to_string(), - 4, - NonZeroU32::new(4).unwrap(), - false, - ), - "# abcdefg" - ); - assert_eq!( - wrap_with_prefix( - "".to_string(), - "".to_string(), - "\thello world".to_string(), - 8, - NonZeroU32::new(4).unwrap(), - false, - ), - "hello\nworld" - ); - assert_eq!( - wrap_with_prefix( - "// ".to_string(), - "// ".to_string(), - "xx \nyy zz aa bb cc".to_string(), - 12, - NonZeroU32::new(4).unwrap(), - false, - ), - "// xx yy zz\n// aa bb cc" - ); - assert_eq!( - wrap_with_prefix( - String::new(), - String::new(), - "这是什么 \n 钢笔".to_string(), - 3, - NonZeroU32::new(4).unwrap(), - false, - ), - "这是什\n么 钢\n笔" - ); - assert_eq!( - wrap_with_prefix( - String::new(), - String::new(), - format!("foo{}bar", '\u{2009}'), // thin space - 80, - NonZeroU32::new(4).unwrap(), - false, - ), - format!("foo{}bar", '\u{2009}') - ); -} - pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap; fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap; @@ -27337,478 +20293,6 @@ pub trait SemanticsProvider { ) -> Option>>; } -pub trait CompletionProvider { - fn completions( - &self, - buffer: &Entity, - buffer_position: text::Anchor, - trigger: CompletionContext, - window: &mut Window, - cx: &mut Context, - ) -> Task>>; - - fn resolve_completions( - &self, - _buffer: Entity, - _completion_indices: Vec, - _completions: Rc>>, - _cx: &mut Context, - ) -> Task> { - Task::ready(Ok(false)) - } - - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _all_commit_ranges: Vec>, - _cx: &mut Context, - ) -> Task>> { - Task::ready(Ok(None)) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool; - - fn selection_changed(&self, _mat: Option<&StringMatch>, _window: &mut Window, _cx: &mut App) {} - - fn sort_completions(&self) -> bool { - true - } - - fn filter_completions(&self) -> bool { - true - } - - fn show_snippets(&self) -> bool { - false - } -} - -pub trait CodeActionProvider { - fn id(&self) -> Arc; - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - window: &mut Window, - cx: &mut App, - ) -> Task>>; - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - push_to_history: bool, - window: &mut Window, - cx: &mut App, - ) -> Task>; -} - -impl CodeActionProvider for Entity { - fn id(&self) -> Arc { - "project".into() - } - - fn code_actions( - &self, - buffer: &Entity, - range: Range, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - self.update(cx, |project, cx| { - let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { - Some(project.code_lens_actions(buffer, range.clone(), cx)) - } else { - None - }; - let code_actions = project.code_actions(buffer, range, None, cx); - cx.background_spawn(async move { - let code_lens_actions = match code_lens_actions { - Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), - None => Vec::new(), - }; - let code_actions = code_actions - .await - .context("code action fetch")? - .unwrap_or_default(); - Ok(code_lens_actions.into_iter().chain(code_actions).collect()) - }) - }) - } - - fn apply_code_action( - &self, - buffer_handle: Entity, - action: CodeAction, - push_to_history: bool, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - self.update(cx, |project, cx| { - project.apply_code_action(buffer_handle, action, push_to_history, cx) - }) - } -} - -fn has_strong_snippet_prefix_match( - project: &Project, - buffer: &Entity, - buffer_anchor: text::Anchor, - classifier: &CharClassifier, - query: &str, - cx: &App, -) -> bool { - if query.chars().take(2).count() < 2 { - return false; - } - - let query = query.to_lowercase(); - let is_word_char = |character| classifier.is_word(character); - let languages = buffer.read(cx).languages_at(buffer_anchor); - let snippet_store = project.snippets().read(cx); - - languages.iter().any(|language| { - snippet_store - .snippets_for(Some(language.lsp_id()), cx) - .iter() - .flat_map(|snippet| snippet.prefix.iter()) - .flat_map(|prefix| snippet_candidate_suffixes(prefix, &is_word_char)) - .any(|candidate| candidate.to_lowercase().starts_with(&query)) - }) -} - -fn snippet_completions( - project: &Project, - buffer: &Entity, - buffer_anchor: text::Anchor, - classifier: CharClassifier, - cx: &mut App, -) -> Task> { - let languages = buffer.read(cx).languages_at(buffer_anchor); - let snippet_store = project.snippets().read(cx); - - let scopes: Vec<_> = languages - .iter() - .filter_map(|language| { - let language_name = language.lsp_id(); - let snippets = snippet_store.snippets_for(Some(language_name), cx); - - if snippets.is_empty() { - None - } else { - Some((language.default_scope(), snippets)) - } - }) - .collect(); - - if scopes.is_empty() { - return Task::ready(Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: false, - })); - } - - let snapshot = buffer.read(cx).text_snapshot(); - let executor = cx.background_executor().clone(); - - cx.background_spawn(async move { - let is_word_char = |c| classifier.is_word(c); - - let mut is_incomplete = false; - let mut completions: Vec = Vec::new(); - - const MAX_PREFIX_LEN: usize = 128; - let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot); - let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN); - let window_start = snapshot.clip_offset(window_start, Bias::Left); - - let max_buffer_window: String = snapshot - .text_for_range(window_start..buffer_offset) - .collect(); - - if max_buffer_window.is_empty() { - return Ok(CompletionResponse { - completions: vec![], - display_options: CompletionDisplayOptions::default(), - is_incomplete: true, - }); - } - - for (_scope, snippets) in scopes.into_iter() { - // Sort snippets by word count to match longer snippet prefixes first. - let mut sorted_snippet_candidates = snippets - .iter() - .enumerate() - .flat_map(|(snippet_ix, snippet)| { - snippet - .prefix - .iter() - .enumerate() - .map(move |(prefix_ix, prefix)| { - let word_count = - snippet_candidate_suffixes(prefix, &is_word_char).count(); - ((snippet_ix, prefix_ix), prefix, word_count) - }) - }) - .collect_vec(); - sorted_snippet_candidates - .sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count)); - - // Each prefix may be matched multiple times; the completion menu must filter out duplicates. - - let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, &is_word_char) - .take( - sorted_snippet_candidates - .first() - .map(|(_, _, word_count)| *word_count) - .unwrap_or_default(), - ) - .collect_vec(); - - const MAX_RESULTS: usize = 100; - // Each match also remembers how many characters from the buffer it consumed - let mut matches: Vec<(StringMatch, usize)> = vec![]; - - let mut snippet_list_cutoff_index = 0; - for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() { - let word_count = buffer_index + 1; - // Increase `snippet_list_cutoff_index` until we have all of the - // snippets with sufficiently many words. - while sorted_snippet_candidates - .get(snippet_list_cutoff_index) - .is_some_and(|(_ix, _prefix, snippet_word_count)| { - *snippet_word_count >= word_count - }) - { - snippet_list_cutoff_index += 1; - } - - // Take only the candidates with at least `word_count` many words - let snippet_candidates_at_word_len = - &sorted_snippet_candidates[..snippet_list_cutoff_index]; - - let candidates = snippet_candidates_at_word_len - .iter() - .map(|(_snippet_ix, prefix, _snippet_word_count)| prefix) - .enumerate() // index in `sorted_snippet_candidates` - // First char must match - .filter(|(_ix, prefix)| { - itertools::equal( - prefix - .chars() - .next() - .into_iter() - .flat_map(|c| c.to_lowercase()), - buffer_window - .chars() - .next() - .into_iter() - .flat_map(|c| c.to_lowercase()), - ) - }) - .map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix)) - .collect::>(); - - matches.extend( - fuzzy::match_strings( - &candidates, - &buffer_window, - buffer_window.chars().any(|c| c.is_uppercase()), - true, - MAX_RESULTS - matches.len(), // always prioritize longer snippets - &Default::default(), - executor.clone(), - ) - .await - .into_iter() - .map(|string_match| (string_match, buffer_window.len())), - ); - - if matches.len() >= MAX_RESULTS { - break; - } - } - - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_anchor); - - if matches.len() >= MAX_RESULTS { - is_incomplete = true; - } - - completions.extend(matches.iter().map(|(string_match, buffer_window_len)| { - let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) = - sorted_snippet_candidates[string_match.candidate_id]; - let snippet = &snippets[snippet_index]; - let start = buffer_offset - buffer_window_len; - let start = snapshot.anchor_before(start); - let range = start..buffer_anchor; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Completion { - replace_range: range, - new_text: snippet.body.clone(), - source: CompletionSource::Lsp { - insert_range: None, - server_id: LanguageServerId(usize::MAX), - resolved: true, - lsp_completion: Box::new(lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..lsp::CompletionItem::default() - }), - lsp_defaults: None, - }, - label: CodeLabel { - text: matching_prefix.clone(), - runs: Vec::new(), - filter_range: 0..matching_prefix.len(), - }, - icon_path: None, - documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { - single_line: snippet.name.clone().into(), - plain_text: snippet - .description - .clone() - .map(|description| description.into()), - }), - insert_text_mode: None, - confirm: None, - match_start: Some(start), - snippet_deduplication_key: Some((snippet_index, prefix_index)), - } - })); - } - - Ok(CompletionResponse { - completions, - display_options: CompletionDisplayOptions::default(), - is_incomplete, - }) - }) -} - -impl CompletionProvider for Entity { - fn completions( - &self, - buffer: &Entity, - buffer_position: text::Anchor, - options: CompletionContext, - _window: &mut Window, - cx: &mut Context, - ) -> Task>> { - self.update(cx, |project, cx| { - let task = project.completions(buffer, buffer_position, options, cx); - cx.background_spawn(task) - }) - } - - fn resolve_completions( - &self, - buffer: Entity, - completion_indices: Vec, - completions: Rc>>, - cx: &mut Context, - ) -> Task> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.resolve_completions(buffer, completion_indices, completions, cx) - }) - }) - } - - fn apply_additional_edits_for_completion( - &self, - buffer: Entity, - completions: Rc>>, - completion_index: usize, - push_to_history: bool, - all_commit_ranges: Vec>, - cx: &mut Context, - ) -> Task>> { - self.update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.apply_additional_edits_for_completion( - buffer, - completions, - completion_index, - push_to_history, - all_commit_ranges, - cx, - ) - }) - }) - } - - fn is_completion_trigger( - &self, - buffer: &Entity, - position: language::Anchor, - text: &str, - trigger_in_words: bool, - cx: &mut Context, - ) -> bool { - let mut chars = text.chars(); - let char = if let Some(char) = chars.next() { - char - } else { - return false; - }; - if chars.next().is_some() { - return false; - } - - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); - let classifier = snapshot - .char_classifier_at(position) - .scope_context(Some(CharScopeContext::Completion)); - if trigger_in_words && classifier.is_word(char) { - return true; - } - - buffer.completion_triggers().contains(text) - } - - fn show_snippets(&self) -> bool { - true - } -} - impl SemanticsProvider for WeakEntity { fn hover( &self, @@ -28285,87 +20769,6 @@ impl EditorSnapshot { } } - pub fn render_crease_toggle( - &self, - buffer_row: MultiBufferRow, - row_contains_cursor: bool, - editor: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - let mut is_foldable = false; - - if let Some(crease) = self - .crease_snapshot - .query_row(buffer_row, self.buffer_snapshot()) - { - is_foldable = true; - match crease { - Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { - if let Some(render_toggle) = render_toggle { - let toggle_callback = - Arc::new(move |folded, window: &mut Window, cx: &mut App| { - if folded { - editor.update(cx, |editor, cx| { - editor.fold_at(buffer_row, window, cx) - }); - } else { - editor.update(cx, |editor, cx| { - editor.unfold_at(buffer_row, window, cx) - }); - } - }); - return Some((render_toggle)( - buffer_row, - folded, - toggle_callback, - window, - cx, - )); - } - } - } - } - - is_foldable |= !self.use_lsp_folding_ranges && self.starts_indent(buffer_row); - - if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { - Some( - Disclosure::new(("gutter_crease", buffer_row.0), !folded) - .toggle_state(folded) - .on_click(window.listener_for(&editor, move |this, _e, window, cx| { - if folded { - this.unfold_at(buffer_row, window, cx); - } else { - this.fold_at(buffer_row, window, cx); - } - })) - .into_any_element(), - ) - } else { - None - } - } - - pub fn render_crease_trailer( - &self, - buffer_row: MultiBufferRow, - window: &mut Window, - cx: &mut App, - ) -> Option { - let folded = self.is_line_folded(buffer_row); - if let Crease::Inline { render_trailer, .. } = self - .crease_snapshot - .query_row(buffer_row, self.buffer_snapshot())? - { - let render_trailer = render_trailer.as_ref()?; - Some(render_trailer(buffer_row, folded, window, cx)) - } else { - None - } - } - pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { let digit_count = self.widest_line_number().ilog10() + 1; column_pixels(style, digit_count as usize, window) @@ -28562,353 +20965,6 @@ impl Render for Editor { } } -impl EntityInputHandler for Editor { - fn text_for_range( - &mut self, - range_utf16: Range, - adjusted_range: &mut Option>, - _: &mut Window, - cx: &mut Context, - ) -> Option { - let snapshot = self.buffer.read(cx).read(cx); - let start = snapshot.clip_offset_utf16( - MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)), - Bias::Left, - ); - let end = snapshot.clip_offset_utf16( - MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)), - Bias::Right, - ); - if (start.0.0..end.0.0) != range_utf16 { - adjusted_range.replace(start.0.0..end.0.0); - } - Some(snapshot.text_for_range(start..end).collect()) - } - - fn selected_text_range( - &mut self, - ignore_disabled_input: bool, - _: &mut Window, - cx: &mut Context, - ) -> Option { - // Prevent the IME menu from appearing when holding down an alphabetic key - // while input is disabled. - if !ignore_disabled_input && !self.input_enabled { - return None; - } - - let selection = self - .selections - .newest::(&self.display_snapshot(cx)); - let range = selection.range(); - - Some(UTF16Selection { - range: range.start.0.0..range.end.0.0, - reversed: selection.reversed, - }) - } - - fn marked_text_range(&self, _: &mut Window, cx: &mut Context) -> Option> { - let snapshot = self.buffer.read(cx).read(cx); - let range = self - .text_highlights(HighlightKey::InputComposition, cx)? - .1 - .first()?; - Some(range.start.to_offset_utf16(&snapshot).0.0..range.end.to_offset_utf16(&snapshot).0.0) - } - - fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { - self.clear_highlights(HighlightKey::InputComposition, cx); - self.ime_transaction.take(); - } - - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - text: &str, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - cx.emit(EditorEvent::InputIgnored { text: text.into() }); - return; - } - - self.transact(window, cx, |this, window, cx| { - let new_selected_ranges = if let Some(range_utf16) = range_utf16 { - if let Some(marked_ranges) = this.marked_text_ranges(cx) { - // During IME composition, macOS reports the replacement range - // relative to the first marked region (the only one visible via - // marked_text_range). The correct targets for replacement are the - // marked ranges themselves — one per cursor — so use them directly. - Some(marked_ranges) - } else if range_utf16.start == range_utf16.end { - // An empty replacement range means "insert at cursor" with no text - // to replace. macOS reports the cursor position from its own - // (single-cursor) view of the buffer, which diverges from our actual - // cursor positions after multi-cursor edits have shifted offsets. - // Treating this as range_utf16=None lets each cursor insert in place. - None - } else { - // Outside of IME composition (e.g. Accessibility Keyboard word - // completion), the range is an absolute document offset for the - // newest cursor. Fan it out to all cursors via - // selection_replacement_ranges, which applies the delta relative - // to the newest selection to every cursor. - let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) - ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } - } else { - this.marked_text_ranges(cx) - }; - - let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { - let newest_selection_id = this.selections.newest_anchor().id; - this.selections - .all::(&this.display_snapshot(cx)) - .iter() - .zip(ranges_to_replace.iter()) - .find_map(|(selection, range)| { - if selection.id == newest_selection_id { - Some( - (range.start.0.0 as isize - selection.head().0.0 as isize) - ..(range.end.0.0 as isize - selection.head().0.0 as isize), - ) - } else { - None - } - }) - }); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: range_to_replace, - text: text.into(), - }); - - if let Some(new_selected_ranges) = new_selected_ranges { - // Only backspace if at least one range covers actual text. When all - // ranges are empty (e.g. a trailing-space insertion from Accessibility - // Keyboard sends replacementRange=cursor..cursor), backspace would - // incorrectly delete the character just before the cursor. - let should_backspace = new_selected_ranges.iter().any(|r| r.start != r.end); - this.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - if should_backspace { - this.backspace(&Default::default(), window, cx); - } - } - - this.handle_input(text, window, cx); - }); - - if let Some(transaction) = self.ime_transaction { - self.buffer.update(cx, |buffer, cx| { - buffer.group_until_transaction(transaction, cx); - }); - } - - self.unmark_text(window, cx); - } - - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - text: &str, - new_selected_range_utf16: Option>, - window: &mut Window, - cx: &mut Context, - ) { - if !self.input_enabled { - return; - } - - let transaction = self.transact(window, cx, |this, window, cx| { - let ranges_to_replace = if let Some(mut marked_ranges) = this.marked_text_ranges(cx) { - let snapshot = this.buffer.read(cx).read(cx); - if let Some(relative_range_utf16) = range_utf16.as_ref() { - for marked_range in &mut marked_ranges { - marked_range.end = marked_range.start + relative_range_utf16.end; - marked_range.start += relative_range_utf16.start; - marked_range.start = - snapshot.clip_offset_utf16(marked_range.start, Bias::Left); - marked_range.end = - snapshot.clip_offset_utf16(marked_range.end, Bias::Right); - } - } - Some(marked_ranges) - } else if let Some(range_utf16) = range_utf16 { - let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) - ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } else { - None - }; - - let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { - let newest_selection_id = this.selections.newest_anchor().id; - this.selections - .all::(&this.display_snapshot(cx)) - .iter() - .zip(ranges_to_replace.iter()) - .find_map(|(selection, range)| { - if selection.id == newest_selection_id { - Some( - (range.start.0.0 as isize - selection.head().0.0 as isize) - ..(range.end.0.0 as isize - selection.head().0.0 as isize), - ) - } else { - None - } - }) - }); - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: range_to_replace, - text: text.into(), - }); - - if let Some(ranges) = ranges_to_replace { - this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges(ranges) - }); - } - - let marked_ranges = { - let snapshot = this.buffer.read(cx).read(cx); - this.selections - .disjoint_anchors_arc() - .iter() - .map(|selection| { - selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot) - }) - .collect::>() - }; - - if text.is_empty() { - this.unmark_text(window, cx); - } else { - this.highlight_text( - HighlightKey::InputComposition, - marked_ranges.clone(), - HighlightStyle { - underline: Some(UnderlineStyle { - thickness: px(1.), - color: None, - wavy: false, - }), - ..Default::default() - }, - cx, - ); - } - - // Disable auto-closing when composing text (i.e. typing a `"` on a Brazilian keyboard) - let use_autoclose = this.use_autoclose; - let use_auto_surround = this.use_auto_surround; - this.set_use_autoclose(false); - this.set_use_auto_surround(false); - this.handle_input(text, window, cx); - this.set_use_autoclose(use_autoclose); - this.set_use_auto_surround(use_auto_surround); - - if let Some(new_selected_range) = new_selected_range_utf16 { - let snapshot = this.buffer.read(cx).read(cx); - let new_selected_ranges = marked_ranges - .into_iter() - .map(|marked_range| { - let insertion_start = marked_range.start.to_offset_utf16(&snapshot).0; - let new_start = MultiBufferOffsetUtf16(OffsetUtf16( - insertion_start.0 + new_selected_range.start, - )); - let new_end = MultiBufferOffsetUtf16(OffsetUtf16( - insertion_start.0 + new_selected_range.end, - )); - snapshot.clip_offset_utf16(new_start, Bias::Left) - ..snapshot.clip_offset_utf16(new_end, Bias::Right) - }) - .collect::>(); - - drop(snapshot); - this.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - } - }); - - self.ime_transaction = self.ime_transaction.or(transaction); - if let Some(transaction) = self.ime_transaction { - self.buffer.update(cx, |buffer, cx| { - buffer.group_until_transaction(transaction, cx); - }); - } - - if self - .text_highlights(HighlightKey::InputComposition, cx) - .is_none() - { - self.ime_transaction.take(); - } - } - - fn bounds_for_range( - &mut self, - range_utf16: Range, - element_bounds: gpui::Bounds, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let text_layout_details = self.text_layout_details(window, cx); - let CharacterDimensions { - em_width, - em_advance, - line_height, - } = self.character_dimensions(window, cx); - - let snapshot = self.snapshot(window, cx); - let scroll_position = snapshot.scroll_position(); - let scroll_left = scroll_position.x * ScrollOffset::from(em_advance); - - let start = - MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)).to_display_point(&snapshot); - let x = Pixels::from( - ScrollOffset::from( - snapshot.x_for_display_point(start, &text_layout_details) - + self.gutter_dimensions.full_width(), - ) - scroll_left, - ); - let y = line_height * (start.row().as_f64() - scroll_position.y) as f32; - - Some(Bounds { - origin: element_bounds.origin + point(x, y), - size: size(em_width, line_height), - }) - } - - fn character_index_for_point( - &mut self, - point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let position_map = self.last_position_map.as_ref()?; - if !position_map.text_hitbox.contains(&point) { - return None; - } - let display_point = position_map.point_for_position(point).previous_valid; - let anchor = position_map - .snapshot - .display_point_to_anchor(display_point, Bias::Left); - let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot()); - Some(utf16_offset.0.0) - } - - fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { - self.expects_character_input - } -} - trait SelectionExt { fn display_range(&self, map: &DisplaySnapshot) -> Range; fn spanned_rows( @@ -29210,53 +21266,6 @@ pub fn styled_runs_for_code_label<'a>( ) } -pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { - let mut prev_index = 0; - let mut prev_codepoint: Option = None; - text.char_indices() - .chain([(text.len(), '\0')]) - .filter_map(move |(index, codepoint)| { - let prev_codepoint = prev_codepoint.replace(codepoint)?; - let is_boundary = index == text.len() - || !prev_codepoint.is_uppercase() && codepoint.is_uppercase() - || !prev_codepoint.is_alphanumeric() && codepoint.is_alphanumeric(); - if is_boundary { - let chunk = &text[prev_index..index]; - prev_index = index; - Some(chunk) - } else { - None - } - }) -} - -/// Given a string of text immediately before the cursor, iterates over possible -/// strings a snippet could match to. More precisely: returns an iterator over -/// suffixes of `text` created by splitting at word boundaries (before & after -/// every non-word character). -/// -/// Shorter suffixes are returned first. -pub(crate) fn snippet_candidate_suffixes<'a>( - text: &'a str, - is_word_char: &'a dyn Fn(char) -> bool, -) -> impl std::iter::Iterator + 'a { - let mut prev_index = text.len(); - let mut prev_codepoint = None; - text.char_indices() - .rev() - .chain([(0, '\0')]) - .filter_map(move |(index, codepoint)| { - let prev_index = std::mem::replace(&mut prev_index, index); - let prev_codepoint = prev_codepoint.replace(codepoint)?; - if is_word_char(prev_codepoint) && is_word_char(codepoint) { - None - } else { - let chunk = &text[prev_index..]; // go to end of string - Some(chunk) - } - }) -} - pub trait RangeToAnchorExt: Sized { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 304f44d3c38..d71c3f4e4a2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -18,8 +18,8 @@ use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkS use collections::HashMap; use futures::{StreamExt, channel::oneshot}; use gpui::{ - BackgroundExecutor, DismissEvent, Task, TestAppContext, UpdateGlobal, VisualTestContext, - WindowBounds, WindowOptions, div, + BackgroundExecutor, DismissEvent, Task, TaskExt, TestAppContext, UpdateGlobal, + VisualTestContext, WindowBounds, WindowOptions, div, }; use indoc::indoc; use language::{ @@ -8173,7 +8173,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { ) { cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(unwrapped_text); - cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(wrapped_text); } } @@ -8578,7 +8578,7 @@ async fn test_rewrap_block_comments(cx: &mut TestAppContext) { ) { cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.set_state(unwrapped_text); - cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(wrapped_text); } } @@ -8604,7 +8604,7 @@ async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) { cx.set_state(indoc! {" // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ "}); - cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(indoc! {" // Lorem ipsum dolor sit amet, // consectetur adipiscing elit.ˇ @@ -8632,7 +8632,7 @@ async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) { cx.set_state(indoc! {" // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ "}); - cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); + cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx)); cx.assert_editor_state(indoc! {" // Lorem ipsum dolor sit amet, // consectetur adipiscing elit.ˇ diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e751535910..fe3ec5cb462 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -46,9 +46,9 @@ use gpui::{ Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, StyledText, TextAlign, TextRun, TextStyleRefinement, WeakEntity, Window, - anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, pattern_slash, - point, px, quad, relative, size, solid_background, transparent_black, + Style, Styled, StyledText, TaskExt, TextAlign, TextRun, TextStyleRefinement, WeakEntity, + Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, + pattern_slash, point, px, quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{ @@ -570,7 +570,9 @@ impl EditorElement { register_action(editor, window, Editor::move_line_up); register_action(editor, window, Editor::move_line_down); register_action(editor, window, Editor::transpose); - register_action(editor, window, Editor::rewrap); + register_action(editor, window, |editor, _: &crate::Rewrap, _, cx| { + editor.rewrap(crate::RewrapOptions::default(), cx); + }); register_action(editor, window, Editor::cut); register_action(editor, window, Editor::kill_ring_cut); register_action(editor, window, Editor::kill_ring_yank); @@ -10081,8 +10083,6 @@ impl Element for EditorElement { .editor .update(cx, |editor, cx| editor.highlighted_display_rows(window, cx)); - let is_light = cx.theme().appearance().is_light(); - let mut highlighted_ranges = self .editor_with_selections(cx) .map(|editor| { @@ -10122,42 +10122,49 @@ impl Element for EditorElement { }) .unwrap_or_default(); + struct DiffHunkHighlightColors { + filled_background: Hsla, + hollow_background: Hsla, + hollow_border: Hsla, + } + + let colors = cx.theme().colors(); + let added_diff_hunk_colors = DiffHunkHighlightColors { + filled_background: colors.editor_diff_hunk_added_background, + hollow_background: colors.editor_diff_hunk_added_hollow_background, + hollow_border: colors.editor_diff_hunk_added_hollow_border, + }; + let deleted_diff_hunk_colors = DiffHunkHighlightColors { + filled_background: colors.editor_diff_hunk_deleted_background, + hollow_background: colors.editor_diff_hunk_deleted_hollow_background, + hollow_border: colors.editor_diff_hunk_deleted_hollow_border, + }; + let drag_highlight_color = colors.editor_active_line_background; + let drag_border_color = colors.border_focused; + for (ix, row_info) in row_infos.iter().enumerate() { let Some(diff_status) = row_info.diff_status else { continue; }; - let background_color = match diff_status.kind { - DiffHunkStatusKind::Added => cx.theme().colors().version_control_added, - DiffHunkStatusKind::Deleted => { - cx.theme().colors().version_control_deleted - } + let diff_hunk_colors = match diff_status.kind { + DiffHunkStatusKind::Added => &added_diff_hunk_colors, + DiffHunkStatusKind::Deleted => &deleted_diff_hunk_colors, DiffHunkStatusKind::Modified => { debug_panic!("modified diff status for row info"); continue; } }; - let hunk_opacity = if is_light { 0.16 } else { 0.12 }; - let hollow_highlight = LineHighlight { - background: (background_color.opacity(if is_light { - 0.08 - } else { - 0.06 - })) - .into(), - border: Some(if is_light { - background_color.opacity(0.48) - } else { - background_color.opacity(0.36) - }), + background: diff_hunk_colors.hollow_background.into(), + border: Some(diff_hunk_colors.hollow_border), include_gutter: true, type_id: None, }; let filled_highlight = LineHighlight { - background: solid_background(background_color.opacity(hunk_opacity)), + background: solid_background(diff_hunk_colors.filled_background), border: None, include_gutter: true, type_id: None, @@ -10182,11 +10189,9 @@ impl Element for EditorElement { let range = drag_state.row_range(&snapshot.display_snapshot); let start_row = range.start().0; let end_row = range.end().0; - let drag_highlight_color = - cx.theme().colors().editor_active_line_background; let drag_highlight = LineHighlight { background: solid_background(drag_highlight_color), - border: Some(cx.theme().colors().border_focused), + border: Some(drag_border_color), include_gutter: true, type_id: None, }; diff --git a/crates/editor/src/fold.rs b/crates/editor/src/fold.rs new file mode 100644 index 00000000000..1367505b1d0 --- /dev/null +++ b/crates/editor/src/fold.rs @@ -0,0 +1,1095 @@ +use super::*; + +impl GutterDimensions { + /// The width of the space reserved for the fold indicators, + /// use alongside 'justify_end' and `gutter_width` to + /// right align content with the line numbers + pub fn fold_area_width(&self) -> Pixels { + self.margin + self.right_padding + } +} + +impl EditorSnapshot { + pub fn render_crease_toggle( + &self, + buffer_row: MultiBufferRow, + row_contains_cursor: bool, + editor: Entity, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + let mut is_foldable = false; + + if let Some(crease) = self + .crease_snapshot + .query_row(buffer_row, self.buffer_snapshot()) + { + is_foldable = true; + match crease { + Crease::Inline { render_toggle, .. } | Crease::Block { render_toggle, .. } => { + if let Some(render_toggle) = render_toggle { + let toggle_callback = + Arc::new(move |folded, window: &mut Window, cx: &mut App| { + if folded { + editor.update(cx, |editor, cx| { + editor.fold_at(buffer_row, window, cx) + }); + } else { + editor.update(cx, |editor, cx| { + editor.unfold_at(buffer_row, window, cx) + }); + } + }); + return Some((render_toggle)( + buffer_row, + folded, + toggle_callback, + window, + cx, + )); + } + } + } + } + + is_foldable |= !self.use_lsp_folding_ranges && self.starts_indent(buffer_row); + + if folded || (is_foldable && (row_contains_cursor || self.gutter_hovered)) { + Some( + Disclosure::new(("gutter_crease", buffer_row.0), !folded) + .toggle_state(folded) + .on_click(window.listener_for(&editor, move |this, _e, window, cx| { + if folded { + this.unfold_at(buffer_row, window, cx); + } else { + this.fold_at(buffer_row, window, cx); + } + })) + .into_any_element(), + ) + } else { + None + } + } + + pub fn render_crease_trailer( + &self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut App, + ) -> Option { + let folded = self.is_line_folded(buffer_row); + if let Crease::Inline { render_trailer, .. } = self + .crease_snapshot + .query_row(buffer_row, self.buffer_snapshot())? + { + let render_trailer = render_trailer.as_ref()?; + Some(render_trailer(buffer_row, folded, window, cx)) + } else { + None + } + } +} + +impl Editor { + pub fn toggle_fold( + &mut self, + _: &actions::ToggleFold, + window: &mut Window, + cx: &mut Context, + ) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selection = self.selections.newest::(&display_map); + + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_lines(&Default::default(), window, cx) + } else { + self.fold(&Default::default(), window, cx) + } + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids: HashSet<_> = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect(); + + let should_unfold = buffer_ids + .iter() + .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx)); + + for buffer_id in buffer_ids { + if should_unfold { + self.unfold_buffer(buffer_id, cx); + } else { + self.fold_buffer(buffer_id, cx); + } + } + } + } + + pub fn toggle_fold_recursive( + &mut self, + _: &actions::ToggleFoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let selection = self.selections.newest::(&self.display_snapshot(cx)); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_recursive(&Default::default(), window, cx) + } else { + self.fold_recursive(&Default::default(), window, cx) + } + } + + pub fn fold(&mut self, _: &actions::Fold, window: &mut Window, cx: &mut Context) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + let mut row = range.start.row; + while row <= range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + { + found = true; + row = crease.range().end.row + 1; + to_fold.push(crease); + } else { + row += 1 + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + && crease.range().end.row >= buffer_start_row + { + to_fold.push(crease); + if row <= range.start.row { + break; + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.fold_buffer(buffer_id, cx); + } + } + } + + pub fn toggle_fold_all( + &mut self, + _: &actions::ToggleFoldAll, + window: &mut Window, + cx: &mut Context, + ) { + let has_folds = if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let has_folds = display_map + .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len()) + .next() + .is_some(); + has_folds + } else { + let snapshot = self.buffer.read(cx).snapshot(cx); + let has_folds = snapshot + .all_buffer_ids() + .any(|buffer_id| self.is_buffer_folded(buffer_id, cx)); + has_folds + }; + + if has_folds { + self.unfold_all(&actions::UnfoldAll, window, cx); + } else { + self.fold_all(&actions::FoldAll, window, cx); + } + } + + pub fn fold_at_level_1( + &mut self, + _: &actions::FoldAtLevel1, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(1), window, cx); + } + + pub fn fold_at_level_2( + &mut self, + _: &actions::FoldAtLevel2, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(2), window, cx); + } + + pub fn fold_at_level_3( + &mut self, + _: &actions::FoldAtLevel3, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(3), window, cx); + } + + pub fn fold_at_level_4( + &mut self, + _: &actions::FoldAtLevel4, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(4), window, cx); + } + + pub fn fold_at_level_5( + &mut self, + _: &actions::FoldAtLevel5, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(5), window, cx); + } + + pub fn fold_at_level_6( + &mut self, + _: &actions::FoldAtLevel6, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(6), window, cx); + } + + pub fn fold_at_level_7( + &mut self, + _: &actions::FoldAtLevel7, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(7), window, cx); + } + + pub fn fold_at_level_8( + &mut self, + _: &actions::FoldAtLevel8, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(8), window, cx); + } + + pub fn fold_at_level_9( + &mut self, + _: &actions::FoldAtLevel9, + window: &mut Window, + cx: &mut Context, + ) { + self.fold_at_level(&actions::FoldAtLevel(9), window, cx); + } + + pub fn fold_all(&mut self, _: &actions::FoldAll, window: &mut Window, cx: &mut Context) { + if self.buffer.read(cx).is_singleton() { + let mut fold_ranges = Vec::new(); + let snapshot = self.buffer.read(cx).snapshot(cx); + + for row in 0..snapshot.max_row().0 { + if let Some(foldable_range) = self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(row)) + { + fold_ranges.push(foldable_range); + } + } + + self.fold_creases(fold_ranges, true, window, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| { + editor + .update_in(cx, |editor, _, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + for buffer_id in snapshot.all_buffer_ids() { + editor.fold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_function_bodies( + &mut self, + _: &actions::FoldFunctionBodies, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + + let ranges = snapshot + .text_object_ranges( + MultiBufferOffset(0)..snapshot.len(), + TreeSitterOptions::default(), + ) + .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) + .collect::>(); + + let creases = ranges + .into_iter() + .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) + .collect(); + + self.fold_creases(creases, true, window, cx); + } + + pub fn fold_recursive( + &mut self, + _: &actions::FoldRecursive, + window: &mut Window, + cx: &mut Context, + ) { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + for row in range.start.row..=range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + found = true; + to_fold.push(crease); + } + } + if found { + continue; + } + } + + for row in (0..=range.start.row).rev() { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { + if crease.range().end.row >= buffer_start_row { + to_fold.push(crease); + } else { + break; + } + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub fn fold_at( + &mut self, + buffer_row: MultiBufferRow, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if let Some(crease) = display_map.crease_for_buffer_row(buffer_row) { + let autoscroll = self + .selections + .all::(&display_map) + .iter() + .any(|selection| crease.range().overlaps(&selection.range())); + + self.fold_creases(vec![crease], autoscroll, window, cx); + } + } + + pub fn unfold_lines(&mut self, _: &UnfoldLines, _window: &mut Window, cx: &mut Context) { + if self.buffer_kind(cx) == ItemBufferKind::Singleton { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let selections = self.selections.all::(&display_map); + let ranges = selections + .iter() + .map(|s| { + let range = s.display_range(&display_map).sorted(); + let mut start = range.start.to_point(&display_map); + let mut end = range.end.to_point(&display_map); + start.column = 0; + end.column = buffer.line_len(MultiBufferRow(end.row)); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } else { + let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range)) + .collect::>(); + for buffer_id in buffer_ids { + self.unfold_buffer(buffer_id, cx); + } + } + } + + pub fn unfold_recursive( + &mut self, + _: &UnfoldRecursive, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all::(&display_map); + let ranges = selections + .iter() + .map(|s| { + let mut range = s.display_range(&display_map).sorted(); + *range.start.column_mut() = 0; + *range.end.column_mut() = display_map.line_len(range.end.row()); + let start = range.start.to_point(&display_map); + let end = range.end.to_point(&display_map); + start..end + }) + .collect::>(); + + self.unfold_ranges(&ranges, true, true, cx); + } + + pub fn unfold_at( + &mut self, + buffer_row: MultiBufferRow, + _window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + let intersection_range = Point::new(buffer_row.0, 0) + ..Point::new( + buffer_row.0, + display_map.buffer_snapshot().line_len(buffer_row), + ); + + let autoscroll = self + .selections + .all::(&display_map) + .iter() + .any(|selection| RangeExt::overlaps(&selection.range(), &intersection_range)); + + self.unfold_ranges(&[intersection_range], true, autoscroll, cx); + } + + pub fn unfold_all( + &mut self, + _: &actions::UnfoldAll, + _window: &mut Window, + cx: &mut Context, + ) { + if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.unfold_ranges( + &[MultiBufferOffset(0)..display_map.buffer_snapshot().len()], + true, + true, + cx, + ); + } else { + self.toggle_fold_multiple_buffers = cx.spawn(async move |editor, cx| { + editor + .update(cx, |editor, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + for buffer_id in snapshot.all_buffer_ids() { + editor.unfold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } + } + + pub fn fold_selected_ranges( + &mut self, + _: &FoldSelectedRanges, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(&display_map); + let ranges = selections + .into_iter() + .map(|s| Crease::simple(s.range(), display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, true, window, cx); + } + + pub fn fold_ranges( + &mut self, + ranges: Vec>, + auto_scroll: bool, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let ranges = ranges + .into_iter() + .map(|r| Crease::simple(r, display_map.fold_placeholder.clone())) + .collect::>(); + self.fold_creases(ranges, auto_scroll, window, cx); + } + + pub fn fold_creases( + &mut self, + creases: Vec>, + auto_scroll: bool, + window: &mut Window, + cx: &mut Context, + ) { + if creases.is_empty() { + return; + } + + self.display_map.update(cx, |map, cx| map.fold(creases, cx)); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + + self.scrollbar_marker_state.dirty = true; + self.update_data_on_scroll(false, window, cx); + self.folds_did_change(cx); + } + + /// Removes any folds whose ranges intersect any of the given ranges. + pub fn unfold_ranges( + &mut self, + ranges: &[Range], + inclusive: bool, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.unfold_intersecting(ranges.iter().cloned(), inclusive, cx); + }); + self.folds_did_change(cx); + } + + pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.fold_buffers([buffer_id], cx); + } + + pub fn fold_buffers( + &mut self, + buffer_ids: impl IntoIterator, + cx: &mut Context, + ) { + if self.buffer().read(cx).is_singleton() { + return; + } + + let ids_to_fold: Vec = buffer_ids + .into_iter() + .filter(|id| !self.is_buffer_folded(*id, cx)) + .collect(); + + if ids_to_fold.is_empty() { + return; + } + + self.display_map.update(cx, |display_map, cx| { + display_map.fold_buffers(ids_to_fold.clone(), cx) + }); + + let snapshot = self.display_snapshot(cx); + self.selections.change_with(&snapshot, |selections| { + for buffer_id in ids_to_fold.iter().copied() { + selections.remove_selections_from_buffer(buffer_id); + } + }); + + cx.emit(EditorEvent::BufferFoldToggled { + ids: ids_to_fold, + folded: true, + }); + cx.notify(); + } + + pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() || !self.is_buffer_folded(buffer_id, cx) { + return; + } + self.display_map.update(cx, |display_map, cx| { + display_map.unfold_buffers([buffer_id], cx); + }); + cx.emit(EditorEvent::BufferFoldToggled { + ids: vec![buffer_id], + folded: false, + }); + cx.notify(); + } + + pub fn is_buffer_folded(&self, buffer: BufferId, cx: &App) -> bool { + self.display_map.read(cx).is_buffer_folded(buffer) + } + + pub fn has_any_buffer_folded(&self, cx: &App) -> bool { + if self.buffer().read(cx).is_singleton() { + return false; + } + !self.folded_buffers(cx).is_empty() + } + + pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet { + self.display_map.read(cx).folded_buffers() + } + + pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { + self.display_map.update(cx, |display_map, cx| { + display_map.disable_header_for_buffer(buffer_id, cx); + }); + cx.notify(); + } + + /// Removes any folds with the given ranges. + pub fn remove_folds_with_type( + &mut self, + ranges: &[Range], + type_id: TypeId, + auto_scroll: bool, + cx: &mut Context, + ) { + self.remove_folds_with(ranges, auto_scroll, cx, |map, cx| { + map.remove_folds_with_type(ranges.iter().cloned(), type_id, cx) + }); + self.folds_did_change(cx); + } + + pub fn update_renderer_widths( + &mut self, + widths: impl IntoIterator, + cx: &mut Context, + ) -> bool { + self.display_map + .update(cx, |map, cx| map.update_fold_widths(widths, cx)) + } + + pub fn default_fold_placeholder(&self, cx: &App) -> FoldPlaceholder { + self.display_map.read(cx).fold_placeholder.clone() + } + + pub fn insert_creases( + &mut self, + creases: impl IntoIterator>, + cx: &mut Context, + ) -> Vec { + self.display_map + .update(cx, |map, cx| map.insert_creases(creases, cx)) + } + + pub fn remove_creases( + &mut self, + ids: impl IntoIterator, + cx: &mut Context, + ) -> Vec<(CreaseId, Range)> { + self.display_map + .update(cx, |map, cx| map.remove_creases(ids, cx)) + } + + pub(super) fn fold_at_level( + &mut self, + fold_at: &FoldAtLevel, + window: &mut Window, + cx: &mut Context, + ) { + if !self.buffer.read(cx).is_singleton() { + return; + } + + let fold_at_level = fold_at.0; + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut to_fold = Vec::new(); + let mut stack = vec![(0, snapshot.max_row().0, 1)]; + + let row_ranges_to_keep: Vec> = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|sel| sel.start.row..sel.end.row) + .collect(); + + while let Some((mut start_row, end_row, current_level)) = stack.pop() { + while start_row < end_row { + match self + .snapshot(window, cx) + .crease_for_buffer_row(MultiBufferRow(start_row)) + { + Some(crease) => { + let nested_start_row = crease.range().start.row + 1; + let nested_end_row = crease.range().end.row; + + if current_level < fold_at_level { + stack.push((nested_start_row, nested_end_row, current_level + 1)); + } else if current_level == fold_at_level { + // Fold iff there is no selection completely contained within the fold region + if !row_ranges_to_keep.iter().any(|selection| { + selection.end >= nested_start_row + && selection.start <= nested_end_row + }) { + to_fold.push(crease); + } + } + + start_row = nested_end_row + 1; + } + None => start_row += 1, + } + } + } + + self.fold_creases(to_fold, true, window, cx); + } + + pub(super) fn unfold_buffers_with_selections(&mut self, cx: &mut Context) { + if self.buffer().read(cx).is_singleton() { + return; + } + let snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_ids: HashSet = self + .selections + .disjoint_anchor_ranges() + .flat_map(|range| snapshot.buffer_ids_for_range(range)) + .collect(); + for buffer_id in buffer_ids { + self.unfold_buffer(buffer_id, cx); + } + } + + pub(super) fn folds_did_change(&mut self, cx: &mut Context) { + use text::ToOffset as _; + + if self.mode.is_minimap() + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab + { + return; + } + + let display_snapshot = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let Some(buffer_snapshot) = display_snapshot.buffer_snapshot().as_singleton() else { + return; + }; + let inmemory_folds = display_snapshot + .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + let start = fold.range.start.text_anchor_in(buffer_snapshot); + let end = fold.range.end.text_anchor_in(buffer_snapshot); + (start..end).to_point(buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.folds = inmemory_folds; + }); + + let Some(workspace_id) = self.workspace_serialization_id(cx) else { + return; + }; + + // Get file path for path-based fold storage (survives tab close) + let Some(file_path) = self.buffer().read(cx).as_singleton().and_then(|buffer| { + project::File::from_dyn(buffer.read(cx).file()) + .map(|file| Arc::::from(file.abs_path(cx))) + }) else { + return; + }; + + let background_executor = cx.background_executor().clone(); + const FINGERPRINT_LEN: usize = 32; + let db_folds = display_snapshot + .folds_in_range(MultiBufferOffset(0)..display_snapshot.buffer_snapshot().len()) + .map(|fold| { + let start = fold + .range + .start + .text_anchor_in(buffer_snapshot) + .to_offset(buffer_snapshot); + let end = fold + .range + .end + .text_anchor_in(buffer_snapshot) + .to_offset(buffer_snapshot); + + // Extract fingerprints - content at fold boundaries for validation on restore + // Both fingerprints must be INSIDE the fold to avoid capturing surrounding + // content that might change independently. + // start_fp: first min(32, fold_len) bytes of fold content + // end_fp: last min(32, fold_len) bytes of fold content + // Clip to character boundaries to handle multibyte UTF-8 characters. + let fold_len = end - start; + let start_fp_end = buffer_snapshot + .clip_offset(start + std::cmp::min(FINGERPRINT_LEN, fold_len), Bias::Left); + let start_fp: String = buffer_snapshot + .text_for_range(start..start_fp_end) + .collect(); + let end_fp_start = buffer_snapshot + .clip_offset(end.saturating_sub(FINGERPRINT_LEN).max(start), Bias::Right); + let end_fp: String = buffer_snapshot.text_for_range(end_fp_start..end).collect(); + + (start, end, start_fp, end_fp) + }) + .collect::>(); + let db = EditorDb::global(cx); + self.serialize_folds = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + if db_folds.is_empty() { + // No folds - delete any persisted folds for this file + db.delete_file_folds(workspace_id, file_path) + .await + .with_context(|| format!("deleting file folds for workspace {workspace_id:?}")) + .log_err(); + } else { + db.save_file_folds(workspace_id, file_path, db_folds) + .await + .with_context(|| { + format!("persisting file folds for workspace {workspace_id:?}") + }) + .log_err(); + } + }); + } + + pub(super) fn refresh_single_line_folds( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + struct NewlineFold; + let type_id = std::any::TypeId::of::(); + if !self.mode.is_single_line() { + return; + } + let snapshot = self.snapshot(window, cx); + if snapshot.buffer_snapshot().max_point().row == 0 { + return; + } + let task = cx.background_spawn(async move { + let new_newlines = snapshot + .buffer_chars_at(MultiBufferOffset(0)) + .filter_map(|(c, i)| { + if c == '\n' { + Some( + snapshot.buffer_snapshot().anchor_after(i) + ..snapshot.buffer_snapshot().anchor_before(i + 1usize), + ) + } else { + None + } + }) + .collect::>(); + let existing_newlines = snapshot + .folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()) + .filter_map(|fold| { + if fold.placeholder.type_tag == Some(type_id) { + Some(fold.range.start..fold.range.end) + } else { + None + } + }) + .collect::>(); + + (new_newlines, existing_newlines) + }); + self.folding_newlines = cx.spawn(async move |this, cx| { + let (new_newlines, existing_newlines) = task.await; + if new_newlines == existing_newlines { + return; + } + let placeholder = FoldPlaceholder { + render: Arc::new(move |_, _, cx| { + div() + .bg(cx.theme().status().hint_background) + .border_b_1() + .size_full() + .font(ThemeSettings::get_global(cx).buffer_font.clone()) + .border_color(cx.theme().status().hint) + .child("\\n") + .into_any() + }), + constrain_width: false, + merge_adjacent: false, + type_tag: Some(type_id), + collapsed_text: None, + }; + let creases = new_newlines + .into_iter() + .map(|range| Crease::simple(range, placeholder.clone())) + .collect(); + this.update(cx, |this, cx| { + this.display_map.update(cx, |display_map, cx| { + display_map.remove_folds_with_type(existing_newlines, type_id, cx); + display_map.fold(creases, cx); + }); + }) + .ok(); + }); + } + + /// Load folds from the file_folds database table by file path. + /// Used when manually opening a file that was previously closed. + pub(super) fn load_folds_from_db( + &mut self, + workspace_id: WorkspaceId, + file_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_minimap() + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab + { + return; + } + + let Some(folds) = EditorDb::global(cx) + .get_file_folds(workspace_id, &file_path) + .log_err() + else { + return; + }; + if folds.is_empty() { + return; + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let snapshot_len = snapshot.len().0; + + // Helper: search for fingerprint in buffer, return offset if found + let find_fingerprint = |fingerprint: &str, search_start: usize| -> Option { + let search_start = snapshot + .clip_offset(MultiBufferOffset(search_start), Bias::Left) + .0; + let search_end = snapshot_len.saturating_sub(fingerprint.len()); + + let mut byte_offset = search_start; + for ch in snapshot.chars_at(MultiBufferOffset(search_start)) { + if byte_offset > search_end { + break; + } + if snapshot.contains_str_at(MultiBufferOffset(byte_offset), fingerprint) { + return Some(byte_offset); + } + byte_offset += ch.len_utf8(); + } + None + }; + + let mut search_start = 0usize; + + let valid_folds: Vec<_> = folds + .into_iter() + .filter_map(|(stored_start, stored_end, start_fp, end_fp)| { + let sfp = start_fp?; + let efp = end_fp?; + let efp_len = efp.len(); + + let start_matches = stored_start < snapshot_len + && snapshot.contains_str_at(MultiBufferOffset(stored_start), &sfp); + let efp_check_pos = stored_end.saturating_sub(efp_len); + let end_matches = efp_check_pos >= stored_start + && stored_end <= snapshot_len + && snapshot.contains_str_at(MultiBufferOffset(efp_check_pos), &efp); + + let (new_start, new_end) = if start_matches && end_matches { + (stored_start, stored_end) + } else if sfp == efp { + let new_start = find_fingerprint(&sfp, search_start)?; + let fold_len = stored_end - stored_start; + let new_end = new_start + fold_len; + (new_start, new_end) + } else { + let new_start = find_fingerprint(&sfp, search_start)?; + let efp_pos = find_fingerprint(&efp, new_start + sfp.len())?; + let new_end = efp_pos + efp_len; + (new_start, new_end) + }; + + search_start = new_end; + + if new_end <= new_start { + return None; + } + + Some( + snapshot.clip_offset(MultiBufferOffset(new_start), Bias::Left) + ..snapshot.clip_offset(MultiBufferOffset(new_end), Bias::Right), + ) + }) + .collect(); + + if !valid_folds.is_empty() { + self.fold_ranges(valid_folds, false, window, cx); + } + } + + fn remove_folds_with( + &mut self, + ranges: &[Range], + auto_scroll: bool, + cx: &mut Context, + update: impl FnOnce(&mut DisplayMap, &mut Context), + ) { + if ranges.is_empty() { + return; + } + + self.display_map.update(cx, update); + + if auto_scroll { + self.request_autoscroll(Autoscroll::fit(), cx); + } + + cx.notify(); + self.scrollbar_marker_state.dirty = true; + self.active_indent_guides_state.dirty = true; + } +} diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index c59a3e004a8..6c1db5f3ee9 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -16,7 +16,7 @@ impl Editor { if !self.lsp_data_enabled() || !self.use_document_folding_ranges { return; } - let Some(project) = self.project.clone() else { + let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else { return; }; @@ -43,7 +43,8 @@ impl Editor { let Some(tasks) = editor .update(cx, |_, cx| { - project.read(cx).lsp_store().update(cx, |lsp_store, cx| { + let project = project.upgrade()?; + Some(project.read(cx).lsp_store().update(cx, |lsp_store, cx| { buffers_to_query .into_iter() .map(|buffer| { @@ -52,9 +53,10 @@ impl Editor { async move { (buffer_id, task.await) } }) .collect::>() - }) + })) }) .ok() + .flatten() else { return; }; diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 080babe4c68..dd4b156dcab 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1 +1,710 @@ pub mod blame; + +use super::*; + +impl Editor { + pub fn diff_hunks_in_ranges<'a>( + &'a self, + ranges: &'a [Range], + buffer: &'a MultiBufferSnapshot, + ) -> impl 'a + Iterator { + ranges.iter().flat_map(move |range| { + let end_excerpt = buffer.excerpt_containing(range.end..range.end); + let range = range.to_point(buffer); + let mut peek_end = range.end; + if range.end.row < buffer.max_row().0 { + peek_end = Point::new(range.end.row + 1, 0); + } + buffer + .diff_hunks_in_range(range.start..peek_end) + .filter(move |hunk| { + if let Some((_, excerpt_range)) = &end_excerpt + && let Some(end_anchor) = + buffer.anchor_in_excerpt(excerpt_range.context.end) + && let Some(hunk_end_anchor) = + buffer.anchor_in_excerpt(hunk.excerpt_range.context.end) + && hunk_end_anchor.cmp(&end_anchor, buffer).is_gt() + { + false + } else { + true + } + }) + }) + } + + pub fn set_render_diff_hunk_controls( + &mut self, + render_diff_hunk_controls: RenderDiffHunkControlsFn, + cx: &mut Context, + ) { + self.render_diff_hunk_controls = render_diff_hunk_controls; + cx.notify(); + } + + pub fn working_directory(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) + && let Some(dir) = file.abs_path(cx).parent() + { + return Some(dir.to_owned()); + } + } + + None + } + + pub fn target_file_abs_path(&self, cx: &mut Context) -> Option { + self.active_buffer(cx).and_then(|buffer| { + let buffer = buffer.read(cx); + if let Some(project_path) = buffer.project_path(cx) { + let project = self.project()?.read(cx); + project.absolute_path(&project_path, cx) + } else { + buffer + .file() + .and_then(|file| file.as_local().map(|file| file.abs_path(cx))) + } + }) + } + + /// Returns the project path for the editor's buffer, if any buffer is + /// opened in the editor. + pub fn project_path(&self, cx: &App) -> Option { + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + buffer.read(cx).project_path(cx) + } else { + None + } + } + + pub fn git_blame_inline_enabled(&self) -> bool { + self.git_blame_inline_enabled + } + + pub fn selection_menu_enabled(&self, cx: &App) -> bool { + self.show_selection_menu + .unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu) + } + + pub fn toggle_selection_menu( + &mut self, + _: &ToggleSelectionMenu, + _: &mut Window, + cx: &mut Context, + ) { + self.show_selection_menu = self + .show_selection_menu + .map(|show_selections_menu| !show_selections_menu) + .or_else(|| Some(!EditorSettings::get_global(cx).toolbar.selections_menu)); + + cx.notify(); + } + + pub fn blame(&self) -> Option<&Entity> { + self.blame.as_ref() + } + + pub fn show_git_blame_gutter(&self) -> bool { + self.show_git_blame_gutter + } + + pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context) { + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); + self.buffer + .update(cx, |buffer, cx| buffer.expand_diff_hunks(ranges, cx)) + } + + pub fn copy_file_name_without_extension( + &mut self, + _: &CopyFileNameWithoutExtension, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(file_stem) = self.active_buffer(cx).and_then(|buffer| { + let file = buffer.read(cx).file()?; + file.path().file_stem() + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string())); + } + } + + pub fn copy_file_name(&mut self, _: &CopyFileName, _: &mut Window, cx: &mut Context) { + if let Some(file_name) = self.active_buffer(cx).and_then(|buffer| { + let file = buffer.read(cx).file()?; + Some(file.file_name(cx)) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(file_name.to_string())); + } + } + + pub fn toggle_git_blame( + &mut self, + _: &::git::Blame, + window: &mut Window, + cx: &mut Context, + ) { + self.show_git_blame_gutter = !self.show_git_blame_gutter; + + if self.show_git_blame_gutter && !self.has_blame_entries(cx) { + self.start_git_blame(true, window, cx); + } + + cx.notify(); + } + + pub fn toggle_git_blame_inline( + &mut self, + _: &ToggleGitBlameInline, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_git_blame_inline_internal(true, window, cx); + cx.notify(); + } + + pub(super) fn toggle_staged_selected_diff_hunks( + &mut self, + _: &::git::ToggleStaged, + _: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let ranges: Vec<_> = self + .selections + .disjoint_anchors() + .iter() + .map(|s| s.range()) + .collect(); + let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot); + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + } + + pub(super) fn stage_and_next( + &mut self, + _: &::git::StageAndNext, + window: &mut Window, + cx: &mut Context, + ) { + self.do_stage_or_unstage_and_next(true, window, cx); + } + + pub(super) fn unstage_and_next( + &mut self, + _: &::git::UnstageAndNext, + window: &mut Window, + cx: &mut Context, + ) { + self.do_stage_or_unstage_and_next(false, window, cx); + } + + pub(super) fn stage_or_unstage_diff_hunks( + &mut self, + stage: bool, + ranges: Vec>, + cx: &mut Context, + ) { + if self.delegate_stage_and_restore { + let snapshot = self.buffer.read(cx).snapshot(cx); + let hunks: Vec<_> = self.diff_hunks_in_ranges(&ranges, &snapshot).collect(); + if !hunks.is_empty() { + cx.emit(EditorEvent::StageOrUnstageRequested { stage, hunks }); + } + return; + } + let task = self.save_buffers_for_ranges_if_needed(&ranges, cx); + cx.spawn(async move |this, cx| { + task.await?; + this.update(cx, |this, cx| { + let snapshot = this.buffer.read(cx).snapshot(cx); + let chunk_by = this + .diff_hunks_in_ranges(&ranges, &snapshot) + .chunk_by(|hunk| hunk.buffer_id); + for (buffer_id, hunks) in &chunk_by { + this.do_stage_or_unstage(stage, buffer_id, hunks, cx); + } + }) + }) + .detach_and_log_err(cx); + } + + pub(super) fn do_stage_or_unstage( + &self, + stage: bool, + buffer_id: BufferId, + hunks: impl Iterator, + cx: &mut App, + ) -> Option<()> { + let project = self.project()?; + let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?; + let diff = self.buffer.read(cx).diff_for(buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let file_exists = buffer_snapshot + .file() + .is_some_and(|file| file.disk_state().exists()); + diff.update(cx, |diff, cx| { + diff.stage_or_unstage_hunks( + stage, + &hunks + .map(|hunk| buffer_diff::DiffHunk { + buffer_range: hunk.buffer_range, + // We don't need to pass in word diffs here because they're only used for rendering and + // this function changes internal state + base_word_diffs: Vec::default(), + buffer_word_diffs: Vec::default(), + diff_base_byte_range: hunk.diff_base_byte_range.start.0 + ..hunk.diff_base_byte_range.end.0, + secondary_status: hunk.status.secondary, + range: Point::zero()..Point::zero(), // unused + }) + .collect::>(), + &buffer_snapshot, + file_exists, + cx, + ) + }); + None + } + + pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut Context) -> bool { + self.buffer.update(cx, |buffer, cx| { + let ranges = vec![Anchor::Min..Anchor::Max]; + if !buffer.all_diff_hunks_expanded() + && buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx) + { + buffer.collapse_diff_hunks(ranges, cx); + true + } else { + false + } + }) + } + + pub(super) fn has_any_expanded_diff_hunks(&self, cx: &App) -> bool { + if self.buffer.read(cx).all_diff_hunks_expanded() { + return true; + } + let ranges = vec![Anchor::Min..Anchor::Max]; + self.buffer + .read(cx) + .has_expanded_diff_hunks_in_ranges(&ranges, cx) + } + + pub(super) fn toggle_diff_hunks_in_ranges( + &mut self, + ranges: Vec>, + cx: &mut Context, + ) { + self.buffer.update(cx, |buffer, cx| { + let expand = !buffer.has_expanded_diff_hunks_in_ranges(&ranges, cx); + buffer.expand_or_collapse_diff_hunks(ranges, expand, cx); + }) + } + + pub(super) fn toggle_single_diff_hunk(&mut self, range: Range, cx: &mut Context) { + self.buffer.update(cx, |buffer, cx| { + buffer.toggle_single_diff_hunk(range, cx); + }) + } + + pub(super) fn apply_all_diff_hunks( + &mut self, + _: &ApplyAllDiffHunks, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + + let buffers = self.buffer.read(cx).all_buffers(); + for branch_buffer in buffers { + branch_buffer.update(cx, |branch_buffer, cx| { + branch_buffer.merge_into_base(Vec::new(), cx); + }); + } + + if let Some(project) = self.project.clone() { + self.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project, + window, + cx, + ) + .detach_and_log_err(cx); + } + } + + pub(super) fn apply_selected_diff_hunks( + &mut self, + _: &ApplyDiffHunk, + window: &mut Window, + cx: &mut Context, + ) { + if self.read_only(cx) { + return; + } + let snapshot = self.snapshot(window, cx); + let hunks = snapshot.hunks_for_ranges( + self.selections + .all(&snapshot.display_snapshot) + .into_iter() + .map(|selection| selection.range()), + ); + let mut ranges_by_buffer = HashMap::default(); + self.transact(window, cx, |editor, _window, cx| { + for hunk in hunks { + if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { + ranges_by_buffer + .entry(buffer.clone()) + .or_insert_with(Vec::new) + .push(hunk.buffer_range.to_offset(buffer.read(cx))); + } + } + + for (buffer, ranges) in ranges_by_buffer { + buffer.update(cx, |buffer, cx| { + buffer.merge_into_base(ranges, cx); + }); + } + }); + + if let Some(project) = self.project.clone() { + self.save( + SaveOptions { + format: true, + force_format: false, + autosave: false, + }, + project, + window, + cx, + ) + .detach_and_log_err(cx); + } + } + + pub(super) fn target_file<'a>(&self, cx: &'a App) -> Option<&'a dyn language::LocalFile> { + self.active_buffer(cx)? + .read(cx) + .file() + .and_then(|f| f.as_local()) + } + + pub(super) fn reveal_in_finder( + &mut self, + _: &RevealInFileManager, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.target_file_abs_path(cx) { + if let Some(project) = self.project() { + project.update(cx, |project, cx| project.reveal_path(&path, cx)); + } else { + cx.reveal_path(&path); + } + } + } + + pub(super) fn copy_path( + &mut self, + _: &zed_actions::workspace::CopyPath, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.target_file_abs_path(cx) + && let Some(path) = path.to_str() + { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); + } + } + + pub(super) fn copy_relative_path( + &mut self, + _: &zed_actions::workspace::CopyRelativePath, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.active_buffer(cx).and_then(|buffer| { + let project = self.project()?.read(cx); + let path = buffer.read(cx).file()?.path(); + let path = path.display(project.path_style(cx)); + Some(path) + }) { + cx.write_to_clipboard(ClipboardItem::new_string(path.to_string())); + } else { + cx.propagate(); + } + } + + pub(super) fn go_to_active_debug_line( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + maybe!({ + let breakpoint_store = self.breakpoint_store.as_ref()?; + + let (active_stack_frame, debug_line_pane_id) = { + let store = breakpoint_store.read(cx); + let active_stack_frame = store.active_position().cloned(); + let debug_line_pane_id = store.active_debug_line_pane_id(); + (active_stack_frame, debug_line_pane_id) + }; + + let Some(active_stack_frame) = active_stack_frame else { + self.clear_row_highlights::(); + return None; + }; + + if let Some(debug_line_pane_id) = debug_line_pane_id { + if let Some(workspace) = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + { + let editor_pane_id = workspace + .read(cx) + .pane_for_item_id(cx.entity_id()) + .map(|pane| pane.entity_id()); + + if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) { + self.clear_row_highlights::(); + return None; + } + } + } + + let position = active_stack_frame.position; + + let snapshot = self.buffer.read(cx).snapshot(cx); + let multibuffer_anchor = snapshot.anchor_in_excerpt(position)?; + + self.clear_row_highlights::(); + + self.go_to_line::( + multibuffer_anchor, + Some(cx.theme().colors().editor_debugger_active_line_background), + window, + cx, + ); + + cx.notify(); + + Some(()) + }) + .is_some() + } + + pub(super) fn open_git_blame_commit( + &mut self, + _: &OpenGitBlameCommit, + window: &mut Window, + cx: &mut Context, + ) { + self.open_git_blame_commit_internal(window, cx); + } + + pub(super) fn start_git_blame( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(project) = self.project() { + if let Some(buffer) = self.buffer().read(cx).as_singleton() + && buffer.read(cx).file().is_none() + { + return; + } + + let focused = self.focus_handle(cx).contains_focused(window, cx); + + let project = project.clone(); + let blame = cx + .new(|cx| GitBlame::new(self.buffer.clone(), project, user_triggered, focused, cx)); + self.blame_subscription = + Some(cx.observe_in(&blame, window, |_, _, _, cx| cx.notify())); + self.blame = Some(blame); + } + } + + pub(super) fn toggle_git_blame_inline_internal( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + if self.git_blame_inline_enabled { + self.git_blame_inline_enabled = false; + self.show_git_blame_inline = false; + self.show_git_blame_inline_delay_task.take(); + } else { + self.git_blame_inline_enabled = true; + self.start_git_blame_inline(user_triggered, window, cx); + } + + cx.notify(); + } + + pub(super) fn start_git_blame_inline( + &mut self, + user_triggered: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.start_git_blame(user_triggered, window, cx); + + if ProjectSettings::get_global(cx) + .git + .inline_blame_delay() + .is_some() + { + self.start_inline_blame_timer(window, cx); + } else { + self.show_git_blame_inline = true + } + } + + pub(super) fn render_git_blame_gutter(&self, cx: &App) -> bool { + !self.mode().is_minimap() && self.show_git_blame_gutter && self.has_blame_entries(cx) + } + + pub(super) fn render_git_blame_inline(&self, window: &Window, cx: &App) -> bool { + self.show_git_blame_inline + && (self.focus_handle.is_focused(window) || self.inline_blame_popover.is_some()) + && !self.newest_selection_head_on_empty_line(cx) + && self.has_blame_entries(cx) + } + + fn has_stageable_diff_hunks_in_ranges( + &self, + ranges: &[Range], + snapshot: &MultiBufferSnapshot, + ) -> bool { + let mut hunks = self.diff_hunks_in_ranges(ranges, snapshot); + hunks.any(|hunk| hunk.status().has_secondary_hunk()) + } + + fn save_buffers_for_ranges_if_needed( + &mut self, + ranges: &[Range], + cx: &mut Context, + ) -> Task> { + let multibuffer = self.buffer.read(cx); + let snapshot = multibuffer.read(cx); + let buffer_ids: HashSet<_> = ranges + .iter() + .flat_map(|range| snapshot.buffer_ids_for_range(range.clone())) + .collect(); + drop(snapshot); + + let mut buffers = HashSet::default(); + for buffer_id in buffer_ids { + if let Some(buffer_entity) = multibuffer.buffer(buffer_id) { + let buffer = buffer_entity.read(cx); + if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty() + { + buffers.insert(buffer_entity); + } + } + } + + if let Some(project) = &self.project { + project.update(cx, |project, cx| project.save_buffers(buffers, cx)) + } else { + Task::ready(Ok(())) + } + } + + fn do_stage_or_unstage_and_next( + &mut self, + stage: bool, + window: &mut Window, + cx: &mut Context, + ) { + let ranges = self.selections.disjoint_anchor_ranges().collect::>(); + + if ranges.iter().any(|range| range.start != range.end) { + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + return; + } + + self.stage_or_unstage_diff_hunks(stage, ranges, cx); + + let all_diff_hunks_expanded = self.buffer().read(cx).all_diff_hunks_expanded(); + let wrap_around = !all_diff_hunks_expanded; + let snapshot = self.snapshot(window, cx); + let position = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + + self.go_to_hunk_before_or_after_position( + &snapshot, + position, + Direction::Next, + wrap_around, + window, + cx, + ); + } + + fn open_git_blame_commit_internal( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option<()> { + let blame = self.blame.as_ref()?; + let snapshot = self.snapshot(window, cx); + let cursor = self + .selections + .newest::(&snapshot.display_snapshot) + .head(); + let (buffer, point) = snapshot.buffer_snapshot().point_to_buffer_point(cursor)?; + let (_, blame_entry) = blame + .update(cx, |blame, cx| { + blame + .blame_for_rows( + &[RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }], + cx, + ) + .next() + }) + .flatten()?; + let renderer = cx.global::().0.clone(); + let repo = blame.read(cx).repository(cx, buffer.remote_id())?; + let workspace = self.workspace()?.downgrade(); + renderer.open_blame_commit(blame_entry, repo, workspace, window, cx); + None + } + + fn has_blame_entries(&self, cx: &App) -> bool { + self.blame() + .is_some_and(|blame| blame.read(cx).has_generated_entries()) + } + + fn newest_selection_head_on_empty_line(&self, cx: &App) -> bool { + let cursor_anchor = self.selections.newest_anchor().head(); + + let snapshot = self.buffer.read(cx).snapshot(cx); + let buffer_row = MultiBufferRow(cursor_anchor.to_point(&snapshot).row); + + snapshot.line_len(buffer_row) == 0 + } +} diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 1877d8704f6..3c9e13d00df 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -14,7 +14,7 @@ use settings::Settings; use std::{ops::Range, sync::LazyLock}; use text::OffsetRangeExt; use theme::ActiveTheme as _; -use util::{ResultExt, TryFutureExt as _, maybe}; +use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition}; #[derive(Debug)] pub struct HoveredLinkState { @@ -63,7 +63,7 @@ impl RangeInEditor { #[derive(Debug, Clone)] pub enum HoverLink { Url(String), - File(ResolvedPath), + File(ResolvedFileTarget), Text(LocationLink), InlayHint(lsp::Location, LanguageServerId), } @@ -376,7 +376,7 @@ pub fn show_link_definition( (range, vec![HoverLink::Url(url)]) }) .ok() - } else if let Some((filename_range, filename)) = + } else if let Some((filename_range, file_target)) = find_file(&buffer, project.clone(), anchor, cx).await { let range = maybe!({ @@ -385,7 +385,7 @@ pub fn show_link_definition( Some(RangeInEditor::Text(range)) }); - Some((range, vec![HoverLink::File(filename)])) + Some((range, vec![HoverLink::File(file_target)])) } else if let Some(provider) = provider { let task = cx.update(|_, cx| { provider.definitions(&buffer, anchor, preferred_kind, cx) @@ -608,12 +608,49 @@ pub(crate) fn find_url_from_range( None } +#[derive(Debug, Clone)] +pub(crate) struct ResolvedFileTarget { + pub resolved_path: ResolvedPath, + pub row: Option, + pub column: Option, +} + +impl ResolvedFileTarget { + /// After opening a file, navigate the editor to the row/column position if present. + pub fn navigate_item_to_position( + &self, + item: Box, + cx: &mut AsyncWindowContext, + ) { + if let Some(row) = self.row { + let col = self.column.unwrap_or(0); + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + let row = row.saturating_sub(1); + let col = col.saturating_sub(1); + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let point = buffer + .read(cx) + .snapshot() + .point_from_external_input(row, col); + editor.go_to_singleton_buffer_point_silently(point, window, cx); + }) + .log_err(); + } + } + } +} + pub(crate) async fn find_file( buffer: &Entity, project: Option>, position: text::Anchor, cx: &mut AsyncWindowContext, -) -> Option<(Range, ResolvedPath)> { +) -> Option<(Range, ResolvedFileTarget)> { let project = project?; let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let scope = snapshot.language_scope_at(position); @@ -636,19 +673,53 @@ pub(crate) async fn find_file( let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); + // Compute the highlight range for a pattern_range within the candidate string. + let make_range = |pattern_range: &Range| -> Range { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end) + }; + + // For each candidate extracted by link_pattern_file_candidates, try resolving in order: + // 1. The raw candidate string + // 2. The path portion after stripping `:row:col` suffix + // 3. With language-specific file extensions appended to raw candidate + // 4. With language-specific file extensions appended to stripped path for (pattern_candidate, pattern_range) in &pattern_candidates { + // Try the raw candidate first. if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { - let offset_range = range.to_offset(&snapshot); - let actual_start = offset_range.start + pattern_range.start; - let actual_end = offset_range.end - (candidate_len - pattern_range.end); return Some(( - snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), - existing_path, + make_range(pattern_range), + ResolvedFileTarget { + resolved_path: existing_path, + row: None, + column: None, + }, )); } - } - if let Some(scope) = scope { - for (pattern_candidate, pattern_range) in pattern_candidates { + + // Parse row:col suffix once per candidate for use in fallback attempts. + // This handles patterns like `file.rs:83:1`, `file.rs:83`, and `file.rs:20:in`. + let parsed = PathWithPosition::parse_str(pattern_candidate); + let parsed_path = parsed.path.to_string_lossy(); + + // Try resolving just the path portion (without :row:col). + if parsed.row.is_some() { + if let Some(existing_path) = check_path(&parsed_path, &project, buffer, cx).await { + return Some(( + make_range(pattern_range), + ResolvedFileTarget { + resolved_path: existing_path, + row: parsed.row, + column: parsed.column, + }, + )); + } + } + + // Try with language-specific suffixes. + if let Some(scope) = &scope { for suffix in scope.path_suffixes() { if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { continue; @@ -658,15 +729,39 @@ pub(crate) async fn find_file( if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await { - let offset_range = range.to_offset(&snapshot); - let actual_start = offset_range.start + pattern_range.start; - let actual_end = offset_range.end - (candidate_len - pattern_range.end); return Some(( - snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), - existing_path, + make_range(pattern_range), + ResolvedFileTarget { + resolved_path: existing_path, + row: None, + column: None, + }, )); } } + + // Try with language-specific suffixes on the stripped path. + if parsed.row.is_some() { + for suffix in scope.path_suffixes() { + if parsed_path.ends_with(&format!(".{suffix}")) { + continue; + } + + let suffixed_candidate = format!("{parsed_path}.{suffix}"); + if let Some(existing_path) = + check_path(&suffixed_candidate, &project, buffer, cx).await + { + return Some(( + make_range(pattern_range), + ResolvedFileTarget { + resolved_path: existing_path, + row: parsed.row, + column: parsed.column, + }, + )); + } + } + } } } None @@ -721,7 +816,7 @@ fn surrounding_filename( found_start = true; break; } - if (ch == '"' || ch == '\'') && !inside_quotes { + if (ch == '"' || ch == '\'' || ch == '`') && !inside_quotes { found_start = true; inside_quotes = true; break; @@ -754,7 +849,7 @@ fn surrounding_filename( found_end = true; break; } - if ch == '"' || ch == '\'' { + if ch == '"' || ch == '\'' || ch == '`' { // If we're inside quotes, we stop when we come across the next quote if inside_quotes { found_end = true; @@ -1576,6 +1671,16 @@ mod tests { (" ˇ\"常\"", Some("常")), (" \"ˇ常\"", Some("常")), ("ˇ\"常\"", Some("常")), + // Path with row:column suffix + ("fiˇle.rs:83:1", Some("file.rs:83:1")), + ("file.rs:83ˇ:1 foo", Some("file.rs:83:1")), + ("file.rs:20ˇ:in bar", Some("file.rs:20:in")), + // Backtick delimiters + ("`fˇile.txt`", Some("file.txt")), + ("ˇ`file.txt`", Some("file.txt")), + ("`fˇile.txt` and more", Some("file.txt")), + // Backtick with row:col + ("`fiˇle.rs:83:1`", Some("file.rs:83:1")), ]; for (input, expected) in test_cases { @@ -1873,6 +1978,274 @@ mod tests { }); } + #[gpui::test] + async fn test_hover_filename_with_row_column(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + // Insert a new file with multiple lines + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file( + path!("/root/dir/file2.rs"), + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n" + .as_bytes() + .to_vec(), + ) + .await; + + // file2.rs:5:3 should be highlighted and clickable + cx.set_state(indoc! {" + Go to file2.rs:5:3 for the fix.ˇ + "}); + + let screen_coord = cx.pixel_position(indoc! {" + Go to filˇe2.rs:5:3 for the fix. + "}); + + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Go to «file2.rs:5:3ˇ» for the fix. + "}, + ); + + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + + cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2)); + cx.update_workspace(|workspace, window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + { + let editor = active_editor.read(cx); + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + assert_eq!( + file_path, + std::path::PathBuf::from(path!("/root/dir/file2.rs")) + ); + } + + // Check that the cursor is at row 5, column 3 (0-indexed: row 4, col 2) + let (count, snapshot) = active_editor.update(cx, |editor, cx| { + (editor.selections.count(), editor.snapshot(window, cx)) + }); + assert_eq!(count, 1); + let selections = active_editor + .read(cx) + .selections + .newest::(&snapshot.display_snapshot); + assert_eq!( + selections.head().row, + 4, + "Expected cursor on row 5 (0-indexed: 4)" + ); + assert_eq!( + selections.head().column, + 2, + "Expected cursor on column 3 (0-indexed: 2)" + ); + }); + } + + #[gpui::test] + async fn test_hover_filename_with_row_only(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file( + path!("/root/dir/file2.rs"), + "line 1\nline 2\nline 3\nline 4\nline 5\n" + .as_bytes() + .to_vec(), + ) + .await; + + // file2.rs:3 should be highlighted and clickable + cx.set_state(indoc! {" + Go to file2.rs:3 please.ˇ + "}); + + let screen_coord = cx.pixel_position(indoc! {" + Go to filˇe2.rs:3 please. + "}); + + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Go to «file2.rs:3ˇ» please. + "}, + ); + + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + + cx.update_workspace(|workspace, window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + let (count, snapshot) = active_editor.update(cx, |editor, cx| { + (editor.selections.count(), editor.snapshot(window, cx)) + }); + assert_eq!(count, 1); + let selections = active_editor + .read(cx) + .selections + .newest::(&snapshot.display_snapshot); + assert_eq!( + selections.head().row, + 2, + "Expected cursor on row 3 (0-indexed: 2)" + ); + assert_eq!(selections.head().column, 0, "Expected cursor on column 0"); + }); + } + + #[gpui::test] + async fn test_hover_filename_with_non_numeric_suffix(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file( + path!("/root/dir/file2.rs"), + "line 1\nline 2\nline 3\n".as_bytes().to_vec(), + ) + .await; + + // file2.rs:2:in should resolve to file2.rs line 2 (like Ruby backtraces) + cx.set_state(indoc! {" + Error at file2.rs:2:in 'method'ˇ + "}); + + let screen_coord = cx.pixel_position(indoc! {" + Error at filˇe2.rs:2:in 'method' + "}); + + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + Error at «file2.rs:2:inˇ» 'method' + "}, + ); + + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + + cx.update_workspace(|workspace, window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + let (count, snapshot) = active_editor.update(cx, |editor, cx| { + (editor.selections.count(), editor.snapshot(window, cx)) + }); + assert_eq!(count, 1); + let selections = active_editor + .read(cx) + .selections + .newest::(&snapshot.display_snapshot); + assert_eq!( + selections.head().row, + 1, + "Expected cursor on row 2 (0-indexed: 1)" + ); + }); + } + + #[gpui::test] + async fn test_hover_markdown_link_with_row_column(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + ..Default::default() + }, + cx, + ) + .await; + + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file( + path!("/root/dir/file2.rs"), + "line 1\nline 2\nline 3\nline 4\nline 5\n" + .as_bytes() + .to_vec(), + ) + .await; + + // Markdown link [text](file2.rs:3:2) should highlight only the inner link, + // not the surrounding markdown syntax. + cx.set_state(indoc! {" + See [here](file2.rs:3:2) for details.ˇ + "}); + + let screen_coord = cx.pixel_position(indoc! {" + See [here](filˇe2.rs:3:2) for details. + "}); + + cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key()); + cx.assert_editor_text_highlights( + HighlightKey::HoveredLinkState, + indoc! {" + See [here](«file2.rs:3:2ˇ») for details. + "}, + ); + + cx.simulate_click(screen_coord, Modifiers::secondary_key()); + + cx.update_workspace(|workspace, window, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + { + let editor = active_editor.read(cx); + let buffer = editor.buffer().read(cx).as_singleton().unwrap(); + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + assert_eq!( + file_path, + std::path::PathBuf::from(path!("/root/dir/file2.rs")) + ); + } + + // Check cursor is at row 3, column 2 (0-indexed: row 2, col 1) + let (count, snapshot) = active_editor.update(cx, |editor, cx| { + (editor.selections.count(), editor.snapshot(window, cx)) + }); + assert_eq!(count, 1); + let selections = active_editor + .read(cx) + .selections + .newest::(&snapshot.display_snapshot); + assert_eq!( + selections.head().row, + 2, + "Expected cursor on row 3 (0-indexed: 2)" + ); + assert_eq!( + selections.head().column, + 1, + "Expected cursor on column 2 (0-indexed: 1)" + ); + }); + } + #[gpui::test] async fn test_hover_directories(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index cfa7284127e..6474170aace 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -11,8 +11,8 @@ use anyhow::Context as _; use gpui::{ AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, - StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, - Window, canvas, div, px, + StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TaskExt, + TextStyleRefinement, Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs new file mode 100644 index 00000000000..1634ad35564 --- /dev/null +++ b/crates/editor/src/input.rs @@ -0,0 +1,2221 @@ +use super::*; + +const ORDERED_LIST_MAX_MARKER_LEN: usize = 16; + +impl Editor { + pub fn set_input_enabled(&mut self, input_enabled: bool) { + self.input_enabled = input_enabled; + } + + pub fn set_expects_character_input(&mut self, expects_character_input: bool) { + self.expects_character_input = expects_character_input; + } + + pub fn set_autoindent(&mut self, autoindent: bool) { + if autoindent { + self.autoindent_mode = Some(AutoindentMode::EachLine); + } else { + self.autoindent_mode = None; + } + } + + pub fn set_use_autoclose(&mut self, autoclose: bool) { + self.use_autoclose = autoclose; + } + + pub fn replay_insert_event( + &mut self, + text: &str, + relative_utf16_range: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + cx.emit(EditorEvent::InputIgnored { text: text.into() }); + return; + } + if let Some(relative_utf16_range) = relative_utf16_range { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + let new_ranges = selections.into_iter().map(|range| { + let start = MultiBufferOffsetUtf16(OffsetUtf16( + range + .head() + .0 + .0 + .saturating_add_signed(relative_utf16_range.start), + )); + let end = MultiBufferOffsetUtf16(OffsetUtf16( + range + .head() + .0 + .0 + .saturating_add_signed(relative_utf16_range.end), + )); + start..end + }); + s.select_ranges(new_ranges); + }); + } + + self.handle_input(text, window, cx); + } + + pub fn handle_input(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + let text: Arc = text.into(); + + if self.read_only(cx) { + return; + } + + self.unfold_buffers_with_selections(cx); + + let selections = self.selections.all_adjusted(&self.display_snapshot(cx)); + let mut bracket_inserted = false; + let mut edits = Vec::new(); + let mut linked_edits = LinkedEdits::new(); + let mut new_selections = Vec::with_capacity(selections.len()); + let mut new_autoclose_regions = Vec::new(); + let snapshot = self.buffer.read(cx).read(cx); + let mut clear_linked_edit_ranges = false; + let mut all_selections_read_only = true; + let mut has_adjacent_edits = false; + let mut in_adjacent_group = false; + + let mut regions = self + .selections_with_autoclose_regions(selections, &snapshot) + .peekable(); + + while let Some((selection, autoclose_region)) = regions.next() { + if snapshot + .point_to_buffer_point(selection.head()) + .is_none_or(|(snapshot, ..)| !snapshot.capability.editable()) + { + continue; + } + if snapshot + .point_to_buffer_point(selection.tail()) + .is_none_or(|(snapshot, ..)| !snapshot.capability.editable()) + { + // note, ideally we'd clip the tail to the closest writeable region towards the head + continue; + } + all_selections_read_only = false; + + if let Some(scope) = snapshot.language_scope_at(selection.head()) { + // Determine if the inserted text matches the opening or closing + // bracket of any of this language's bracket pairs. + let mut bracket_pair = None; + let mut is_bracket_pair_start = false; + let mut is_bracket_pair_end = false; + if !text.is_empty() { + let mut bracket_pair_matching_end = None; + // `text` can be empty when a user is using IME (e.g. Chinese Wubi Simplified) + // and they are removing the character that triggered IME popup. + for (pair, enabled) in scope.brackets() { + if !pair.close && !pair.surround { + continue; + } + + if enabled && pair.start.ends_with(text.as_ref()) { + let prefix_len = pair.start.len() - text.len(); + let preceding_text_matches_prefix = prefix_len == 0 + || (selection.start.column >= (prefix_len as u32) + && snapshot.contains_str_at( + Point::new( + selection.start.row, + selection.start.column - (prefix_len as u32), + ), + &pair.start[..prefix_len], + )); + if preceding_text_matches_prefix { + bracket_pair = Some(pair.clone()); + is_bracket_pair_start = true; + break; + } + } + if pair.end.as_str() == text.as_ref() && bracket_pair_matching_end.is_none() + { + // take first bracket pair matching end, but don't break in case a later bracket + // pair matches start + bracket_pair_matching_end = Some(pair.clone()); + } + } + if let Some(end) = bracket_pair_matching_end + && bracket_pair.is_none() + { + bracket_pair = Some(end); + is_bracket_pair_end = true; + } + } + + if let Some(bracket_pair) = bracket_pair { + let snapshot_settings = snapshot.language_settings_at(selection.start, cx); + let autoclose = self.use_autoclose && snapshot_settings.use_autoclose; + let auto_surround = + self.use_auto_surround && snapshot_settings.use_auto_surround; + if selection.is_empty() { + if is_bracket_pair_start { + // If the inserted text is a suffix of an opening bracket and the + // selection is preceded by the rest of the opening bracket, then + // insert the closing bracket. + let following_text_allows_autoclose = snapshot + .chars_at(selection.start) + .next() + .is_none_or(|c| scope.should_autoclose_before(c)); + + let preceding_text_allows_autoclose = selection.start.column == 0 + || snapshot + .reversed_chars_at(selection.start) + .next() + .is_none_or(|c| { + bracket_pair.start != bracket_pair.end + || !snapshot + .char_classifier_at(selection.start) + .is_word(c) + }); + + let is_closing_quote = if bracket_pair.end == bracket_pair.start + && bracket_pair.start.len() == 1 + { + if let Some(target) = bracket_pair.start.chars().next() { + let mut byte_offset = 0u32; + let current_line_count = snapshot + .reversed_chars_at(selection.start) + .take_while(|&c| c != '\n') + .filter(|c| { + byte_offset += c.len_utf8() as u32; + if *c != target { + return false; + } + + let point = Point::new( + selection.start.row, + selection.start.column.saturating_sub(byte_offset), + ); + + let is_enabled = snapshot + .language_scope_at(point) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| enabled) + }) + .unwrap_or(true); + + let is_delimiter = snapshot + .language_scope_at(Point::new( + point.row, + point.column + 1, + )) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| !enabled) + }) + .unwrap_or(false); + + is_enabled && !is_delimiter + }) + .count(); + current_line_count % 2 == 1 + } else { + false + } + } else { + false + }; + + if autoclose + && bracket_pair.close + && following_text_allows_autoclose + && preceding_text_allows_autoclose + && !is_closing_quote + { + let anchor = snapshot.anchor_before(selection.end); + new_selections.push((selection.map(|_| anchor), text.len())); + new_autoclose_regions.push(( + anchor, + text.len(), + selection.id, + bracket_pair.clone(), + )); + edits.push(( + selection.range(), + format!("{}{}", text, bracket_pair.end).into(), + )); + bracket_inserted = true; + continue; + } + } + + if let Some(region) = autoclose_region { + // If the selection is followed by an auto-inserted closing bracket, + // then don't insert that closing bracket again; just move the selection + // past the closing bracket. + let should_skip = selection.end == region.range.end.to_point(&snapshot) + && text.as_ref() == region.pair.end.as_str() + && snapshot.contains_str_at(region.range.end, text.as_ref()); + if should_skip { + let anchor = snapshot.anchor_after(selection.end); + new_selections + .push((selection.map(|_| anchor), region.pair.end.len())); + continue; + } + } + + let always_treat_brackets_as_autoclosed = snapshot + .language_settings_at(selection.start, cx) + .always_treat_brackets_as_autoclosed; + if always_treat_brackets_as_autoclosed + && is_bracket_pair_end + && snapshot.contains_str_at(selection.end, text.as_ref()) + { + // Otherwise, when `always_treat_brackets_as_autoclosed` is set to `true + // and the inserted text is a closing bracket and the selection is followed + // by the closing bracket then move the selection past the closing bracket. + let anchor = snapshot.anchor_after(selection.end); + new_selections.push((selection.map(|_| anchor), text.len())); + continue; + } + } + // If an opening bracket is 1 character long and is typed while + // text is selected, then surround that text with the bracket pair. + else if auto_surround + && bracket_pair.surround + && is_bracket_pair_start + && bracket_pair.start.chars().count() == 1 + { + edits.push((selection.start..selection.start, text.clone())); + edits.push(( + selection.end..selection.end, + bracket_pair.end.as_str().into(), + )); + bracket_inserted = true; + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(selection.start), + end: snapshot.anchor_before(selection.end), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); + continue; + } + } + } + + if self.auto_replace_emoji_shortcode + && selection.is_empty() + && text.as_ref().ends_with(':') + && let Some(possible_emoji_short_code) = + Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start) + && !possible_emoji_short_code.is_empty() + && let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) + { + let emoji_shortcode_start = Point::new( + selection.start.row, + selection.start.column - possible_emoji_short_code.len() as u32 - 1, + ); + + // Remove shortcode from buffer + edits.push(( + emoji_shortcode_start..selection.start, + "".to_string().into(), + )); + new_selections.push(( + Selection { + id: selection.id, + start: snapshot.anchor_after(emoji_shortcode_start), + end: snapshot.anchor_before(selection.start), + reversed: selection.reversed, + goal: selection.goal, + }, + 0, + )); + + // Insert emoji + let selection_start_anchor = snapshot.anchor_after(selection.start); + new_selections.push((selection.map(|_| selection_start_anchor), 0)); + edits.push((selection.start..selection.end, emoji.to_string().into())); + + continue; + } + + let next_is_adjacent = regions + .peek() + .is_some_and(|(next, _)| selection.end == next.start); + + // If not handling any auto-close operation, then just replace the selected + // text with the given input and move the selection to the end of the + // newly inserted text. + let anchor = if in_adjacent_group || next_is_adjacent { + // After edits the right bias would shift those anchor to the next visible fragment + // but we want to resolve to the previous one + snapshot.anchor_before(selection.end) + } else { + snapshot.anchor_after(selection.end) + }; + + if !self.linked_edit_ranges.is_empty() { + let start_anchor = snapshot.anchor_before(selection.start); + let classifier = snapshot + .char_classifier_at(start_anchor) + .scope_context(Some(CharScopeContext::LinkedEdit)); + + if let Some((_, anchor_range)) = + snapshot.anchor_range_to_buffer_anchor_range(start_anchor..anchor) + { + let is_word_char = text + .chars() + .next() + .is_none_or(|char| classifier.is_word(char)); + + let is_dot = text.as_ref() == "."; + let should_apply_linked_edit = is_word_char || is_dot; + + if should_apply_linked_edit { + linked_edits.push(&self, anchor_range, text.clone(), cx); + } else { + clear_linked_edit_ranges = true; + } + } + } + + new_selections.push((selection.map(|_| anchor), 0)); + edits.push((selection.start..selection.end, text.clone())); + + has_adjacent_edits |= next_is_adjacent; + in_adjacent_group = next_is_adjacent; + } + + if all_selections_read_only { + return; + } + + drop(regions); + drop(snapshot); + + self.transact(window, cx, |this, window, cx| { + if clear_linked_edit_ranges { + this.linked_edit_ranges.clear(); + } + let initial_buffer_versions = + jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx); + + this.buffer.update(cx, |buffer, cx| { + if has_adjacent_edits { + buffer.edit_non_coalesce(edits, this.autoindent_mode.clone(), cx); + } else { + buffer.edit(edits, this.autoindent_mode.clone(), cx); + } + }); + linked_edits.apply(cx); + let new_anchor_selections = new_selections.iter().map(|e| &e.0); + let new_selection_deltas = new_selections.iter().map(|e| e.1); + let map = this.display_map.update(cx, |map, cx| map.snapshot(cx)); + let new_selections = resolve_selections_wrapping_blocks::( + new_anchor_selections, + &map, + ) + .zip(new_selection_deltas) + .map(|(selection, delta)| Selection { + id: selection.id, + start: selection.start + delta, + end: selection.end + delta, + reversed: selection.reversed, + goal: SelectionGoal::None, + }) + .collect::>(); + + let mut i = 0; + for (position, delta, selection_id, pair) in new_autoclose_regions { + let position = position.to_offset(map.buffer_snapshot()) + delta; + let start = map.buffer_snapshot().anchor_before(position); + let end = map.buffer_snapshot().anchor_after(position); + while let Some(existing_state) = this.autoclose_regions.get(i) { + match existing_state + .range + .start + .cmp(&start, map.buffer_snapshot()) + { + Ordering::Less => i += 1, + Ordering::Greater => break, + Ordering::Equal => { + match end.cmp(&existing_state.range.end, map.buffer_snapshot()) { + Ordering::Less => i += 1, + Ordering::Equal => break, + Ordering::Greater => break, + } + } + } + } + this.autoclose_regions.insert( + i, + AutocloseRegion { + selection_id, + range: start..end, + pair, + }, + ); + } + + let had_active_edit_prediction = this.has_active_edit_prediction(); + this.change_selections( + SelectionEffects::scroll(Autoscroll::fit()).completions(false), + window, + cx, + |s| s.select(new_selections), + ); + + if !bracket_inserted + && let Some(on_type_format_task) = + this.trigger_on_type_formatting(text.to_string(), window, cx) + { + on_type_format_task.detach_and_log_err(cx); + } + + let editor_settings = EditorSettings::get_global(cx); + if bracket_inserted + && (editor_settings.auto_signature_help + || editor_settings.show_signature_help_after_edits) + { + this.show_signature_help(&ShowSignatureHelp, window, cx); + } + + let trigger_in_words = + this.show_edit_predictions_in_menu() || !had_active_edit_prediction; + if this.hard_wrap.is_some() { + let latest: Range = this.selections.newest(&map).range(); + if latest.is_empty() + && this + .buffer() + .read(cx) + .snapshot(cx) + .line_len(MultiBufferRow(latest.start.row)) + == latest.start.column + { + this.rewrap( + RewrapOptions { + override_language_settings: true, + preserve_existing_whitespace: true, + line_length: None, + }, + cx, + ) + } + } + this.trigger_completion_on_input(&text, trigger_in_words, window, cx); + refresh_linked_ranges(this, window, cx); + this.refresh_edit_prediction(true, false, window, cx); + jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); + }); + } + + pub fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + + self.transact(window, cx, |this, window, cx| { + let (edits_with_flags, selection_info): (Vec<_>, Vec<_>) = { + let selections = this + .selections + .all::(&this.display_snapshot(cx)); + let multi_buffer = this.buffer.read(cx); + let buffer = multi_buffer.snapshot(cx); + selections + .iter() + .map(|selection| { + let start_point = selection.start.to_point(&buffer); + let mut existing_indent = + buffer.indent_size_for_line(MultiBufferRow(start_point.row)); + let full_indent_len = existing_indent.len; + existing_indent.len = cmp::min(existing_indent.len, start_point.column); + let mut start = selection.start; + let end = selection.end; + let selection_is_empty = start == end; + let language_scope = buffer.language_scope_at(start); + let (delimiter, newline_config) = if let Some(language) = &language_scope { + let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets( + &buffer, + start..end, + language, + ) + || NewlineConfig::insert_extra_newline_tree_sitter( + &buffer, + start..end, + ); + + let mut newline_config = NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: if needs_extra_newline { + Some(IndentSize::spaces(0)) + } else { + None + }, + prevent_auto_indent: false, + }; + + let comment_delimiter = maybe!({ + if !selection_is_empty { + return None; + } + + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } + + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); + }); + + let doc_delimiter = maybe!({ + if !selection_is_empty { + return None; + } + + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } + + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + let list_delimiter = maybe!({ + if !selection_is_empty { + return None; + } + + if !multi_buffer.language_settings(cx).extend_list_on_newline { + return None; + } + + return list_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + ( + comment_delimiter.or(doc_delimiter).or(list_delimiter), + newline_config, + ) + } else { + ( + None, + NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: None, + prevent_auto_indent: false, + }, + ) + }; + + let (edit_start, new_text, prevent_auto_indent) = match &newline_config { + NewlineConfig::ClearCurrentLine => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + (row_start, String::new(), false) + } + NewlineConfig::UnindentCurrentLine { continuation } => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + let tab_size = buffer.language_settings_at(start, cx).tab_size; + let tab_size_indent = IndentSize::spaces(tab_size.get()); + let reduced_indent = + existing_indent.with_delta(Ordering::Less, tab_size_indent); + let mut new_text = String::new(); + new_text.extend(reduced_indent.chars()); + new_text.push_str(continuation); + (row_start, new_text, true) + } + NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent, + prevent_auto_indent, + } => { + let auto_indent_mode = + buffer.language_settings_at(start, cx).auto_indent; + let preserve_indent = + auto_indent_mode != language::AutoIndentMode::None; + let apply_syntax_indent = + auto_indent_mode == language::AutoIndentMode::SyntaxAware; + let capacity_for_delimiter = + delimiter.as_deref().map(str::len).unwrap_or_default(); + let existing_indent_len = if preserve_indent { + existing_indent.len as usize + } else { + 0 + }; + let extra_line_len = extra_line_additional_indent + .map(|i| 1 + existing_indent_len + i.len as usize) + .unwrap_or(0); + let mut new_text = String::with_capacity( + 1 + capacity_for_delimiter + + existing_indent_len + + additional_indent.len as usize + + extra_line_len, + ); + new_text.push('\n'); + if preserve_indent { + new_text.extend(existing_indent.chars()); + } + new_text.extend(additional_indent.chars()); + if let Some(delimiter) = &delimiter { + new_text.push_str(delimiter); + } + if let Some(extra_indent) = extra_line_additional_indent { + new_text.push('\n'); + if preserve_indent { + new_text.extend(existing_indent.chars()); + } + new_text.extend(extra_indent.chars()); + } + // Extend the edit to the beginning of the line + // to clear auto-indent whitespace that would + // otherwise remain as trailing whitespace. This + // applies to blank lines and lines where only + // indentation remains before the cursor. + if selection_is_empty + && preserve_indent + && full_indent_len > 0 + && start_point.column == full_indent_len + { + start = buffer.point_to_offset(Point::new(start_point.row, 0)); + } + + ( + start, + new_text, + *prevent_auto_indent || !apply_syntax_indent, + ) + } + }; + + let anchor = buffer.anchor_after(end); + let new_selection = selection.map(|_| anchor); + ( + ((edit_start..end, new_text), prevent_auto_indent), + (newline_config.has_extra_line(), new_selection), + ) + }) + .unzip() + }; + + let mut auto_indent_edits = Vec::new(); + let mut edits = Vec::new(); + for (edit, prevent_auto_indent) in edits_with_flags { + if prevent_auto_indent { + edits.push(edit); + } else { + auto_indent_edits.push(edit); + } + } + if !edits.is_empty() { + this.edit(edits, cx); + } + if !auto_indent_edits.is_empty() { + this.edit_with_autoindent(auto_indent_edits, cx); + } + + let buffer = this.buffer.read(cx).snapshot(cx); + let new_selections = selection_info + .into_iter() + .map(|(extra_newline_inserted, new_selection)| { + let mut cursor = new_selection.end.to_point(&buffer); + if extra_newline_inserted { + cursor.row -= 1; + cursor.column = buffer.line_len(MultiBufferRow(cursor.row)); + } + new_selection.map(|_| cursor) + }) + .collect(); + + this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); + this.refresh_edit_prediction(true, false, window, cx); + if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { + task.detach_and_log_err(cx); + } + }); + } + + pub fn newline_above(&mut self, _: &NewlineAbove, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + + let buffer = self.buffer.read(cx); + let snapshot = buffer.snapshot(cx); + + let mut edits = Vec::new(); + let mut rows = Vec::new(); + + for (rows_inserted, selection) in self + .selections + .all_adjusted(&self.display_snapshot(cx)) + .into_iter() + .enumerate() + { + let cursor = selection.head(); + let row = cursor.row; + + let start_of_line = snapshot.clip_point(Point::new(row, 0), Bias::Left); + + let newline = "\n".to_string(); + edits.push((start_of_line..start_of_line, newline)); + + rows.push(row + rows_inserted as u32); + } + + self.transact(window, cx, |editor, window, cx| { + editor.edit(edits, cx); + + editor.change_selections(Default::default(), window, cx, |s| { + let mut index = 0; + s.move_cursors_with(&mut |map, _, _| { + let row = rows[index]; + index += 1; + + let point = Point::new(row, 0); + let boundary = map.next_line_boundary(point).1; + let clipped = map.clip_point(boundary, Bias::Left); + + (clipped, SelectionGoal::None) + }); + }); + + let mut indent_edits = Vec::new(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + for row in rows { + let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); + for (row, indent) in indents { + if indent.len == 0 { + continue; + } + + let text = match indent.kind { + IndentKind::Space => " ".repeat(indent.len as usize), + IndentKind::Tab => "\t".repeat(indent.len as usize), + }; + let point = Point::new(row.0, 0); + indent_edits.push((point..point, text)); + } + } + editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } + }); + } + + pub fn newline_below(&mut self, _: &NewlineBelow, window: &mut Window, cx: &mut Context) { + if self.read_only(cx) { + return; + } + + let mut buffer_edits: HashMap, Vec)> = HashMap::default(); + let mut rows = Vec::new(); + let mut rows_inserted = 0; + + for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { + let cursor = selection.head(); + let row = cursor.row; + + let point = Point::new(row, 0); + let Some((buffer_handle, buffer_point)) = + self.buffer.read(cx).point_to_buffer_point(point, cx) + else { + continue; + }; + + buffer_edits + .entry(buffer_handle.entity_id()) + .or_insert_with(|| (buffer_handle, Vec::new())) + .1 + .push(buffer_point); + + rows_inserted += 1; + rows.push(row + rows_inserted); + } + + self.transact(window, cx, |editor, window, cx| { + for (_, (buffer_handle, points)) in &buffer_edits { + buffer_handle.update(cx, |buffer, cx| { + let edits: Vec<_> = points + .iter() + .map(|point| { + let target = Point::new(point.row + 1, 0); + let start_of_line = buffer.point_to_offset(target).min(buffer.len()); + (start_of_line..start_of_line, "\n") + }) + .collect(); + buffer.edit(edits, None, cx); + }); + } + + editor.change_selections(Default::default(), window, cx, |s| { + let mut index = 0; + s.move_cursors_with(&mut |map, _, _| { + let row = rows[index]; + index += 1; + + let point = Point::new(row, 0); + let boundary = map.next_line_boundary(point).1; + let clipped = map.clip_point(boundary, Bias::Left); + + (clipped, SelectionGoal::None) + }); + }); + + let mut indent_edits = Vec::new(); + let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); + for row in rows { + let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); + for (row, indent) in indents { + if indent.len == 0 { + continue; + } + + let text = match indent.kind { + IndentKind::Space => " ".repeat(indent.len as usize), + IndentKind::Tab => "\t".repeat(indent.len as usize), + }; + let point = Point::new(row.0, 0); + indent_edits.push((point..point, text)); + } + } + editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } + }); + } + + pub fn insert(&mut self, text: &str, window: &mut Window, cx: &mut Context) { + let autoindent = text.is_empty().not().then(|| AutoindentMode::Block { + original_indent_columns: Vec::new(), + }); + self.replace_selections(text, autoindent, window, cx, false); + } + + /// Collects linked edits for the current selections, pairing each linked + /// range with `text`. + pub fn linked_edits_for_selections(&self, text: Arc, cx: &App) -> LinkedEdits { + let multibuffer_snapshot = self.buffer().read(cx).snapshot(cx); + let mut linked_edits = LinkedEdits::new(); + if !self.linked_edit_ranges.is_empty() { + for selection in self.selections.disjoint_anchors() { + let Some((_, range)) = + multibuffer_snapshot.anchor_range_to_buffer_anchor_range(selection.range()) + else { + continue; + }; + linked_edits.push(self, range, text.clone(), cx); + } + } + linked_edits + } + + /// Deletes the content covered by the current selections and applies + /// linked edits. + pub fn delete_selections_with_linked_edits( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.replace_selections("", None, window, cx, true); + } + + pub(super) fn observe_pending_input(&mut self, window: &mut Window, cx: &mut Context) { + let mut pending: String = window + .pending_input_keystrokes() + .into_iter() + .flatten() + .filter_map(|keystroke| keystroke.key_char.clone()) + .collect(); + + if !self.input_enabled || self.read_only || !self.focus_handle.is_focused(window) { + pending = "".to_string(); + } + + let existing_pending = self + .text_highlights(HighlightKey::PendingInput, cx) + .map(|(_, ranges)| ranges.to_vec()); + if existing_pending.is_none() && pending.is_empty() { + return; + } + let transaction = + self.transact(window, cx, |this, window, cx| { + let selections = this + .selections + .all::(&this.display_snapshot(cx)); + let edits = selections + .iter() + .map(|selection| (selection.end..selection.end, pending.clone())); + this.edit(edits, cx); + this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(selections.into_iter().enumerate().map(|(ix, sel)| { + sel.start + ix * pending.len()..sel.end + ix * pending.len() + })); + }); + if let Some(existing_ranges) = existing_pending { + let edits = existing_ranges.iter().map(|range| (range.clone(), "")); + this.edit(edits, cx); + } + }); + + let snapshot = self.snapshot(window, cx); + let ranges = self + .selections + .all::(&snapshot.display_snapshot) + .into_iter() + .map(|selection| { + snapshot.buffer_snapshot().anchor_after(selection.end) + ..snapshot + .buffer_snapshot() + .anchor_before(selection.end + pending.len()) + }) + .collect(); + + if pending.is_empty() { + self.clear_highlights(HighlightKey::PendingInput, cx); + } else { + self.highlight_text( + HighlightKey::PendingInput, + ranges, + HighlightStyle { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: None, + wavy: false, + }), + ..Default::default() + }, + cx, + ); + } + + self.ime_transaction = self.ime_transaction.or(transaction); + if let Some(transaction) = self.ime_transaction { + self.buffer.update(cx, |buffer, cx| { + buffer.group_until_transaction(transaction, cx); + }); + } + + if self + .text_highlights(HighlightKey::PendingInput, cx) + .is_none() + { + self.ime_transaction.take(); + } + } + + pub(super) fn linked_editing_ranges_for( + &self, + query_range: Range, + cx: &App, + ) -> Option, Vec>>> { + use text::ToOffset as TO; + + if self.linked_edit_ranges.is_empty() { + return None; + } + if query_range.start.buffer_id != query_range.end.buffer_id { + return None; + }; + let multibuffer_snapshot = self.buffer.read(cx).snapshot(cx); + let buffer = self.buffer.read(cx).buffer(query_range.end.buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let (base_range, linked_ranges) = self.linked_edit_ranges.get( + buffer_snapshot.remote_id(), + query_range.clone(), + &buffer_snapshot, + )?; + // find offset from the start of current range to current cursor position + let start_byte_offset = TO::to_offset(&base_range.start, &buffer_snapshot); + + let start_offset = TO::to_offset(&query_range.start, &buffer_snapshot); + let start_difference = start_offset - start_byte_offset; + let end_offset = TO::to_offset(&query_range.end, &buffer_snapshot); + let end_difference = end_offset - start_byte_offset; + + // Current range has associated linked ranges. + let mut linked_edits = HashMap::<_, Vec<_>>::default(); + for range in linked_ranges.iter() { + let start_offset = TO::to_offset(&range.start, &buffer_snapshot); + let end_offset = start_offset + end_difference; + let start_offset = start_offset + start_difference; + if start_offset > buffer_snapshot.len() || end_offset > buffer_snapshot.len() { + continue; + } + if self.selections.disjoint_anchor_ranges().any(|s| { + let Some((selection_start, _)) = + multibuffer_snapshot.anchor_to_buffer_anchor(s.start) + else { + return false; + }; + let Some((selection_end, _)) = multibuffer_snapshot.anchor_to_buffer_anchor(s.end) + else { + return false; + }; + if selection_start.buffer_id != query_range.start.buffer_id + || selection_end.buffer_id != query_range.end.buffer_id + { + return false; + } + TO::to_offset(&selection_start, &buffer_snapshot) <= end_offset + && TO::to_offset(&selection_end, &buffer_snapshot) >= start_offset + }) { + continue; + } + let start = buffer_snapshot.anchor_after(start_offset); + let end = buffer_snapshot.anchor_after(end_offset); + linked_edits + .entry(buffer.clone()) + .or_default() + .push(start..end); + } + Some(linked_edits) + } + + pub(super) fn marked_text_ranges( + &self, + cx: &App, + ) -> Option>> { + let snapshot = self.buffer.read(cx).read(cx); + let (_, ranges) = self.text_highlights(HighlightKey::InputComposition, cx)?; + Some( + ranges + .iter() + .map(move |range| { + range.start.to_offset_utf16(&snapshot)..range.end.to_offset_utf16(&snapshot) + }) + .collect(), + ) + } + + /// Replaces the editor's selections with the provided `text`, applying the + /// given `autoindent_mode` (`None` will skip autoindentation). + /// + /// Early returns if the editor is in read-only mode, without applying any + /// edits. + pub(super) fn replace_selections( + &mut self, + text: &str, + autoindent_mode: Option, + window: &mut Window, + cx: &mut Context, + apply_linked_edits: bool, + ) { + if self.read_only(cx) { + return; + } + + let text: Arc = text.into(); + self.transact(window, cx, |this, window, cx| { + let old_selections = this.selections.all_adjusted(&this.display_snapshot(cx)); + let linked_edits = if apply_linked_edits { + this.linked_edits_for_selections(text.clone(), cx) + } else { + LinkedEdits::new() + }; + + let selection_anchors = this.buffer.update(cx, |buffer, cx| { + let anchors = { + let snapshot = buffer.read(cx); + old_selections + .iter() + .map(|s| { + let anchor = snapshot.anchor_after(s.head()); + s.map(|_| anchor) + }) + .collect::>() + }; + buffer.edit( + old_selections + .iter() + .map(|s| (s.start..s.end, text.clone())), + autoindent_mode, + cx, + ); + anchors + }); + + linked_edits.apply(cx); + + this.change_selections(Default::default(), window, cx, |s| { + s.select_anchors(selection_anchors); + }); + + if apply_linked_edits { + refresh_linked_ranges(this, window, cx); + } + + cx.notify(); + }); + } + + /// If any empty selections is touching the start of its innermost containing autoclose + /// region, expand it to select the brackets. + pub(super) fn select_autoclose_pair(&mut self, window: &mut Window, cx: &mut Context) { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let buffer = self.buffer.read(cx).read(cx); + let new_selections = self + .selections_with_autoclose_regions(selections, &buffer) + .map(|(mut selection, region)| { + if !selection.is_empty() { + return selection; + } + + if let Some(region) = region { + let mut range = region.range.to_offset(&buffer); + if selection.start == range.start && range.start.0 >= region.pair.start.len() { + range.start -= region.pair.start.len(); + if buffer.contains_str_at(range.start, ®ion.pair.start) + && buffer.contains_str_at(range.end, ®ion.pair.end) + { + range.end += region.pair.end.len(); + selection.start = range.start; + selection.end = range.end; + + return selection; + } + } + } + + let always_treat_brackets_as_autoclosed = buffer + .language_settings_at(selection.start, cx) + .always_treat_brackets_as_autoclosed; + + if !always_treat_brackets_as_autoclosed { + return selection; + } + + if let Some(scope) = buffer.language_scope_at(selection.start) { + for (pair, enabled) in scope.brackets() { + if !enabled || !pair.close { + continue; + } + + if buffer.contains_str_at(selection.start, &pair.end) { + let pair_start_len = pair.start.len(); + if buffer.contains_str_at( + selection.start.saturating_sub_usize(pair_start_len), + &pair.start, + ) { + selection.start -= pair_start_len; + selection.end += pair.end.len(); + + return selection; + } + } + } + } + + selection + }) + .collect(); + + drop(buffer); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select(new_selections) + }); + } + + /// Remove any autoclose regions that no longer contain their selection or have invalid anchors in ranges. + pub(super) fn invalidate_autoclose_regions( + &mut self, + mut selections: &[Selection], + buffer: &MultiBufferSnapshot, + ) { + self.autoclose_regions.retain(|state| { + if !state.range.start.is_valid(buffer) || !state.range.end.is_valid(buffer) { + return false; + } + + let mut i = 0; + while let Some(selection) = selections.get(i) { + if selection.end.cmp(&state.range.start, buffer).is_lt() { + selections = &selections[1..]; + continue; + } + if selection.start.cmp(&state.range.end, buffer).is_gt() { + break; + } + if selection.id == state.selection_id { + return true; + } else { + i += 1; + } + } + false + }); + } + + fn set_use_auto_surround(&mut self, auto_surround: bool) { + self.use_auto_surround = auto_surround; + } + + fn find_possible_emoji_shortcode_at_position( + snapshot: &MultiBufferSnapshot, + position: Point, + ) -> Option { + let mut chars = Vec::new(); + let mut found_colon = false; + for char in snapshot.reversed_chars_at(position).take(100) { + // Found a possible emoji shortcode in the middle of the buffer + if found_colon { + if char.is_whitespace() { + chars.reverse(); + return Some(chars.iter().collect()); + } + // If the previous character is not a whitespace, we are in the middle of a word + // and we only want to complete the shortcode if the word is made up of other emojis + let mut containing_word = String::new(); + for ch in snapshot + .reversed_chars_at(position) + .skip(chars.len() + 1) + .take(100) + { + if ch.is_whitespace() { + break; + } + containing_word.push(ch); + } + let containing_word = containing_word.chars().rev().collect::(); + if util::word_consists_of_emojis(containing_word.as_str()) { + chars.reverse(); + return Some(chars.iter().collect()); + } + } + + if char.is_whitespace() || !char.is_ascii() { + return None; + } + if char == ':' { + found_colon = true; + } else { + chars.push(char); + } + } + // Found a possible emoji shortcode at the beginning of the buffer + chars.reverse(); + Some(chars.iter().collect()) + } + + /// Iterate the given selections, and for each one, find the smallest surrounding + /// autoclose region. This uses the ordering of the selections and the autoclose + /// regions to avoid repeated comparisons. + fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>( + &'a self, + selections: impl IntoIterator>, + buffer: &'a MultiBufferSnapshot, + ) -> impl Iterator, Option<&'a AutocloseRegion>)> { + let mut i = 0; + let mut regions = self.autoclose_regions.as_slice(); + selections.into_iter().map(move |selection| { + let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer); + + let mut enclosing = None; + while let Some(pair_state) = regions.get(i) { + if pair_state.range.end.to_offset(buffer) < range.start { + regions = ®ions[i + 1..]; + i = 0; + } else if pair_state.range.start.to_offset(buffer) > range.end { + break; + } else { + if pair_state.selection_id == selection.id { + enclosing = Some(pair_state); + } + i += 1; + } + } + + (selection, enclosing) + }) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl Editor { + pub fn set_linked_edit_ranges_for_testing( + &mut self, + ranges: Vec<(Range, Vec>)>, + cx: &mut Context, + ) -> Option<()> { + let Some((buffer, _)) = self + .buffer + .read(cx) + .text_anchor_for_position(self.selections.newest_anchor().start, cx) + else { + return None; + }; + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + let mut linked_ranges = Vec::with_capacity(ranges.len()); + for (base_range, linked_ranges_points) in ranges { + let base_anchor = + buffer.anchor_before(base_range.start)..buffer.anchor_after(base_range.end); + let linked_anchors = linked_ranges_points + .into_iter() + .map(|range| buffer.anchor_before(range.start)..buffer.anchor_after(range.end)) + .collect(); + linked_ranges.push((base_anchor, linked_anchors)); + } + let mut map = HashMap::default(); + map.insert(buffer_id, linked_ranges); + self.linked_edit_ranges = linked_editing_ranges::LinkedEditingRanges(map); + Some(()) + } + + #[cfg(test)] + pub(super) fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) { + self.auto_replace_emoji_shortcode = auto_replace; + } +} + +pub(super) fn is_list_prefix_row( + row: MultiBufferRow, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> bool { + let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { + return false; + }; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_prefixes: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|p| p.as_ref()) + .collect::>() + }) + .collect(); + let unordered_list_markers: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| marker.as_ref()) + .collect(); + let all_prefixes: Vec<_> = task_list_prefixes + .into_iter() + .chain(unordered_list_markers) + .collect(); + if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + if all_prefixes + .iter() + .any(|prefix| candidate.starts_with(*prefix)) + { + return true; + } + } + + let ordered_list_candidate: String = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + if let Some(captures) = regex.captures(&ordered_list_candidate) { + return captures.get(0).is_some(); + } + } + + false +} + +#[derive(Debug)] +enum NewlineConfig { + /// Insert newline with optional additional indent and optional extra blank line + Newline { + additional_indent: IndentSize, + extra_line_additional_indent: Option, + prevent_auto_indent: bool, + }, + /// Clear the current line + ClearCurrentLine, + /// Unindent the current line and add continuation + UnindentCurrentLine { continuation: Arc }, +} + +impl NewlineConfig { + fn has_extra_line(&self) -> bool { + matches!( + self, + Self::Newline { + extra_line_additional_indent: Some(_), + .. + } + ) + } + + fn insert_extra_newline_brackets( + buffer: &MultiBufferSnapshot, + range: Range, + language: &language::LanguageScope, + ) -> bool { + let leading_whitespace_len = buffer + .reversed_chars_at(range.start) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let trailing_whitespace_len = buffer + .chars_at(range.end) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; + + language.brackets().any(|(pair, enabled)| { + let pair_start = pair.start.trim_end(); + let pair_end = pair.end.trim_start(); + + enabled + && pair.newline + && buffer.contains_str_at(range.end, pair_end) + && buffer.contains_str_at( + range.start.saturating_sub_usize(pair_start.len()), + pair_start, + ) + }) + } + + fn insert_extra_newline_tree_sitter( + buffer: &MultiBufferSnapshot, + range: Range, + ) -> bool { + let (buffer, range) = match buffer + .range_to_buffer_ranges(range.start..range.end) + .as_slice() + { + [(buffer_snapshot, range, _)] => (buffer_snapshot.clone(), range.clone()), + _ => return false, + }; + let pair = { + let mut result: Option> = None; + + for pair in buffer + .all_bracket_ranges(range.start.0..range.end.0) + .filter(move |pair| { + pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 + }) + { + let len = pair.close_range.end - pair.open_range.start; + + if let Some(existing) = &result { + let existing_len = existing.close_range.end - existing.open_range.start; + if len > existing_len { + continue; + } + } + + result = Some(pair); + } + + result + }; + let Some(pair) = pair else { + return false; + }; + pair.newline_only + && buffer + .chars_for_range(pair.open_range.end..range.start.0) + .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) + .all(|c| c.is_whitespace() && c != '\n') + } +} + +fn comment_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> Option> { + let delimiters = language.line_comment_prefixes(); + let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?; + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + let comment_candidate = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_len_of_delimiter + 2) + .collect::(); + let (delimiter, trimmed_len, is_repl) = delimiters + .iter() + .filter_map(|delimiter| { + let prefix = delimiter.trim_end(); + if comment_candidate.starts_with(prefix) { + let is_repl = if let Some(stripped_comment) = comment_candidate.strip_prefix(prefix) + { + stripped_comment.starts_with(" %%") + } else { + false + }; + Some((delimiter, prefix.len(), is_repl)) + } else { + None + } + }) + .max_by_key(|(_, len, _)| *len)?; + + if let Some(BlockCommentConfig { + start: block_start, .. + }) = language.block_comment() + { + let block_start_trimmed = block_start.trim_end(); + if block_start_trimmed.starts_with(delimiter.trim_end()) { + let line_content = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(block_start_trimmed.len()) + .collect::(); + + if line_content.starts_with(block_start_trimmed) { + return None; + } + } + } + + let cursor_is_placed_after_comment_marker = + num_of_whitespaces + trimmed_len <= start_point.column as usize; + if cursor_is_placed_after_comment_marker { + if !is_repl { + return Some(delimiter.clone()); + } + + let line_content_after_cursor: String = snapshot + .chars_for_range(range) + .skip(start_point.column as usize) + .collect(); + + if line_content_after_cursor.trim().is_empty() { + return None; + } else { + return Some(delimiter.clone()); + } + } else { + None + } +} + +fn documentation_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let BlockCommentConfig { + start: start_tag, + end: end_tag, + prefix: delimiter, + tab_size: len, + } = language.documentation_comment()?; + let is_within_block_comment = buffer + .language_scope_at(*start_point) + .is_some_and(|scope| scope.override_name() == Some("comment")); + if !is_within_block_comment { + return None; + } + + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. + let column = start_point.column; + let cursor_is_after_start_tag = { + let start_tag_len = start_tag.len(); + let start_tag_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(start_tag_len) + .collect::(); + if start_tag_line.starts_with(start_tag.as_ref()) { + num_of_whitespaces + start_tag_len <= column as usize + } else { + false + } + }; + + let cursor_is_after_delimiter = { + let delimiter_trim = delimiter.trim_end(); + let delimiter_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(delimiter_trim.len()) + .collect::(); + if delimiter_line.starts_with(delimiter_trim) { + num_of_whitespaces + delimiter_trim.len() <= column as usize + } else { + false + } + }; + + let mut needs_extra_line = false; + let mut extra_line_additional_indent = IndentSize::spaces(0); + + let cursor_is_before_end_tag_if_exists = { + let mut char_position = 0u32; + let mut end_tag_offset = None; + + 'outer: for chunk in snapshot.text_for_range(range) { + if let Some(byte_pos) = chunk.find(&**end_tag) { + let chars_before_match = chunk[..byte_pos].chars().count() as u32; + end_tag_offset = Some(char_position + chars_before_match); + break 'outer; + } + char_position += chunk.chars().count() as u32; + } + + if let Some(end_tag_offset) = end_tag_offset { + let cursor_is_before_end_tag = column <= end_tag_offset; + if cursor_is_after_start_tag { + if cursor_is_before_end_tag { + needs_extra_line = true; + } + let cursor_is_at_start_of_end_tag = column == end_tag_offset; + if cursor_is_at_start_of_end_tag { + extra_line_additional_indent.len = *len; + } + } + cursor_is_before_end_tag + } else { + true + } + }; + + if (cursor_is_after_start_tag || cursor_is_after_delimiter) + && cursor_is_before_end_tag_if_exists + { + let additional_indent = if cursor_is_after_start_tag { + IndentSize::spaces(*len) + } else { + IndentSize::spaces(0) + }; + + *newline_config = NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent: if needs_extra_line { + Some(extra_line_additional_indent) + } else { + None + }, + prevent_auto_indent: true, + }; + Some(delimiter.clone()) + } else { + None + } +} + +fn list_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_entries: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|prefix| (prefix.as_ref(), config.continuation.as_ref())) + }) + .collect(); + let unordered_list_entries: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| (marker.as_ref(), marker.as_ref())) + .collect(); + + let all_entries: Vec<_> = task_list_entries + .into_iter() + .chain(unordered_list_entries) + .collect(); + + if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + + if let Some((prefix, continuation)) = all_entries + .iter() + .filter(|(prefix, _)| candidate.starts_with(*prefix)) + .max_by_key(|(prefix, _)| prefix.len()) + { + let end_of_prefix = num_of_whitespaces + prefix.len(); + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + return Some((*continuation).into()); + } + + if start_point.column as usize == end_of_prefix { + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: (*continuation).into(), + }; + } + } + + return None; + } + } + + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + + if let Some(captures) = regex.captures(&candidate) { + let full_match = captures.get(0)?; + let marker_len = full_match.len(); + let end_of_prefix = num_of_whitespaces + marker_len; + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + let number: u32 = captures.get(1)?.as_str().parse().ok()?; + let continuation = ordered_config + .format + .replace("{1}", &(number + 1).to_string()); + return Some(continuation.into()); + } + + if start_point.column as usize == end_of_prefix { + let continuation = ordered_config.format.replace("{1}", "1"); + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: continuation.into(), + }; + } + } + + return None; + } + } + + None +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + adjusted_range: &mut Option>, + _: &mut Window, + cx: &mut Context, + ) -> Option { + let snapshot = self.buffer.read(cx).read(cx); + let start = snapshot.clip_offset_utf16( + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)), + Bias::Left, + ); + let end = snapshot.clip_offset_utf16( + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)), + Bias::Right, + ); + if (start.0.0..end.0.0) != range_utf16 { + adjusted_range.replace(start.0.0..end.0.0); + } + Some(snapshot.text_for_range(start..end).collect()) + } + + fn selected_text_range( + &mut self, + ignore_disabled_input: bool, + _: &mut Window, + cx: &mut Context, + ) -> Option { + // Prevent the IME menu from appearing when holding down an alphabetic key + // while input is disabled. + if !ignore_disabled_input && !self.input_enabled { + return None; + } + + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); + let range = selection.range(); + + Some(UTF16Selection { + range: range.start.0.0..range.end.0.0, + reversed: selection.reversed, + }) + } + + fn marked_text_range(&self, _: &mut Window, cx: &mut Context) -> Option> { + let snapshot = self.buffer.read(cx).read(cx); + let range = self + .text_highlights(HighlightKey::InputComposition, cx)? + .1 + .first()?; + Some(range.start.to_offset_utf16(&snapshot).0.0..range.end.to_offset_utf16(&snapshot).0.0) + } + + fn unmark_text(&mut self, _: &mut Window, cx: &mut Context) { + self.clear_highlights(HighlightKey::InputComposition, cx); + self.ime_transaction.take(); + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + cx.emit(EditorEvent::InputIgnored { text: text.into() }); + return; + } + + self.transact(window, cx, |this, window, cx| { + let new_selected_ranges = if let Some(range_utf16) = range_utf16 { + if let Some(marked_ranges) = this.marked_text_ranges(cx) { + // During IME composition, macOS reports the replacement range + // relative to the first marked region (the only one visible via + // marked_text_range). The correct targets for replacement are the + // marked ranges themselves — one per cursor — so use them directly. + Some(marked_ranges) + } else if range_utf16.start == range_utf16.end { + // An empty replacement range means "insert at cursor" with no text + // to replace. macOS reports the cursor position from its own + // (single-cursor) view of the buffer, which diverges from our actual + // cursor positions after multi-cursor edits have shifted offsets. + // Treating this as range_utf16=None lets each cursor insert in place. + None + } else { + // Outside of IME composition (e.g. Accessibility Keyboard word + // completion), the range is an absolute document offset for the + // newest cursor. Fan it out to all cursors via + // selection_replacement_ranges, which applies the delta relative + // to the newest selection to every cursor. + let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) + ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } + } else { + this.marked_text_ranges(cx) + }; + + let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(&this.display_snapshot(cx)) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0.0 as isize - selection.head().0.0 as isize) + ..(range.end.0.0 as isize - selection.head().0.0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(new_selected_ranges) = new_selected_ranges { + // Only backspace if at least one range covers actual text. When all + // ranges are empty (e.g. a trailing-space insertion from Accessibility + // Keyboard sends replacementRange=cursor..cursor), backspace would + // incorrectly delete the character just before the cursor. + let should_backspace = new_selected_ranges.iter().any(|r| r.start != r.end); + this.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); + if should_backspace { + this.backspace(&Default::default(), window, cx); + } + } + + this.handle_input(text, window, cx); + }); + + if let Some(transaction) = self.ime_transaction { + self.buffer.update(cx, |buffer, cx| { + buffer.group_until_transaction(transaction, cx); + }); + } + + self.unmark_text(window, cx); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + if !self.input_enabled { + return; + } + + let transaction = self.transact(window, cx, |this, window, cx| { + let ranges_to_replace = if let Some(mut marked_ranges) = this.marked_text_ranges(cx) { + let snapshot = this.buffer.read(cx).read(cx); + if let Some(relative_range_utf16) = range_utf16.as_ref() { + for marked_range in &mut marked_ranges { + marked_range.end = marked_range.start + relative_range_utf16.end; + marked_range.start += relative_range_utf16.start; + marked_range.start = + snapshot.clip_offset_utf16(marked_range.start, Bias::Left); + marked_range.end = + snapshot.clip_offset_utf16(marked_range.end, Bias::Right); + } + } + Some(marked_ranges) + } else if let Some(range_utf16) = range_utf16 { + let range_utf16 = MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)) + ..MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.end)); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } else { + None + }; + + let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(&this.display_snapshot(cx)) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0.0 as isize - selection.head().0.0 as isize) + ..(range.end.0.0 as isize - selection.head().0.0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(ranges) = ranges_to_replace { + this.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(ranges) + }); + } + + let marked_ranges = { + let snapshot = this.buffer.read(cx).read(cx); + this.selections + .disjoint_anchors_arc() + .iter() + .map(|selection| { + selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot) + }) + .collect::>() + }; + + if text.is_empty() { + this.unmark_text(window, cx); + } else { + this.highlight_text( + HighlightKey::InputComposition, + marked_ranges.clone(), + HighlightStyle { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: None, + wavy: false, + }), + ..Default::default() + }, + cx, + ); + } + + // Disable auto-closing when composing text (i.e. typing a `"` on a Brazilian keyboard) + let use_autoclose = this.use_autoclose; + let use_auto_surround = this.use_auto_surround; + this.set_use_autoclose(false); + this.set_use_auto_surround(false); + this.handle_input(text, window, cx); + this.set_use_autoclose(use_autoclose); + this.set_use_auto_surround(use_auto_surround); + + if let Some(new_selected_range) = new_selected_range_utf16 { + let snapshot = this.buffer.read(cx).read(cx); + let new_selected_ranges = marked_ranges + .into_iter() + .map(|marked_range| { + let insertion_start = marked_range.start.to_offset_utf16(&snapshot).0; + let new_start = MultiBufferOffsetUtf16(OffsetUtf16( + insertion_start.0 + new_selected_range.start, + )); + let new_end = MultiBufferOffsetUtf16(OffsetUtf16( + insertion_start.0 + new_selected_range.end, + )); + snapshot.clip_offset_utf16(new_start, Bias::Left) + ..snapshot.clip_offset_utf16(new_end, Bias::Right) + }) + .collect::>(); + + drop(snapshot); + this.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); + } + }); + + self.ime_transaction = self.ime_transaction.or(transaction); + if let Some(transaction) = self.ime_transaction { + self.buffer.update(cx, |buffer, cx| { + buffer.group_until_transaction(transaction, cx); + }); + } + + if self + .text_highlights(HighlightKey::InputComposition, cx) + .is_none() + { + self.ime_transaction.take(); + } + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + element_bounds: gpui::Bounds, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let text_layout_details = self.text_layout_details(window, cx); + let CharacterDimensions { + em_width, + em_advance, + line_height, + } = self.character_dimensions(window, cx); + + let snapshot = self.snapshot(window, cx); + let scroll_position = snapshot.scroll_position(); + let scroll_left = scroll_position.x * ScrollOffset::from(em_advance); + + let start = + MultiBufferOffsetUtf16(OffsetUtf16(range_utf16.start)).to_display_point(&snapshot); + let x = Pixels::from( + ScrollOffset::from( + snapshot.x_for_display_point(start, &text_layout_details) + + self.gutter_dimensions.full_width(), + ) - scroll_left, + ); + let y = line_height * (start.row().as_f64() - scroll_position.y) as f32; + + Some(Bounds { + origin: element_bounds.origin + point(x, y), + size: size(em_width, line_height), + }) + } + + fn character_index_for_point( + &mut self, + point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let position_map = self.last_position_map.as_ref()?; + if !position_map.text_hitbox.contains(&point) { + return None; + } + let display_point = position_map.point_for_position(point).previous_valid; + let anchor = position_map + .snapshot + .display_point_to_anchor(display_point, Bias::Left); + let utf16_offset = anchor.to_offset_utf16(&position_map.snapshot.buffer_snapshot()); + Some(utf16_offset.0.0) + } + + fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { + self.expects_character_input + } +} diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 125f09c9661..9434da98973 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -11,7 +11,7 @@ use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; use file_icons::FileIcons; use fs::MTime; -use futures::future::try_join_all; +use futures::{channel::oneshot, future::try_join_all}; use git::status::GitSummary; use gpui::{ AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font, @@ -22,22 +22,24 @@ use language::{ SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; -use multi_buffer::{MultiBufferOffset, PathKey}; +use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey}; use project::{ File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, }; +use rope::TextSummary; use rpc::proto::{self, update_view}; use settings::Settings; use std::{ any::{Any, TypeId}, borrow::Cow, cmp::{self, Ordering}, + num::NonZeroU32, ops::Range, path::{Path, PathBuf}, sync::Arc, }; -use text::{BufferId, BufferSnapshot, Selection}; +use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection}; use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; @@ -99,6 +101,10 @@ impl FollowableItem for Editor { .await .debug_assert_ok("leaders don't share views for unshared buffers")?; + let path_excerpts = + deserialize_path_excerpts_and_wait_for_anchors(state.path_excerpts, &buffers, cx) + .await?; + let editor = cx.update(|window, cx| { let multibuffer = cx.new(|cx| { let mut multibuffer; @@ -106,27 +112,13 @@ impl FollowableItem for Editor { multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx) } else { multibuffer = MultiBuffer::new(project.read(cx).capability()); - for path_with_ranges in state.path_excerpts { - let Some(path_key) = - path_with_ranges.path_key.and_then(deserialize_path_key) - else { - continue; - }; - let Some(buffer_id) = BufferId::new(path_with_ranges.buffer_id).ok() - else { - continue; - }; + for (path_key, buffer_id, ranges) in path_excerpts { let Some(buffer) = buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id) else { continue; }; let buffer_snapshot = buffer.read(cx).snapshot(); - let ranges = path_with_ranges - .ranges - .into_iter() - .filter_map(deserialize_excerpt_range) - .collect::>(); multibuffer.update_path_excerpts( path_key, buffer.clone(), @@ -400,25 +392,20 @@ async fn update_editor_from_message( .map(|id| BufferId::new(id).map(|id| project.open_buffer_by_id(id, cx))) .collect::>>() })?; - let _inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?; + let inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?; + + let updated_paths = deserialize_path_excerpts_and_wait_for_anchors( + message.updated_paths, + &inserted_excerpt_buffers, + cx, + ) + .await?; // Update the editor's excerpts. let buffer_snapshot = this.update(cx, |editor, cx| { editor.buffer.update(cx, |multibuffer, cx| { - for path_with_excerpts in message.updated_paths { - let Some(path_key) = path_with_excerpts.path_key.and_then(deserialize_path_key) - else { - continue; - }; - let ranges = path_with_excerpts - .ranges - .into_iter() - .filter_map(deserialize_excerpt_range) - .collect::>(); - let Some(buffer) = BufferId::new(path_with_excerpts.buffer_id) - .ok() - .and_then(|buffer_id| project.read(cx).buffer_for_id(buffer_id, cx)) - else { + for (path_key, buffer_id, ranges) in updated_paths { + let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else { continue; }; @@ -537,6 +524,56 @@ fn serialize_excerpt_range(range: ExcerptRange) -> proto::Exce } } +async fn deserialize_path_excerpts_and_wait_for_anchors( + path_excerpts: Vec, + buffers: &[Entity], + cx: &mut AsyncWindowContext, +) -> Result>)>> { + let path_excerpts = path_excerpts + .into_iter() + .filter_map(|path_with_ranges| { + let path_key = path_with_ranges.path_key.and_then(deserialize_path_key)?; + let buffer_id = BufferId::new(path_with_ranges.buffer_id).ok()?; + let ranges = path_with_ranges + .ranges + .into_iter() + .filter_map(deserialize_excerpt_range) + .collect::>(); + Some((path_key, buffer_id, ranges)) + }) + .collect::>(); + + let wait_for_anchors = cx.update(|_, cx| { + buffers + .iter() + .map(|buffer| { + let buffer_id = buffer.read(cx).remote_id(); + let anchors = path_excerpts + .iter() + .filter(|(_, id, _)| *id == buffer_id) + .flat_map(|(_, _, ranges)| { + ranges.iter().flat_map(|range| { + [ + range.context.start, + range.context.end, + range.primary.start, + range.primary.end, + ] + }) + }) + .collect::>(); + buffer.update(cx, |buffer, _| buffer.wait_for_anchors(anchors)) + }) + .collect::>() + })?; + // Without this wait, resolving these anchors later can race ahead of the + // leader's pending buffer ops and trip `panic_bad_anchor` on a stale + // snapshot. + try_join_all(wait_for_anchors).await?; + + Ok(path_excerpts) +} + fn deserialize_excerpt_range( excerpt_range: proto::ExcerptRange, ) -> Option> { @@ -1871,6 +1908,7 @@ impl SearchableItem for Editor { ranges.iter().cloned().collect::>() }); + let executor = cx.background_executor().clone(); cx.background_spawn(async move { let mut ranges = Vec::new(); @@ -1879,38 +1917,70 @@ impl SearchableItem for Editor { } else { search_within_ranges }; - + let num_cpus = executor.num_cpus(); for range in search_within_ranges { for (search_buffer, search_range, deleted_hunk_anchor) in buffer.range_to_buffer_ranges_with_deleted_hunks(range) { - ranges.extend( - query - .search( - search_buffer, - Some(search_range.start.0..search_range.end.0), - ) - .await - .into_iter() - .filter_map(|match_range| { - if let Some(deleted_hunk_anchor) = deleted_hunk_anchor { - let start = search_buffer - .anchor_after(search_range.start + match_range.start); - let end = search_buffer - .anchor_before(search_range.start + match_range.end); - Some( - deleted_hunk_anchor.with_diff_base_anchor(start) - ..deleted_hunk_anchor.with_diff_base_anchor(end), - ) - } else { - let start = search_buffer - .anchor_after(search_range.start + match_range.start); - let end = search_buffer - .anchor_before(search_range.start + match_range.end); - buffer.buffer_anchor_range_to_anchor_range(start..end) - } - }), - ); + let query = query.clone(); + + let mut results = Vec::new(); + executor + .scoped(|scope| { + for search_range in chunk_search_range( + search_buffer.text.clone(), + &query, + num_cpus as u32, + search_range, + ) { + let query = query.clone(); + let buffer = buffer.clone(); + + let (tx, rx) = oneshot::channel(); + results.push(rx); + scope.spawn(async move { + let chunk_result = query + .search( + search_buffer, + Some(search_range.start..search_range.end), + ) + .await + .into_iter() + .filter_map(|match_range| { + if let Some(deleted_hunk_anchor) = deleted_hunk_anchor { + let start = search_buffer.anchor_after( + search_range.start + match_range.start, + ); + let end = search_buffer.anchor_before( + search_range.start + match_range.end, + ); + Some( + deleted_hunk_anchor.with_diff_base_anchor(start) + ..deleted_hunk_anchor + .with_diff_base_anchor(end), + ) + } else { + let start = search_buffer.anchor_after( + search_range.start + match_range.start, + ); + let end = search_buffer.anchor_before( + search_range.start + match_range.end, + ); + buffer.anchor_range_in_buffer(start..end) + } + }) + .collect::>(); + _ = tx.send(chunk_result); + }); + } + }) + .await; + + for rx in results { + if let Ok(results) = rx.await { + ranges.extend(results); + } + } } } @@ -2109,6 +2179,48 @@ fn deserialize_path_key(path_key: proto::PathKey) -> Option { }) } +fn chunk_search_range( + buffer: BufferSnapshot, + query: &SearchQuery, + num_cpus: u32, + initial_range: Range, +) -> Box> + 'static> { + let range = initial_range.to_offset(&buffer); + if range.is_empty() { + return Box::new(std::iter::empty()); + } + + let summary: TextSummary = buffer.text_summary_for_range(initial_range); + let num_chunks = if !query.is_regex() && !query.as_str().contains('\n') { + NonZeroU32::new(summary.lines.row.saturating_add(1).min(num_cpus.max(1))) + } else { + NonZeroU32::new(1) + }; + + let Some(num_chunks) = num_chunks else { + return Box::new(std::iter::empty()); + }; + + let mut chunk_start = range.start; + let rope = buffer.as_rope().clone(); + let range_end = range.end; + let average_chunk_length = summary.len.div_ceil(num_chunks.get() as usize); + Box::new(std::iter::from_fn(move || { + if chunk_start >= range_end { + return None; + } + let candidate_position = chunk_start + average_chunk_length; + let adjusted = rope.ceil_char_boundary(candidate_position); + let mut as_point = rope.offset_to_point(adjusted); + as_point.row += 1; + as_point.column = 0; + let end_offset = buffer.point_to_offset(as_point).min(range_end); + let ret = chunk_start..end_offset; + chunk_start = end_offset; + Some(ret) + })) +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; @@ -2134,6 +2246,115 @@ mod tests { assert_eq!(path_for_file(&file, 0, false, cx), None); } + #[gpui::test] + fn test_chunk_search_range_multi_line(cx: &mut App) { + let text = "line one\nline two\nline three\nline four\nline five\nline six\n"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "line", 4, 0..text.len()); + + assert_chunks_are_contiguous(&chunks, 0..text.len()); + assert!( + chunks.len() <= 4, + "got {} chunks, expected <= num_cpus (4)", + chunks.len() + ); + for chunk in &chunks { + let end = chunk.end; + assert!( + end == text.len() || text.as_bytes()[end - 1] == b'\n', + "chunk ending at {end} is not a line boundary", + ); + } + } + + #[gpui::test] + fn test_chunk_search_range_single_line(cx: &mut App) { + let text = "hello world hello again"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 0..text.len()); + assert_chunks_are_contiguous(&chunks, 0..text.len()); + } + + #[gpui::test] + fn test_chunk_search_range_empty_range(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("hello world", cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 5..5); + assert!(chunks.is_empty()); + } + + #[gpui::test] + fn test_chunk_search_range_does_not_start_at_zero(cx: &mut App) { + let line = "abcdefghij\n"; + let text = line.repeat(20); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx)); + let snapshot = buffer.read(cx).snapshot(); + + let start = line.len() * 7; + let end = line.len() * 14; + let chunks = chunk_search_range_for_test(&snapshot, "abc", 4, start..end); + + assert_chunks_are_contiguous(&chunks, start..end); + } + + fn chunk_search_range_for_test( + snapshot: &language::BufferSnapshot, + query: &str, + num_cpus: u32, + range: Range, + ) -> Vec> { + let query = SearchQuery::text( + query, + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap(); + chunk_search_range( + snapshot.text.clone(), + &query, + num_cpus, + BufferOffset(range.start)..BufferOffset(range.end), + ) + .collect() + } + + #[track_caller] + fn assert_chunks_are_contiguous(chunks: &[Range], expected: Range) { + assert!(!chunks.is_empty(), "expected at least one chunk"); + assert_eq!( + chunks.first().unwrap().start, + expected.start, + "first chunk does not start at {}", + expected.start + ); + assert_eq!( + chunks.last().unwrap().end, + expected.end, + "last chunk does not end at {}", + expected.end + ); + for chunk in chunks { + assert!(chunk.start < chunk.end, "empty chunk: {:?}", chunk); + } + for window in chunks.windows(2) { + assert_eq!( + window[0].end, window[1].start, + "gap or overlap between chunks {:?} and {:?}", + window[0], window[1], + ); + } + } + async fn deserialize_editor( item_id: ItemId, workspace_id: WorkspaceId, diff --git a/crates/editor/src/rewrap.rs b/crates/editor/src/rewrap.rs new file mode 100644 index 00000000000..50647729d32 --- /dev/null +++ b/crates/editor/src/rewrap.rs @@ -0,0 +1,782 @@ +use super::*; + +impl Editor { + pub fn rewrap(&mut self, options: RewrapOptions, cx: &mut Context) { + if self.read_only(cx) || self.mode.is_single_line() { + return; + } + let buffer = self.buffer.read(cx).snapshot(cx); + let selections = self.selections.all::(&self.display_snapshot(cx)); + + #[derive(Clone, Debug, PartialEq)] + enum CommentFormat { + /// single line comment, with prefix for line + Line(String), + /// single line within a block comment, with prefix for line + BlockLine(String), + /// a single line of a block comment that includes the initial delimiter + BlockCommentWithStart(BlockCommentConfig), + /// a single line of a block comment that includes the ending delimiter + BlockCommentWithEnd(BlockCommentConfig), + } + + // Split selections to respect paragraph, indent, and comment prefix boundaries. + let wrap_ranges = selections.into_iter().flat_map(|selection| { + let language_settings = buffer.language_settings_at(selection.head(), cx); + let language_scope = buffer.language_scope_at(selection.head()); + + let indent_and_prefix_for_row = + |row: u32| -> (IndentSize, Option, Option) { + let indent = buffer.indent_size_for_line(MultiBufferRow(row)); + let (comment_prefix, rewrap_prefix) = if let Some(language_scope) = + &language_scope + { + let indent_end = Point::new(row, indent.len); + let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + let line_text_after_indent = buffer + .text_for_range(indent_end..line_end) + .collect::(); + + let is_within_comment_override = buffer + .language_scope_at(indent_end) + .is_some_and(|scope| scope.override_name() == Some("comment")); + let comment_delimiters = if is_within_comment_override { + // we are within a comment syntax node, but we don't + // yet know what kind of comment: block, doc or line + match ( + language_scope.documentation_comment(), + language_scope.block_comment(), + ) { + (Some(config), _) | (_, Some(config)) + if buffer.contains_str_at(indent_end, &config.start) => + { + Some(CommentFormat::BlockCommentWithStart(config.clone())) + } + (Some(config), _) | (_, Some(config)) + if line_text_after_indent.ends_with(config.end.as_ref()) => + { + Some(CommentFormat::BlockCommentWithEnd(config.clone())) + } + (Some(config), _) | (_, Some(config)) + if !config.prefix.is_empty() + && buffer.contains_str_at(indent_end, &config.prefix) => + { + Some(CommentFormat::BlockLine(config.prefix.to_string())) + } + (_, _) => language_scope + .line_comment_prefixes() + .iter() + .find(|prefix| buffer.contains_str_at(indent_end, prefix)) + .map(|prefix| CommentFormat::Line(prefix.to_string())), + } + } else { + // we not in an overridden comment node, but we may + // be within a non-overridden line comment node + language_scope + .line_comment_prefixes() + .iter() + .find(|prefix| buffer.contains_str_at(indent_end, prefix)) + .map(|prefix| CommentFormat::Line(prefix.to_string())) + }; + + let rewrap_prefix = language_scope + .rewrap_prefixes() + .iter() + .find_map(|prefix_regex| { + prefix_regex.find(&line_text_after_indent).map(|mat| { + if mat.start() == 0 { + Some(mat.as_str().to_string()) + } else { + None + } + }) + }) + .flatten(); + (comment_delimiters, rewrap_prefix) + } else { + (None, None) + }; + (indent, comment_prefix, rewrap_prefix) + }; + + let mut start_row = selection.start.row; + let mut end_row = selection.end.row; + + if selection.is_empty() { + let cursor_row = selection.start.row; + + let (mut indent_size, comment_prefix, _) = indent_and_prefix_for_row(cursor_row); + let line_prefix = match &comment_prefix { + Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => { + Some(prefix.as_str()) + } + Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { + prefix, .. + })) => Some(prefix.as_ref()), + Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig { + start: _, + end: _, + prefix, + tab_size, + })) => { + indent_size.len += tab_size; + Some(prefix.as_ref()) + } + None => None, + }; + let indent_prefix = indent_size.chars().collect::(); + let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or("")); + + 'expand_upwards: while start_row > 0 { + let prev_row = start_row - 1; + if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len() + && !buffer.is_line_blank(MultiBufferRow(prev_row)) + { + start_row = prev_row; + } else { + break 'expand_upwards; + } + } + + 'expand_downwards: while end_row < buffer.max_point().row { + let next_row = end_row + 1; + if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix) + && buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len() + && !buffer.is_line_blank(MultiBufferRow(next_row)) + { + end_row = next_row; + } else { + break 'expand_downwards; + } + } + } + + let mut non_blank_rows_iter = (start_row..=end_row) + .filter(|row| !buffer.is_line_blank(MultiBufferRow(*row))) + .peekable(); + + let first_row = if let Some(&row) = non_blank_rows_iter.peek() { + row + } else { + return Vec::new(); + }; + + let mut ranges = Vec::new(); + + let mut current_range_start = first_row; + let mut prev_row = first_row; + let ( + mut current_range_indent, + mut current_range_comment_delimiters, + mut current_range_rewrap_prefix, + ) = indent_and_prefix_for_row(first_row); + + for row in non_blank_rows_iter.skip(1) { + let has_paragraph_break = row > prev_row + 1; + + let (row_indent, row_comment_delimiters, row_rewrap_prefix) = + indent_and_prefix_for_row(row); + + let has_indent_change = row_indent != current_range_indent; + let has_comment_change = row_comment_delimiters != current_range_comment_delimiters; + + let has_boundary_change = has_comment_change + || row_rewrap_prefix.is_some() + || (has_indent_change && current_range_comment_delimiters.is_some()); + + if has_paragraph_break || has_boundary_change { + ranges.push(( + language_settings.clone(), + Point::new(current_range_start, 0) + ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), + current_range_indent, + current_range_comment_delimiters.clone(), + current_range_rewrap_prefix.clone(), + )); + current_range_start = row; + current_range_indent = row_indent; + current_range_comment_delimiters = row_comment_delimiters; + current_range_rewrap_prefix = row_rewrap_prefix; + } + prev_row = row; + } + + ranges.push(( + language_settings.clone(), + Point::new(current_range_start, 0) + ..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))), + current_range_indent, + current_range_comment_delimiters, + current_range_rewrap_prefix, + )); + + ranges + }); + + let mut edits = Vec::new(); + let mut rewrapped_row_ranges = Vec::>::new(); + + for (language_settings, wrap_range, mut indent_size, comment_prefix, rewrap_prefix) in + wrap_ranges + { + let start_row = wrap_range.start.row; + let end_row = wrap_range.end.row; + + // Skip selections that overlap with a range that has already been rewrapped. + let selection_range = start_row..end_row; + if rewrapped_row_ranges + .iter() + .any(|range| range.overlaps(&selection_range)) + { + continue; + } + + let tab_size = language_settings.tab_size; + + let (line_prefix, inside_comment) = match &comment_prefix { + Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => { + (Some(prefix.as_str()), true) + } + Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => { + (Some(prefix.as_ref()), true) + } + Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig { + start: _, + end: _, + prefix, + tab_size, + })) => { + indent_size.len += tab_size; + (Some(prefix.as_ref()), true) + } + None => (None, false), + }; + let indent_prefix = indent_size.chars().collect::(); + let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or("")); + + let allow_rewrap_based_on_language = match language_settings.allow_rewrap { + RewrapBehavior::InComments => inside_comment, + RewrapBehavior::InSelections => !wrap_range.is_empty(), + RewrapBehavior::Anywhere => true, + }; + + let should_rewrap = options.override_language_settings + || allow_rewrap_based_on_language + || self.hard_wrap.is_some(); + if !should_rewrap { + continue; + } + + let start = Point::new(start_row, 0); + let start_offset = ToOffset::to_offset(&start, &buffer); + let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row))); + let selection_text = buffer.text_for_range(start..end).collect::(); + let mut first_line_delimiter = None; + let mut last_line_delimiter = None; + let Some(lines_without_prefixes) = selection_text + .lines() + .enumerate() + .map(|(ix, line)| { + let line_trimmed = line.trim_start(); + if rewrap_prefix.is_some() && ix > 0 { + Ok(line_trimmed) + } else if let Some( + CommentFormat::BlockCommentWithStart(BlockCommentConfig { + start, + prefix, + end, + tab_size, + }) + | CommentFormat::BlockCommentWithEnd(BlockCommentConfig { + start, + prefix, + end, + tab_size, + }), + ) = &comment_prefix + { + let line_trimmed = line_trimmed + .strip_prefix(start.as_ref()) + .map(|s| { + let mut indent_size = indent_size; + indent_size.len -= tab_size; + let indent_prefix: String = indent_size.chars().collect(); + first_line_delimiter = Some((indent_prefix, start)); + s.trim_start() + }) + .unwrap_or(line_trimmed); + let line_trimmed = line_trimmed + .strip_suffix(end.as_ref()) + .map(|s| { + last_line_delimiter = Some(end); + s.trim_end() + }) + .unwrap_or(line_trimmed); + let line_trimmed = line_trimmed + .strip_prefix(prefix.as_ref()) + .unwrap_or(line_trimmed); + Ok(line_trimmed) + } else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix { + line_trimmed.strip_prefix(prefix).with_context(|| { + format!("line did not start with prefix {prefix:?}: {line:?}") + }) + } else { + line_trimmed + .strip_prefix(&line_prefix.trim_start()) + .with_context(|| { + format!("line did not start with prefix {line_prefix:?}: {line:?}") + }) + } + }) + .collect::, _>>() + .log_err() + else { + continue; + }; + + let wrap_column = options.line_length.or(self.hard_wrap).unwrap_or_else(|| { + buffer + .language_settings_at(Point::new(start_row, 0), cx) + .preferred_line_length as usize + }); + + let subsequent_lines_prefix = if let Some(rewrap_prefix_str) = &rewrap_prefix { + format!("{}{}", indent_prefix, " ".repeat(rewrap_prefix_str.len())) + } else { + line_prefix.clone() + }; + + let wrapped_text = { + let mut wrapped_text = wrap_with_prefix( + line_prefix, + subsequent_lines_prefix, + lines_without_prefixes.join("\n"), + wrap_column, + tab_size, + options.preserve_existing_whitespace, + ); + + if let Some((indent, delimiter)) = first_line_delimiter { + wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}"); + } + if let Some(last_line) = last_line_delimiter { + wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}"); + } + + wrapped_text + }; + + // TODO: should always use char-based diff while still supporting cursor behavior that + // matches vim. + let mut diff_options = DiffOptions::default(); + if options.override_language_settings { + diff_options.max_word_diff_len = 0; + diff_options.max_word_diff_line_count = 0; + } else { + diff_options.max_word_diff_len = usize::MAX; + diff_options.max_word_diff_line_count = usize::MAX; + } + + for (old_range, new_text) in + text_diff_with_options(&selection_text, &wrapped_text, diff_options) + { + let edit_start = buffer.anchor_after(start_offset + old_range.start); + let edit_end = buffer.anchor_after(start_offset + old_range.end); + edits.push((edit_start..edit_end, new_text)); + } + + rewrapped_row_ranges.push(start_row..=end_row); + } + + self.buffer + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)); + } +} + +fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { + let tab_size = tab_size.get() as usize; + let mut width = offset; + + for ch in text.chars() { + width += if ch == '\t' { + tab_size - (width % tab_size) + } else { + 1 + }; + } + + width - offset +} + +/// Tokenizes a string into runs of text that should stick together, or that is whitespace. +struct WordBreakingTokenizer<'a> { + input: &'a str, +} + +impl<'a> WordBreakingTokenizer<'a> { + fn new(input: &'a str) -> Self { + Self { input } + } +} + +fn is_char_ideographic(ch: char) -> bool { + use unicode_script::Script::*; + use unicode_script::UnicodeScript; + matches!(ch.script(), Han | Tangut | Yi) +} + +fn is_grapheme_ideographic(text: &str) -> bool { + text.chars().any(is_char_ideographic) +} + +fn is_grapheme_whitespace(text: &str) -> bool { + text.chars().any(|x| x.is_whitespace()) +} + +fn should_stay_with_preceding_ideograph(text: &str) -> bool { + text.chars() + .next() + .is_some_and(|ch| matches!(ch, '。' | '、' | ',' | '?' | '!' | ':' | ';' | '…')) +} + +#[derive(PartialEq, Eq, Debug, Clone, Copy)] +enum WordBreakToken<'a> { + Word { token: &'a str, grapheme_len: usize }, + InlineWhitespace { token: &'a str, grapheme_len: usize }, + Newline, +} + +impl<'a> Iterator for WordBreakingTokenizer<'a> { + /// Yields a span, the count of graphemes in the token, and whether it was + /// whitespace. Note that it also breaks at word boundaries. + type Item = WordBreakToken<'a>; + + fn next(&mut self) -> Option { + use unicode_segmentation::UnicodeSegmentation; + if self.input.is_empty() { + return None; + } + + let mut iter = self.input.graphemes(true).peekable(); + let mut offset = 0; + let mut grapheme_len = 0; + if let Some(first_grapheme) = iter.next() { + let is_newline = first_grapheme == "\n"; + let is_whitespace = is_grapheme_whitespace(first_grapheme); + offset += first_grapheme.len(); + grapheme_len += 1; + if is_grapheme_ideographic(first_grapheme) && !is_whitespace { + if let Some(grapheme) = iter.peek().copied() + && should_stay_with_preceding_ideograph(grapheme) + { + offset += grapheme.len(); + grapheme_len += 1; + } + } else { + let mut words = self.input[offset..].split_word_bound_indices().peekable(); + let mut next_word_bound = words.peek().copied(); + if next_word_bound.is_some_and(|(i, _)| i == 0) { + next_word_bound = words.next(); + } + while let Some(grapheme) = iter.peek().copied() { + if next_word_bound.is_some_and(|(i, _)| i == offset) { + break; + }; + if is_grapheme_whitespace(grapheme) != is_whitespace + || (grapheme == "\n") != is_newline + { + break; + }; + offset += grapheme.len(); + grapheme_len += 1; + iter.next(); + } + } + let token = &self.input[..offset]; + self.input = &self.input[offset..]; + if token == "\n" { + Some(WordBreakToken::Newline) + } else if is_whitespace { + Some(WordBreakToken::InlineWhitespace { + token, + grapheme_len, + }) + } else { + Some(WordBreakToken::Word { + token, + grapheme_len, + }) + } + } else { + None + } + } +} + +fn wrap_with_prefix( + first_line_prefix: String, + subsequent_lines_prefix: String, + unwrapped_text: String, + wrap_column: usize, + tab_size: NonZeroU32, + preserve_existing_whitespace: bool, +) -> String { + let first_line_prefix_len = char_len_with_expanded_tabs(0, &first_line_prefix, tab_size); + let subsequent_lines_prefix_len = + char_len_with_expanded_tabs(0, &subsequent_lines_prefix, tab_size); + let mut wrapped_text = String::new(); + let mut current_line = first_line_prefix; + let mut is_first_line = true; + + let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); + let mut current_line_len = first_line_prefix_len; + let mut in_whitespace = false; + for token in tokenizer { + let have_preceding_whitespace = in_whitespace; + match token { + WordBreakToken::Word { + token, + grapheme_len, + } => { + in_whitespace = false; + let current_prefix_len = if is_first_line { + first_line_prefix_len + } else { + subsequent_lines_prefix_len + }; + if current_line_len + grapheme_len > wrap_column + && current_line_len != current_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + is_first_line = false; + current_line = subsequent_lines_prefix.clone(); + current_line_len = subsequent_lines_prefix_len; + } + current_line.push_str(token); + current_line_len += grapheme_len; + } + WordBreakToken::InlineWhitespace { + mut token, + mut grapheme_len, + } => { + in_whitespace = true; + if have_preceding_whitespace && !preserve_existing_whitespace { + continue; + } + if !preserve_existing_whitespace { + // Keep a single whitespace grapheme as-is + if let Some(first) = + unicode_segmentation::UnicodeSegmentation::graphemes(token, true).next() + { + token = first; + } else { + token = " "; + } + grapheme_len = 1; + } + let current_prefix_len = if is_first_line { + first_line_prefix_len + } else { + subsequent_lines_prefix_len + }; + if current_line_len + grapheme_len > wrap_column { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + is_first_line = false; + current_line = subsequent_lines_prefix.clone(); + current_line_len = subsequent_lines_prefix_len; + } else if current_line_len != current_prefix_len || preserve_existing_whitespace { + current_line.push_str(token); + current_line_len += grapheme_len; + } + } + WordBreakToken::Newline => { + in_whitespace = true; + let current_prefix_len = if is_first_line { + first_line_prefix_len + } else { + subsequent_lines_prefix_len + }; + if preserve_existing_whitespace { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + is_first_line = false; + current_line = subsequent_lines_prefix.clone(); + current_line_len = subsequent_lines_prefix_len; + } else if have_preceding_whitespace { + continue; + } else if current_line_len + 1 > wrap_column + && current_line_len != current_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + is_first_line = false; + current_line = subsequent_lines_prefix.clone(); + current_line_len = subsequent_lines_prefix_len; + } else if current_line_len != current_prefix_len { + current_line.push(' '); + current_line_len += 1; + } + } + } + } + + if !current_line.is_empty() { + wrapped_text.push_str(¤t_line); + } + wrapped_text +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_size_with_expanded_tabs() { + let nz = |val| NonZeroU32::new(val).unwrap(); + assert_eq!(char_len_with_expanded_tabs(0, "", nz(4)), 0); + assert_eq!(char_len_with_expanded_tabs(0, "hello", nz(4)), 5); + assert_eq!(char_len_with_expanded_tabs(0, "\thello", nz(4)), 9); + assert_eq!(char_len_with_expanded_tabs(0, "abc\tab", nz(4)), 6); + assert_eq!(char_len_with_expanded_tabs(0, "hello\t", nz(4)), 8); + assert_eq!(char_len_with_expanded_tabs(0, "\t\t", nz(8)), 16); + assert_eq!(char_len_with_expanded_tabs(0, "x\t", nz(8)), 8); + assert_eq!(char_len_with_expanded_tabs(7, "x\t", nz(8)), 9); + } + + #[test] + fn test_word_breaking_tokenizer() { + let tests: &[(&str, &[WordBreakToken<'static>])] = &[ + ("", &[]), + (" ", &[whitespace(" ", 2)]), + ("Ʒ", &[word("Ʒ", 1)]), + ("Ǽ", &[word("Ǽ", 1)]), + ("⋑", &[word("⋑", 1)]), + ("⋑⋑", &[word("⋑⋑", 2)]), + ( + "原理,进而", + &[word("原", 1), word("理,", 2), word("进", 1), word("而", 1)], + ), + ( + "hello world", + &[word("hello", 5), whitespace(" ", 1), word("world", 5)], + ), + ( + "hello, world", + &[word("hello,", 6), whitespace(" ", 1), word("world", 5)], + ), + ( + " hello world", + &[ + whitespace(" ", 2), + word("hello", 5), + whitespace(" ", 1), + word("world", 5), + ], + ), + ( + "这是什么 \n 钢笔", + &[ + word("这", 1), + word("是", 1), + word("什", 1), + word("么", 1), + whitespace(" ", 1), + newline(), + whitespace(" ", 1), + word("钢", 1), + word("笔", 1), + ], + ), + (" mutton", &[whitespace(" ", 1), word("mutton", 6)]), + ]; + + fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::Word { + token, + grapheme_len, + } + } + + fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::InlineWhitespace { + token, + grapheme_len, + } + } + + fn newline() -> WordBreakToken<'static> { + WordBreakToken::Newline + } + + for (input, result) in tests { + assert_eq!( + WordBreakingTokenizer::new(input) + .collect::>() + .as_slice(), + *result, + ); + } + } + + #[test] + fn test_wrap_with_prefix() { + assert_eq!( + wrap_with_prefix( + "# ".to_string(), + "# ".to_string(), + "abcdefg".to_string(), + 4, + NonZeroU32::new(4).unwrap(), + false, + ), + "# abcdefg" + ); + assert_eq!( + wrap_with_prefix( + "".to_string(), + "".to_string(), + "\thello world".to_string(), + 8, + NonZeroU32::new(4).unwrap(), + false, + ), + "hello\nworld" + ); + assert_eq!( + wrap_with_prefix( + "// ".to_string(), + "// ".to_string(), + "xx \nyy zz aa bb cc".to_string(), + 12, + NonZeroU32::new(4).unwrap(), + false, + ), + "// xx yy zz\n// aa bb cc" + ); + assert_eq!( + wrap_with_prefix( + String::new(), + String::new(), + "这是什么 \n 钢笔".to_string(), + 3, + NonZeroU32::new(4).unwrap(), + false, + ), + "这是什\n么 钢\n笔" + ); + assert_eq!( + wrap_with_prefix( + String::new(), + String::new(), + format!("foo{}bar", '\u{2009}'), // thin space + 80, + NonZeroU32::new(4).unwrap(), + false, + ), + format!("foo{}bar", '\u{2009}') + ); + } +} diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs index 6d4d5999617..ab59586c3a8 100644 --- a/crates/editor/src/rust_analyzer_ext.rs +++ b/crates/editor/src/rust_analyzer_ext.rs @@ -1,7 +1,7 @@ use std::{fs, path::Path}; use anyhow::Context as _; -use gpui::{App, AppContext as _, Context, Entity, Window}; +use gpui::{App, AppContext as _, Context, Entity, TaskExt, Window}; use language::{Capability, Language, proto::serialize_anchor}; use multi_buffer::MultiBuffer; use project::{ diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs new file mode 100644 index 00000000000..9918383fb48 --- /dev/null +++ b/crates/editor/src/selection.rs @@ -0,0 +1,899 @@ +use super::*; + +impl Editor { + pub fn sync_selections( + &mut self, + other: Entity, + cx: &mut Context, + ) -> gpui::Subscription { + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if !other_selections.is_empty() { + self.selections + .change_with(&self.display_snapshot(cx), |selections| { + selections.select_anchors(other_selections); + }); + } + + let other_subscription = cx.subscribe(&other, |this, other, other_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = other_evt { + let other_selections = other.read(cx).selections.disjoint_anchors().to_vec(); + if other_selections.is_empty() { + return; + } + let snapshot = this.display_snapshot(cx); + this.selections.change_with(&snapshot, |selections| { + selections.select_anchors(other_selections); + }); + } + }); + + let this_subscription = cx.subscribe_self::(move |this, this_evt, cx| { + if let EditorEvent::SelectionsChanged { local: true } = this_evt { + let these_selections = this.selections.disjoint_anchors().to_vec(); + if these_selections.is_empty() { + return; + } + other.update(cx, |other_editor, cx| { + let snapshot = other_editor.display_snapshot(cx); + other_editor + .selections + .change_with(&snapshot, |selections| { + selections.select_anchors(these_selections); + }) + }); + } + }); + + Subscription::join(other_subscription, this_subscription) + } + + /// Changes selections using the provided mutation function. Changes to `self.selections` occur + /// immediately, but when run within `transact` or `with_selection_effects_deferred` other + /// effects of selection change occur at the end of the transaction. + pub fn change_selections( + &mut self, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + change: impl FnOnce(&mut MutableSelectionsCollection<'_, '_>) -> R, + ) -> R { + let snapshot = self.display_snapshot(cx); + if let Some(state) = &mut self.deferred_selection_effects_state { + state.effects.scroll = effects.scroll.or(state.effects.scroll); + state.effects.completions = effects.completions; + state.effects.nav_history = effects.nav_history.or(state.effects.nav_history); + let (changed, result) = self.selections.change_with(&snapshot, change); + state.changed |= changed; + return result; + } + let mut state = DeferredSelectionEffectsState { + changed: false, + effects, + old_cursor_position: self.selections.newest_anchor().head(), + history_entry: SelectionHistoryEntry { + selections: self.selections.disjoint_anchors_arc(), + select_next_state: self.select_next_state.clone(), + select_prev_state: self.select_prev_state.clone(), + add_selections_state: self.add_selections_state.clone(), + }, + }; + let (changed, result) = self.selections.change_with(&snapshot, change); + state.changed = state.changed || changed; + if self.defer_selection_effects { + self.deferred_selection_effects_state = Some(state); + } else { + self.apply_selection_effects(state, window, cx); + } + result + } + + /// Defers the effects of selection change, so that the effects of multiple calls to + /// `change_selections` are applied at the end. This way these intermediate states aren't added + /// to selection history and the state of popovers based on selection position aren't + /// erroneously updated. + pub fn with_selection_effects_deferred( + &mut self, + window: &mut Window, + cx: &mut Context, + update: impl FnOnce(&mut Self, &mut Window, &mut Context) -> R, + ) -> R { + let already_deferred = self.defer_selection_effects; + self.defer_selection_effects = true; + let result = update(self, window, cx); + if !already_deferred { + self.defer_selection_effects = false; + if let Some(state) = self.deferred_selection_effects_state.take() { + self.apply_selection_effects(state, window, cx); + } + } + result + } + + pub fn has_non_empty_selection(&self, snapshot: &DisplaySnapshot) -> bool { + self.selections + .all_adjusted(snapshot) + .iter() + .any(|selection| !selection.is_empty()) + } + + pub fn is_range_selected(&mut self, range: &Range, cx: &mut Context) -> bool { + if self + .selections + .pending_anchor() + .is_some_and(|pending_selection| { + let snapshot = self.buffer().read(cx).snapshot(cx); + pending_selection.range().includes(range, &snapshot) + }) + { + return true; + } + + self.selections + .disjoint_in_range::(range.clone(), &self.display_snapshot(cx)) + .into_iter() + .any(|selection| { + // This is needed to cover a corner case, if we just check for an existing + // selection in the fold range, having a cursor at the start of the fold + // marks it as selected. Non-empty selections don't cause this. + let length = selection.end - selection.start; + length > 0 + }) + } + + pub fn has_pending_nonempty_selection(&self) -> bool { + let pending_nonempty_selection = match self.selections.pending_anchor() { + Some(Selection { start, end, .. }) => start != end, + None => false, + }; + + pending_nonempty_selection + || (self.columnar_selection_state.is_some() + && self.selections.disjoint_anchors().len() > 1) + } + + pub fn has_pending_selection(&self) -> bool { + self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some() + } + + pub fn set_selections_from_remote( + &mut self, + selections: Vec>, + pending_selection: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let old_cursor_position = self.selections.newest_anchor().head(); + self.selections + .change_with(&self.display_snapshot(cx), |s| { + s.select_anchors(selections); + if let Some(pending_selection) = pending_selection { + s.set_pending(pending_selection, SelectMode::Character); + } else { + s.clear_pending(); + } + }); + self.selections_did_change( + false, + &old_cursor_position, + SelectionEffects::default(), + window, + cx, + ); + } + + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { + if self.selection_mark_mode { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + sel.collapse_to(sel.head(), SelectionGoal::None); + }); + }) + } + self.selection_mark_mode = true; + cx.notify(); + } + + pub fn swap_selection_ends( + &mut self, + _: &actions::SwapSelectionEnds, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(&mut |_, sel| { + if sel.start != sel.end { + sel.reversed = !sel.reversed + } + }); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + cx.notify(); + } + + pub(super) fn select( + &mut self, + phase: SelectPhase, + window: &mut Window, + cx: &mut Context, + ) { + self.hide_context_menu(window, cx); + + match phase { + SelectPhase::Begin { + position, + add, + click_count, + } => self.begin_selection(position, add, click_count, window, cx), + SelectPhase::BeginColumnar { + position, + goal_column, + reset, + mode, + } => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx), + SelectPhase::Extend { + position, + click_count, + } => self.extend_selection(position, click_count, window, cx), + SelectPhase::Update { + position, + goal_column, + scroll_delta, + } => self.update_selection(position, goal_column, scroll_delta, window, cx), + SelectPhase::End => self.end_selection(window, cx), + } + } + + pub(super) fn extend_selection( + &mut self, + position: DisplayPoint, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let tail = self + .selections + .newest::(&display_map) + .tail(); + let click_count = click_count.max(match self.selections.select_mode() { + SelectMode::Character => 1, + SelectMode::Word(_) => 2, + SelectMode::Line(_) => 3, + SelectMode::All => 4, + }); + self.begin_selection(position, false, click_count, window, cx); + + let tail_anchor = display_map.buffer_snapshot().anchor_before(tail); + + let current_selection = match self.selections.select_mode() { + SelectMode::Character | SelectMode::All => tail_anchor..tail_anchor, + SelectMode::Word(range) | SelectMode::Line(range) => range.clone(), + }; + + let Some((mut pending_selection, mut pending_mode)) = self.pending_selection_and_mode() + else { + log::error!("extend_selection dispatched with no pending selection"); + return; + }; + + if pending_selection + .start + .cmp(¤t_selection.start, display_map.buffer_snapshot()) + == Ordering::Greater + { + pending_selection.start = current_selection.start; + } + if pending_selection + .end + .cmp(¤t_selection.end, display_map.buffer_snapshot()) + == Ordering::Less + { + pending_selection.end = current_selection.end; + pending_selection.reversed = true; + } + + match &mut pending_mode { + SelectMode::Word(range) | SelectMode::Line(range) => *range = current_selection, + _ => {} + } + + let effects = if EditorSettings::get_global(cx).autoscroll_on_clicks { + SelectionEffects::scroll(Autoscroll::fit()) + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + s.set_pending(pending_selection.clone(), pending_mode); + s.set_is_extending(true); + }); + } + + pub(super) fn begin_selection( + &mut self, + position: DisplayPoint, + add: bool, + click_count: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + let position = display_map.clip_point(position, Bias::Left); + + let start; + let end; + let mode; + let mut auto_scroll; + match click_count { + 1 => { + start = buffer.anchor_before(position.to_point(&display_map)); + end = start; + mode = SelectMode::Character; + auto_scroll = true; + } + 2 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let (range, _) = buffer.surrounding_word(position, None); + start = buffer.anchor_before(range.start); + end = buffer.anchor_before(range.end); + mode = SelectMode::Word(start..end); + auto_scroll = true; + } + 3 => { + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + start = buffer.anchor_before(line_start); + end = buffer.anchor_before(next_line_start); + mode = SelectMode::Line(start..end); + auto_scroll = true; + } + _ => { + start = buffer.anchor_before(MultiBufferOffset(0)); + end = buffer.anchor_before(buffer.len()); + mode = SelectMode::All; + auto_scroll = false; + } + } + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; + + let point_to_delete: Option = { + let selected_points: Vec> = + self.selections.disjoint_in_range(start..end, &display_map); + + if !add || click_count > 1 { + None + } else if !selected_points.is_empty() { + Some(selected_points[0].id) + } else { + let clicked_point_already_selected = + self.selections.disjoint_anchors().iter().find(|selection| { + selection.start.to_point(buffer) == start.to_point(buffer) + || selection.end.to_point(buffer) == end.to_point(buffer) + }); + + clicked_point_already_selected.map(|selection| selection.id) + } + }; + + let selections_count = self.selections.count(); + let effects = if auto_scroll { + SelectionEffects::default() + } else { + SelectionEffects::no_scroll() + }; + + self.change_selections(effects, window, cx, |s| { + if let Some(point_to_delete) = point_to_delete { + s.delete(point_to_delete); + + if selections_count == 1 { + s.set_pending_anchor_range(start..end, mode); + } + } else { + if !add { + s.clear_disjoint(); + } + + s.set_pending_anchor_range(start..end, mode); + } + }); + } + + pub(super) fn begin_columnar_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + reset: bool, + mode: ColumnarMode, + window: &mut Window, + cx: &mut Context, + ) { + if !self.focus_handle.is_focused(window) { + self.last_focused_descendant = None; + window.focus(&self.focus_handle, cx); + } + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if reset { + let pointer_position = display_map + .buffer_snapshot() + .anchor_before(position.to_point(&display_map)); + + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |s| { + s.clear_disjoint(); + s.set_pending_anchor_range( + pointer_position..pointer_position, + SelectMode::Character, + ); + }, + ); + }; + + let tail = self.selections.newest::(&display_map).tail(); + let selection_anchor = display_map.buffer_snapshot().anchor_before(tail); + self.columnar_selection_state = match mode { + ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse { + selection_tail: selection_anchor, + display_point: if reset { + if position.column() != goal_column { + Some(DisplayPoint::new(position.row(), goal_column)) + } else { + None + } + } else { + None + }, + }), + ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection { + selection_tail: selection_anchor, + }), + }; + + if !reset { + self.select_columns(position, goal_column, &display_map, window, cx); + } + } + + pub(super) fn update_selection( + &mut self, + position: DisplayPoint, + goal_column: u32, + scroll_delta: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + + if self.columnar_selection_state.is_some() { + self.select_columns(position, goal_column, &display_map, window, cx); + } else if let Some((mut pending, mode)) = self.pending_selection_and_mode() { + let buffer = display_map.buffer_snapshot(); + let head; + let tail; + match &mode { + SelectMode::Character => { + head = position.to_point(&display_map); + tail = pending.tail().to_point(buffer); + } + SelectMode::Word(original_range) => { + let offset = display_map + .clip_point(position, Bias::Left) + .to_offset(&display_map, Bias::Left); + let original_range = original_range.to_offset(buffer); + + let head_offset = if buffer.is_inside_word(offset, None) + || original_range.contains(&offset) + { + let (word_range, _) = buffer.surrounding_word(offset, None); + if word_range.start < original_range.start { + word_range.start + } else { + word_range.end + } + } else { + offset + }; + + head = head_offset.to_point(buffer); + if head_offset <= original_range.start { + tail = original_range.end.to_point(buffer); + } else { + tail = original_range.start.to_point(buffer); + } + } + SelectMode::Line(original_range) => { + let original_range = original_range.to_point(display_map.buffer_snapshot()); + + let position = display_map + .clip_point(position, Bias::Left) + .to_point(&display_map); + let line_start = display_map.prev_line_boundary(position).0; + let next_line_start = buffer.clip_point( + display_map.next_line_boundary(position).0 + Point::new(1, 0), + Bias::Left, + ); + + if line_start < original_range.start { + head = line_start + } else { + head = next_line_start + } + + if head <= original_range.start { + tail = original_range.end; + } else { + tail = original_range.start; + } + } + SelectMode::All => { + return; + } + }; + + if head < tail { + pending.start = buffer.anchor_before(head); + pending.end = buffer.anchor_before(tail); + pending.reversed = true; + } else { + pending.start = buffer.anchor_before(tail); + pending.end = buffer.anchor_before(head); + pending.reversed = false; + } + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.set_pending(pending.clone(), mode); + }); + } else { + log::error!("update_selection dispatched with no pending selection"); + return; + } + + self.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + + pub(super) fn end_selection(&mut self, window: &mut Window, cx: &mut Context) { + self.columnar_selection_state.take(); + if let Some(pending_mode) = self.selections.pending_mode() { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select(selections); + s.clear_pending(); + if s.is_extending() { + s.set_is_extending(false); + } else { + s.set_select_mode(pending_mode); + } + }); + } + } + + fn selections_did_change( + &mut self, + local: bool, + old_cursor_position: &Anchor, + effects: SelectionEffects, + window: &mut Window, + cx: &mut Context, + ) { + self.last_selection_from_search = effects.from_search; + window.invalidate_character_coordinates(); + + // Copy selections to primary selection buffer + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if local { + let selections = self + .selections + .all::(&self.display_snapshot(cx)); + let buffer_handle = self.buffer.read(cx).read(cx); + + let mut text = String::new(); + for (index, selection) in selections.iter().enumerate() { + let text_for_selection = buffer_handle + .text_for_range(selection.start..selection.end) + .collect::(); + + text.push_str(&text_for_selection); + if index != selections.len() - 1 { + text.push('\n'); + } + } + + if !text.is_empty() { + cx.write_to_primary(ClipboardItem::new_string(text)); + } + } + + let selection_anchors = self.selections.disjoint_anchors_arc(); + + if self.focus_handle.is_focused(window) && self.leader_id.is_none() { + self.buffer.update(cx, |buffer, cx| { + buffer.set_active_selections( + &selection_anchors, + self.selections.line_mode(), + self.cursor_shape, + cx, + ) + }); + } + let display_map = self + .display_map + .update(cx, |display_map, cx| display_map.snapshot(cx)); + let buffer = display_map.buffer_snapshot(); + if self.selections.count() == 1 { + self.add_selections_state = None; + } + self.select_next_state = None; + self.select_prev_state = None; + self.select_syntax_node_history.try_clear(); + self.invalidate_autoclose_regions(&selection_anchors, buffer); + self.snippet_stack.invalidate(&selection_anchors, buffer); + self.take_rename(false, window, cx); + + let newest_selection = self.selections.newest_anchor(); + let new_cursor_position = newest_selection.head(); + let selection_start = newest_selection.start; + + if effects.nav_history.is_none() || effects.nav_history == Some(true) { + self.push_to_nav_history( + *old_cursor_position, + Some(new_cursor_position.to_point(buffer)), + false, + effects.nav_history == Some(true), + cx, + ); + } + + if local { + if let Some((anchor, _)) = buffer.anchor_to_buffer_anchor(new_cursor_position) { + self.register_buffer(anchor.buffer_id, cx); + } + + let mut context_menu = self.context_menu.borrow_mut(); + let completion_menu = match context_menu.as_ref() { + Some(CodeContextMenu::Completions(menu)) => Some(menu), + Some(CodeContextMenu::CodeActions(_)) => { + *context_menu = None; + None + } + None => None, + }; + let completion_position = completion_menu.map(|menu| menu.initial_position); + drop(context_menu); + + if effects.completions + && let Some(completion_position) = completion_position + { + let start_offset = selection_start.to_offset(buffer); + let position_matches = start_offset == completion_position.to_offset(buffer); + let continue_showing = if let Some((snap, ..)) = + buffer.point_to_buffer_offset(completion_position) + && !snap.capability.editable() + { + false + } else if position_matches { + if self.snippet_stack.is_empty() { + buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion)) + == Some(CharKind::Word) + } else { + // Snippet choices can be shown even when the cursor is in whitespace. + // Dismissing the menu with actions like backspace is handled by + // invalidation regions. + true + } + } else { + false + }; + + if continue_showing { + self.open_or_update_completions_menu(None, None, false, window, cx); + } else { + self.hide_context_menu(window, cx); + } + } + + hide_hover(self, cx); + + self.refresh_code_actions_for_selection(window, cx); + self.refresh_document_highlights(cx); + refresh_linked_ranges(self, window, cx); + + self.refresh_selected_text_highlights(&display_map, false, window, cx); + self.refresh_matching_bracket_highlights(&display_map, cx); + self.refresh_outline_symbols_at_cursor(cx); + self.update_visible_edit_prediction(window, cx); + self.hide_blame_popover(true, cx); + if self.git_blame_inline_enabled { + self.start_inline_blame_timer(window, cx); + } + } + + self.blink_manager.update(cx, BlinkManager::pause_blinking); + + if local && !self.suppress_selection_callback { + if let Some(callback) = self.on_local_selections_changed.as_ref() { + let cursor_position = self.selections.newest::(&display_map).head(); + callback(cursor_position, window, cx); + } + } + + cx.emit(EditorEvent::SelectionsChanged { local }); + + let selections = &self.selections.disjoint_anchors_arc(); + if local && let Some(buffer_snapshot) = buffer.as_singleton() { + let inmemory_selections = selections + .iter() + .map(|s| { + let start = s.range().start.text_anchor_in(buffer_snapshot); + let end = s.range().end.text_anchor_in(buffer_snapshot); + (start..end).to_point(buffer_snapshot) + }) + .collect(); + self.update_restoration_data(cx, |data| { + data.selections = inmemory_selections; + }); + + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab + && let Some(workspace_id) = self.workspace_serialization_id(cx) + { + let snapshot = self.buffer().read(cx).snapshot(cx); + let selections = selections.clone(); + let background_executor = cx.background_executor().clone(); + let editor_id = cx.entity().entity_id().as_u64() as ItemId; + let db = EditorDb::global(cx); + self.serialize_selections = cx.background_spawn(async move { + background_executor.timer(SERIALIZATION_THROTTLE_TIME).await; + let db_selections = selections + .iter() + .map(|selection| { + ( + selection.start.to_offset(&snapshot).0, + selection.end.to_offset(&snapshot).0, + ) + }) + .collect(); + + db.save_editor_selections(editor_id, workspace_id, db_selections) + .await + .with_context(|| { + format!( + "persisting editor selections for editor {editor_id}, \ + workspace {workspace_id:?}" + ) + }) + .log_err(); + }); + } + } + + cx.notify(); + } + + fn apply_selection_effects( + &mut self, + state: DeferredSelectionEffectsState, + window: &mut Window, + cx: &mut Context, + ) { + if state.changed { + self.selection_history.push(state.history_entry); + + if let Some(autoscroll) = state.effects.scroll { + self.request_autoscroll(autoscroll, cx); + } + + let old_cursor_position = &state.old_cursor_position; + + self.selections_did_change(true, old_cursor_position, state.effects, window, cx); + + if self.should_open_signature_help_automatically(old_cursor_position, cx) { + self.show_signature_help_auto(window, cx); + } + } + } + + fn select_columns( + &mut self, + head: DisplayPoint, + goal_column: u32, + display_map: &DisplaySnapshot, + window: &mut Window, + cx: &mut Context, + ) { + let Some(columnar_state) = self.columnar_selection_state.as_ref() else { + return; + }; + + let tail = match columnar_state { + ColumnarSelectionState::FromMouse { + selection_tail, + display_point, + } => display_point.unwrap_or_else(|| selection_tail.to_display_point(display_map)), + ColumnarSelectionState::FromSelection { selection_tail } => { + selection_tail.to_display_point(display_map) + } + }; + + let start_row = cmp::min(tail.row(), head.row()); + let end_row = cmp::max(tail.row(), head.row()); + let start_column = cmp::min(tail.column(), goal_column); + let end_column = cmp::max(tail.column(), goal_column); + let reversed = start_column < tail.column(); + + let selection_ranges = (start_row.0..=end_row.0) + .map(DisplayRow) + .filter_map(|row| { + if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. }) + || start_column <= display_map.line_len(row)) + && !display_map.is_block_line(row) + { + let start = display_map + .clip_point(DisplayPoint::new(row, start_column), Bias::Left) + .to_point(display_map); + let end = display_map + .clip_point(DisplayPoint::new(row, end_column), Bias::Right) + .to_point(display_map); + if reversed { + Some(end..start) + } else { + Some(start..end) + } + } else { + None + } + }) + .collect::>(); + if selection_ranges.is_empty() { + return; + } + + let ranges = match columnar_state { + ColumnarSelectionState::FromMouse { .. } => { + let mut non_empty_ranges = selection_ranges + .iter() + .filter(|selection_range| selection_range.start != selection_range.end) + .peekable(); + if non_empty_ranges.peek().is_some() { + non_empty_ranges.cloned().collect() + } else { + selection_ranges + } + } + _ => selection_ranges, + }; + + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(ranges); + }); + cx.notify(); + } + + fn pending_selection_and_mode(&self) -> Option<(Selection, SelectMode)> { + Some(( + self.selections.pending_anchor()?.clone(), + self.selections.pending_mode()?, + )) + } +} diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 29c998ce976..23ce7c41be7 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -142,7 +142,10 @@ impl Editor { ); } - let Some((sema, project)) = self.semantics_provider.clone().zip(self.project.clone()) + let Some((sema, project)) = self + .semantics_provider + .clone() + .zip(self.project.as_ref().map(|p| p.downgrade())) else { return; }; @@ -283,6 +286,9 @@ impl Editor { .buffer(buffer_id) .and_then(|buf| buf.read(cx).language().map(|l| l.name())); + let Some(project) = project.upgrade() else { + return; + }; editor.display_map.update(cx, |display_map, cx| { project.read(cx).lsp_store().update(cx, |lsp_store, cx| { let mut token_highlights = Vec::new(); diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 8f7ef224c53..39c450fb959 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -583,8 +583,13 @@ impl SplittableEditor { }; let project = workspace.read(cx).project().clone(); + let is_rhs_singleton = self.rhs_multibuffer.read(cx).is_singleton(); let lhs_multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadOnly); + let mut multibuffer = if is_rhs_singleton { + MultiBuffer::without_headers(Capability::ReadOnly) + } else { + MultiBuffer::new(Capability::ReadOnly) + }; multibuffer.set_all_diff_hunks_expanded(cx); multibuffer }); diff --git a/crates/editor_benchmarks/Cargo.toml b/crates/editor_benchmarks/Cargo.toml new file mode 100644 index 00000000000..8db5d4b26ae --- /dev/null +++ b/crates/editor_benchmarks/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "editor_benchmarks" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +editor.workspace = true +gpui.workspace = true +gpui_platform.workspace = true +language.workspace = true +multi_buffer.workspace = true +project.workspace = true +release_channel.workspace = true +semver.workspace = true +settings.workspace = true +theme.workspace = true +workspace.workspace = true + +[lints] +workspace = true diff --git a/crates/editor_benchmarks/LICENSE-GPL b/crates/editor_benchmarks/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/editor_benchmarks/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/editor_benchmarks/src/main.rs b/crates/editor_benchmarks/src/main.rs new file mode 100644 index 00000000000..81df5533401 --- /dev/null +++ b/crates/editor_benchmarks/src/main.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use editor::Editor; +use gpui::{AppContext as _, AsyncWindowContext, WeakEntity, WindowBounds, WindowOptions}; +use language::Buffer; +use multi_buffer::Anchor; +use project::search::SearchQuery; +use workspace::searchable::SearchableItem; + +#[derive(Debug)] +struct Args { + file: String, + query: String, + replace: Option, + regex: bool, + whole_word: bool, + case_sensitive: bool, +} + +fn parse_args() -> Args { + let mut args_iter = std::env::args().skip(1); + let mut parsed = Args { + file: String::new(), + query: String::new(), + replace: None, + regex: false, + whole_word: false, + case_sensitive: false, + }; + + let mut positional = Vec::new(); + while let Some(arg) = args_iter.next() { + match arg.as_str() { + "--regex" => parsed.regex = true, + "--whole-word" => parsed.whole_word = true, + "--case-sensitive" => parsed.case_sensitive = true, + "-r" | "--replace" => { + parsed.replace = args_iter.next(); + } + "--help" | "-h" => { + eprintln!( + "Usage: editor_benchmarks [OPTIONS] \n\n\ + Arguments:\n \ + Path to the file to search in\n \ + The search query string\n\n\ + Options:\n \ + -r, --replace Replacement text (runs replace_all)\n \ + --regex Treat query as regex\n \ + --whole-word Match whole words only\n \ + --case-sensitive Case-sensitive matching\n \ + -h, --help Print help" + ); + std::process::exit(0); + } + other => positional.push(other.to_string()), + } + } + + if positional.len() < 2 { + eprintln!("Usage: editor_benchmarks [OPTIONS] "); + std::process::exit(1); + } + parsed.file = positional.remove(0); + parsed.query = positional.remove(0); + parsed +} + +fn main() { + let args = parse_args(); + + let file_contents = std::fs::read_to_string(&args.file).expect("failed to read input file"); + let file_len = file_contents.len(); + println!("Read {} ({file_len} bytes)", args.file); + + let mut query = if args.regex { + SearchQuery::regex( + &args.query, + args.whole_word, + args.case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("invalid regex query") + } else { + SearchQuery::text( + &args.query, + args.whole_word, + args.case_sensitive, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("invalid text query") + }; + + if let Some(replacement) = args.replace.as_deref() { + query = query.with_replacement(replacement.to_string()); + } + + let query = Arc::new(query); + let has_replacement = args.replace.is_some(); + + gpui_platform::headless().run(move |cx| { + release_channel::init_test( + semver::Version::new(0, 0, 0), + release_channel::ReleaseChannel::Dev, + cx, + ); + settings::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + + let buffer = cx.new(|cx| Buffer::local(file_contents, cx)); + + let window_handle = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(gpui::Bounds { + origin: Default::default(), + size: gpui::size(gpui::px(800.0), gpui::px(600.0)), + })), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)), + ) + .expect("failed to open window"); + + window_handle + .update(cx, move |_, window, cx| { + cx.spawn_in( + window, + async move |weak: WeakEntity, + cx: &mut AsyncWindowContext| + -> anyhow::Result<()> { + let find_task = weak.update_in(cx, |editor, window, cx| { + editor.find_matches(query.clone(), window, cx) + })?; + + println!("Finding matches..."); + let timer = std::time::Instant::now(); + let matches: Vec> = find_task.await; + let find_elapsed = timer.elapsed(); + println!("Found {} matches in {find_elapsed:?}", matches.len()); + + if has_replacement && !matches.is_empty() { + window_handle.update(cx, |editor: &mut Editor, window, cx| { + let mut match_iter = matches.iter(); + println!("Replacing all matches..."); + let timer = std::time::Instant::now(); + editor.replace_all( + &mut match_iter, + &query, + Default::default(), + window, + cx, + ); + let replace_elapsed = timer.elapsed(); + println!( + "Replaced {} matches in {replace_elapsed:?}", + matches.len() + ); + })?; + } + + std::process::exit(0); + }, + ) + .detach(); + }) + .unwrap(); + }); +} diff --git a/crates/encoding_selector/src/active_buffer_encoding.rs b/crates/encoding_selector/src/active_buffer_encoding.rs index 42fd5f662f6..6d782343142 100644 --- a/crates/encoding_selector/src/active_buffer_encoding.rs +++ b/crates/encoding_selector/src/active_buffer_encoding.rs @@ -3,13 +3,13 @@ use crate::{EncodingSelector, Toggle}; use editor::Editor; use encoding_rs::{Encoding, UTF_8}; use gpui::{ - Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, - div, + App, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, + Window, div, }; use project::Project; use ui::{Button, ButtonCommon, Clickable, LabelSize, Tooltip}; use workspace::{ - StatusBarSettings, StatusItemView, Workspace, + EncodingDisplayOptions, HideStatusItem, StatusBarSettings, StatusItemView, Workspace, item::{ItemHandle, Settings}, }; @@ -131,4 +131,13 @@ impl StatusItemView for ActiveBufferEncoding { cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .active_encoding_button = Some(EncodingDisplayOptions::Disabled); + })) + } } diff --git a/crates/eval_cli/src/headless.rs b/crates/eval_cli/src/headless.rs index 0ddd99e8f8a..a5b86f8eec8 100644 --- a/crates/eval_cli/src/headless.rs +++ b/crates/eval_cli/src/headless.rs @@ -70,6 +70,7 @@ pub fn init(cx: &mut App) -> Arc { git_binary_path, cx.background_executor().clone(), )); + ::set_global(fs.clone(), cx); let mut languages = LanguageRegistry::new(cx.background_executor().clone()); languages.set_language_server_download_dir(paths::languages_dir().clone()); diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index f67e5494695..2fc50434603 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -28,23 +28,15 @@ const RUST_TARGET: &str = "wasm32-wasip2"; /// Once Clang 17 and its wasm target are available via system package managers, we won't need /// to download this. const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/"; -const WASI_SDK_ASSET_NAME: Option<&str> = if cfg!(all(target_os = "macos", target_arch = "x86_64")) -{ - Some("wasi-sdk-25.0-x86_64-macos.tar.gz") -} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { - Some("wasi-sdk-25.0-arm64-macos.tar.gz") -} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { - Some("wasi-sdk-25.0-x86_64-linux.tar.gz") -} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { - Some("wasi-sdk-25.0-arm64-linux.tar.gz") -} else if cfg!(all(target_os = "freebsd", target_arch = "x86_64")) { - Some("wasi-sdk-25.0-x86_64-linux.tar.gz") -} else if cfg!(all(target_os = "freebsd", target_arch = "aarch64")) { - Some("wasi-sdk-25.0-arm64-linux.tar.gz") -} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { - Some("wasi-sdk-25.0-x86_64-windows.tar.gz") -} else { - None +const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! { + all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"), + all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"), + all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), + all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), + all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"), + all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"), + all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"), + _ => None }; pub struct ExtensionBuilder { diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index ca43b4a3993..a59f93610d5 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -19,7 +19,7 @@ use extension::{ ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy, ExtensionLanguageServerProxy, ExtensionSnippetProxy, ExtensionThemeProxy, }; -use fs::{Fs, RemoveOptions}; +use fs::{Fs, RemoveOptions, RenameOptions}; use futures::future::join_all; use futures::{ AsyncReadExt as _, Future, FutureExt as _, StreamExt as _, @@ -31,8 +31,8 @@ use futures::{ select_biased, }; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Task, UpdateGlobal as _, - WeakEntity, actions, + App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Task, TaskExt, + UpdateGlobal as _, WeakEntity, actions, }; use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; use language::{ @@ -77,7 +77,7 @@ const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1); /// /// These snippets should no longer be downloaded or loaded, because their /// functionality has been integrated into the core editor. -const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright"]; +const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright", "basher"]; /// Returns the [`SchemaVersion`] range that is compatible with this version of Zed. pub fn schema_version_range() -> RangeInclusive { @@ -726,41 +726,67 @@ impl ExtensionStore { } }); - let mut response = http_client - .get(url.as_ref(), Default::default(), true) - .await - .context("downloading extension")?; + cx.background_spawn(async move { + let mut response = http_client + .get(url.as_ref(), Default::default(), true) + .await + .context("downloading extension")?; - fs.remove_dir( - &extension_dir, - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) + let content_length = response + .headers() + .get(http_client::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()?.parse::().ok()); + + let mut body = BufReader::new(response.body_mut()); + let mut tar_gz_bytes = Vec::new(); + body.read_to_end(&mut tar_gz_bytes).await?; + + if let Some(content_length) = content_length { + let actual_len = tar_gz_bytes.len(); + if content_length != actual_len { + bail!( + "downloaded extension size {actual_len} \ + does not match content length {content_length}" + ); + } + } + + let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice())); + let archive = Archive::new(decompressed_bytes); + + let remove_dir = || { + fs.remove_dir( + &extension_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + }; + + match tempfile::tempdir_in(paths::temp_dir()).or_else(|_| tempfile::tempdir()) { + Ok(temp_dir) => { + archive.unpack(temp_dir.path()).await?; + remove_dir().await?; + fs.rename( + temp_dir.path(), + &extension_dir, + RenameOptions { + overwrite: true, + ignore_if_exists: true, + create_parents: true, + }, + ) + .await + } + Err(_) => { + remove_dir().await?; + archive.unpack(extension_dir).await.map_err(Into::into) + } + } + }) .await?; - let content_length = response - .headers() - .get(http_client::http::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()?.parse::().ok()); - - let mut body = BufReader::new(response.body_mut()); - let mut tar_gz_bytes = Vec::new(); - body.read_to_end(&mut tar_gz_bytes).await?; - - if let Some(content_length) = content_length { - let actual_len = tar_gz_bytes.len(); - if content_length != actual_len { - bail!(concat!( - "downloaded extension size {actual_len} ", - "does not match content length {content_length}" - )); - } - } - let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice())); - let archive = Archive::new(decompressed_bytes); - archive.unpack(extension_dir).await?; this.update(cx, |this, cx| this.reload(Some(extension_id.clone()), cx))? .await; diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index abdb3ffd3fa..2e2408ea2d9 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -8,7 +8,7 @@ use collections::{BTreeMap, HashSet}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs, RealFs}; use futures::{AsyncReadExt, FutureExt, StreamExt, io::BufReader}; -use gpui::{AppContext as _, BackgroundExecutor, TestAppContext}; +use gpui::{AppContext as _, BackgroundExecutor, TaskExt, TestAppContext}; use http_client::{FakeHttpClient, Response}; use language::{BinaryStatus, LanguageMatcher, LanguageName, LanguageRegistry}; use language_extension::LspAccess; diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 0e6bfe8498d..af3b9031e44 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -15,7 +15,7 @@ use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ Action, Anchor, App, ClipboardItem, Context, Entity, EventEmitter, Focusable, - InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TextStyle, + InteractiveElement, KeyContext, ParentElement, Point, Render, Styled, Task, TaskExt, TextStyle, UniformListScrollHandle, WeakEntity, Window, actions, point, uniform_list, }; use num_format::{Locale, ToFormattedString}; diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index d9af542efea..cb216267376 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); +pub struct AgentPanelTerminalFeatureFlag; + +impl FeatureFlag for AgentPanelTerminalFeatureFlag { + const NAME: &'static str = "agent-panel-terminal"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(AgentPanelTerminalFeatureFlag); + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 66acefde69f..c15524b17bf 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -14,7 +14,7 @@ use fuzzy_nucleo::{PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, - StatefulInteractiveElement, Styled, Task, WeakEntity, Window, actions, rems, + StatefulInteractiveElement, Styled, Task, TaskExt, WeakEntity, Window, actions, rems, }; use open_path_prompt::{ OpenPathPrompt, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 5f2cb0515ce..4ce38b59565 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -72,6 +72,7 @@ pub struct FakeGitRepositoryState { pub simulated_index_write_error_message: Option, pub simulated_create_worktree_error: Option, pub simulated_graph_error: Option, + pub branches_requiring_force_delete: HashSet, pub refs: HashMap, pub graph_commits: Vec>, pub commit_data: HashMap, @@ -91,6 +92,7 @@ impl FakeGitRepositoryState { simulated_index_write_error_message: Default::default(), simulated_create_worktree_error: Default::default(), simulated_graph_error: None, + branches_requiring_force_delete: Default::default(), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), @@ -888,11 +890,22 @@ impl GitRepository for FakeGitRepository { }) } - fn delete_branch(&self, _is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { + fn delete_branch( + &self, + _is_remote: bool, + name: String, + force: bool, + ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { + if !force && state.branches_requiring_force_delete.contains(&name) { + bail!( + "error: The branch '{name}' is not fully merged.\nIf you are sure you want to delete it, run 'git branch -D {name}'." + ); + } if !state.branches.remove(&name) { bail!("no such branch: {name}"); } + state.branches_requiring_force_delete.remove(&name); Ok(()) }) } diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 6db36992dec..fec8b03bfe1 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -1,4 +1,4 @@ -use notify::EventKind; +use notify::{Event, EventKind}; use parking_lot::Mutex; use std::{ collections::{BTreeMap, HashMap}, @@ -87,19 +87,11 @@ impl Watcher for FsWatcher { let path: Arc = path.into(); let registration_path = path.clone(); - let registration_id = global_watcher().add( - path.clone(), - self.mode, - move |result: Result<¬ify::Event, ¬ify::Error>| match result { - Ok(event) => { - log::trace!("watcher received event: {event:?}"); - push_notify_event(&tx, &pending_path_events, &root_path, path.as_ref(), event); - } - Err(error) => { - push_notify_error(&tx, &pending_path_events, path.as_ref(), error); - } - }, - )?; + let registration_id = + global_watcher().add(path.clone(), self.mode, move |event: ¬ify::Event| { + log::trace!("watcher received event: {event:?}"); + push_notify_event(&tx, &pending_path_events, &root_path, path.as_ref(), event); + })?; self.registrations .lock() @@ -176,23 +168,6 @@ fn push_notify_event( enqueue_path_events(tx, pending_path_events, path_events); } -fn push_notify_error( - tx: &smol::channel::Sender<()>, - pending_path_events: &Arc>>, - watched_root: &Path, - error: ¬ify::Error, -) { - log::warn!("watcher error for {watched_root:?}: {error}"); - enqueue_path_events( - tx, - pending_path_events, - vec![PathEvent { - path: watched_root.to_path_buf(), - kind: Some(PathEventKind::Rescan), - }], - ); -} - fn coalesce_pending_rescans(pending_paths: &mut Vec, path_events: &mut Vec) { if !path_events .iter() @@ -247,7 +222,7 @@ fn is_covered_rescan(kind: Option, path: &Path, ancestor: &Path) pub struct WatcherRegistrationId(u32); struct WatcherRegistrationState { - callback: Arc Fn(Result<&'a notify::Event, &'a notify::Error>) + Send + Sync>, + callback: Arc, path: Arc, mode: WatcherMode, } @@ -283,7 +258,7 @@ impl GlobalWatcher { &self, path: Arc, mode: WatcherMode, - cb: impl for<'a> Fn(Result<&'a notify::Event, &'a notify::Error>) + Send + Sync + 'static, + cb: impl Fn(¬ify::Event) + Send + Sync + 'static, ) -> anyhow::Result { let mut state = self.state.lock(); let registrations_for_mode = state.path_registrations(mode); @@ -458,6 +433,16 @@ fn handle_poll_event(event: Result) { } fn handle_event(mode: WatcherMode, event: Result) { + if matches!( + event, + Ok(Event { + kind: EventKind::Access(_), + .. + }) + ) { + return; + } + log::trace!("global handle event for {mode:?}: {event:?}"); let callbacks = { @@ -472,17 +457,12 @@ fn handle_event(mode: WatcherMode, event: Result) match event { Ok(event) => { - if matches!(event.kind, EventKind::Access(_)) { - return; - } for callback in callbacks { - callback(Ok(&event)); + callback(&event); } } Err(error) => { - for callback in callbacks { - callback(Err(&error)); - } + log::warn!("watcher error for {mode:?}: {error}"); } } } diff --git a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs index a6b32f6e1cc..9080d102a2e 100644 --- a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs +++ b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs @@ -2,6 +2,9 @@ mod matcher; mod paths; mod strings; +use fuzzy::CharBag; +use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; + pub use paths::{ PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets, }; @@ -45,6 +48,83 @@ impl LengthPenalty { } } +// Matching is always case-insensitive at the nucleo level — using +// `CaseMatching::Smart` there would *reject* candidates whose capitalization +// doesn't match the query, breaking pickers like the command palette +// (`"Editor: Backspace"` against the action named `"editor: backspace"`). +// `Case::Smart` is honored as a *scoring hint* instead: when the query +// contains uppercase, candidates whose matched characters disagree in case +// are downranked by a per-mismatch penalty rather than dropped. +pub(crate) struct Query { + pub(crate) pattern: Pattern, + /// Non-whitespace query chars in input order, populated only when a smart-case + /// penalty will actually be charged. Aligns 1:1 with the indices appended by + /// `Pattern::indices` (atom-order, needle-order within each atom). + pub(crate) query_chars: Option>, + pub(crate) char_bag: CharBag, +} + +impl Query { + pub(crate) fn build(query: &str, case: Case) -> Option { + if query.chars().all(char::is_whitespace) { + return None; + } + let normalized = query.split_whitespace().collect::>().join(" "); + let pattern = Pattern::new( + &normalized, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + ); + let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase()); + let query_chars = + wants_case_penalty.then(|| query.chars().filter(|c| !c.is_whitespace()).collect()); + Some(Query { + pattern, + query_chars, + char_bag: CharBag::from(query), + }) + } +} + +#[inline] +pub(crate) fn count_case_mismatches( + query_chars: Option<&[char]>, + matched_chars: &[u32], + candidate: &str, + candidate_chars: &mut Vec, +) -> u32 { + let Some(query_chars) = query_chars else { + return 0; + }; + if query_chars.len() != matched_chars.len() { + return 0; + } + candidate_chars.clear(); + candidate_chars.extend(candidate.chars()); + let mut mismatches: u32 = 0; + for (&query_char, &pos) in query_chars.iter().zip(matched_chars) { + if let Some(&candidate_char) = candidate_chars.get(pos as usize) + && candidate_char != query_char + && candidate_char.eq_ignore_ascii_case(&query_char) + { + mismatches += 1; + } + } + mismatches +} + +const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9; + +#[inline] +pub(crate) fn case_penalty(mismatches: u32) -> f64 { + if mismatches == 0 { + 1.0 + } else { + SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32) + } +} + /// Reconstruct byte-offset match positions from a list of matched char offsets /// that is already sorted ascending and deduplicated. pub(crate) fn positions_from_sorted(s: &str, sorted_char_indices: &[u32]) -> Vec { diff --git a/crates/fuzzy_nucleo/src/paths.rs b/crates/fuzzy_nucleo/src/paths.rs index dd4594ce37e..6aaabfeb50e 100644 --- a/crates/fuzzy_nucleo/src/paths.rs +++ b/crates/fuzzy_nucleo/src/paths.rs @@ -9,12 +9,12 @@ use std::{ use util::{paths::PathStyle, rel_path::RelPath}; use nucleo::Utf32Str; -use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; +use nucleo::pattern::Pattern; use fuzzy::CharBag; use crate::matcher::{self, LENGTH_PENALTY}; -use crate::{Cancelled, Case, positions_from_sorted}; +use crate::{Cancelled, Case, Query, case_penalty, count_case_mismatches, positions_from_sorted}; #[derive(Clone, Debug)] pub struct PathMatchCandidate<'a> { @@ -96,47 +96,6 @@ impl Ord for PathMatch { } } -// Path matching is always case-insensitive at the nucleo level. `Case::Smart` -// is honored as a *scoring hint*: when the query contains uppercase, candidates -// whose matched characters disagree in case are downranked by a factor per -// mismatch rather than dropped. This keeps `"Editor: Backspace"` matching -// `"editor: backspace"` while still preferring exact-case hits. -const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9; - -pub(crate) fn make_atoms(query: &str) -> Vec { - query - .split_whitespace() - .map(|word| { - Atom::new( - word, - CaseMatching::Ignore, - Normalization::Smart, - AtomKind::Fuzzy, - false, - ) - }) - .collect() -} - -// Only populated when we will actually charge a smart-case penalty, so the hot -// path can iterate a plain `&[Atom]` and ignore this slice entirely. -fn make_source_words(query: &str, case: Case) -> Option>> { - (case.is_smart() && query.chars().any(|c| c.is_uppercase())).then(|| { - query - .split_whitespace() - .map(|word| word.chars().collect()) - .collect() - }) -} - -fn case_penalty(mismatches: u32) -> f64 { - if mismatches == 0 { - 1.0 - } else { - SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32) - } -} - pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize { let mut path_components = path.components(); let mut relative_components = relative_to.components(); @@ -150,34 +109,34 @@ pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> u path_components.count() + relative_components.count() + 1 } +#[inline] fn get_filename_match_bonus( candidate_buf: &str, - query_atoms: &[Atom], + pattern: &Pattern, matcher: &mut nucleo::Matcher, ) -> f64 { - let filename = match std::path::Path::new(candidate_buf).file_name() { - Some(f) => f.to_str().unwrap_or(""), - None => return 0.0, - }; - if filename.is_empty() || query_atoms.is_empty() { + let Some(filename) = std::path::Path::new(candidate_buf) + .file_name() + .and_then(|f| f.to_str()) + .filter(|f| !f.is_empty()) + else { return 0.0; - } + }; let mut buf = Vec::new(); let haystack = Utf32Str::new(filename, &mut buf); - let mut total_score = 0u32; - for atom in query_atoms { - if let Some(score) = atom.score(haystack, matcher) { - total_score = total_score.saturating_add(score as u32); - } - } - total_score as f64 / filename.len().max(1) as f64 + let score: u32 = pattern + .atoms + .iter() + .filter_map(|atom| atom.score(haystack, matcher)) + .map(|s| s as u32) + .sum(); + + score as f64 / filename.len().max(1) as f64 } fn path_match_helper<'a>( matcher: &mut nucleo::Matcher, - atoms: &[Atom], - source_words: Option<&[Vec]>, - query_bag: CharBag, + query: &Query, candidates: impl Iterator>, results: &mut Vec, worktree_id: usize, @@ -197,7 +156,6 @@ fn path_match_helper<'a>( let path_prefix_len = candidate_buf.len(); let mut buf = Vec::new(); let mut matched_chars: Vec = Vec::new(); - let mut atom_matched_chars = Vec::new(); let mut candidate_chars: Vec = Vec::new(); for candidate in candidates { buf.clear(); @@ -206,7 +164,7 @@ fn path_match_helper<'a>( return Err(Cancelled); } - if !candidate.char_bag.is_superset(query_bag) { + if !candidate.char_bag.is_superset(query.char_bag) { continue; } @@ -219,70 +177,45 @@ fn path_match_helper<'a>( let haystack = Utf32Str::new(&candidate_buf, &mut buf); - if source_words.is_some() { - candidate_chars.clear(); - candidate_chars.extend(candidate_buf.chars()); - } + let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else { + continue; + }; - let mut total_score: u32 = 0; - let mut case_mismatches: u32 = 0; - let mut all_matched = true; + let case_mismatches = count_case_mismatches( + query.query_chars.as_deref(), + &matched_chars, + &candidate_buf, + &mut candidate_chars, + ); - for (atom_idx, atom) in atoms.iter().enumerate() { - atom_matched_chars.clear(); - let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else { - all_matched = false; - break; - }; - total_score = total_score.saturating_add(score as u32); - if let Some(source_words) = source_words { - let query_chars = &source_words[atom_idx]; - if query_chars.len() == atom_matched_chars.len() { - for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) { - if let Some(&candidate_char) = candidate_chars.get(pos as usize) - && candidate_char != query_char - && candidate_char.eq_ignore_ascii_case(&query_char) - { - case_mismatches += 1; - } - } - } - } - matched_chars.extend_from_slice(&atom_matched_chars); - } + matched_chars.sort_unstable(); + matched_chars.dedup(); - if all_matched && !atoms.is_empty() { - matched_chars.sort_unstable(); - matched_chars.dedup(); + let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY; + let filename_bonus = get_filename_match_bonus(&candidate_buf, &query.pattern, matcher); + let positive = (score as f64 + filename_bonus) * case_penalty(case_mismatches); + let adjusted_score = positive - length_penalty; + let positions = positions_from_sorted(&candidate_buf, &matched_chars); - let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY; - let filename_bonus = get_filename_match_bonus(&candidate_buf, atoms, matcher); - let positive = (total_score as f64 + filename_bonus) * case_penalty(case_mismatches); - let adjusted_score = positive - length_penalty; - let positions = positions_from_sorted(&candidate_buf, &matched_chars); - - results.push(PathMatch { - score: adjusted_score, - positions, - worktree_id, - path: if root_is_file { - Arc::clone(path_prefix) - } else { - candidate.path.into() - }, - path_prefix: if root_is_file { - RelPath::empty().into() - } else { - Arc::clone(path_prefix) - }, - is_dir: candidate.is_dir, - distance_to_relative_ancestor: relative_to - .as_ref() - .map_or(usize::MAX, |relative_to| { - distance_between_paths(candidate.path, relative_to.as_ref()) - }), - }); - } + results.push(PathMatch { + score: adjusted_score, + positions, + worktree_id, + path: if root_is_file { + Arc::clone(path_prefix) + } else { + candidate.path.into() + }, + path_prefix: if root_is_file { + RelPath::empty().into() + } else { + Arc::clone(path_prefix) + }, + is_dir: candidate.is_dir, + distance_to_relative_ancestor: relative_to.as_ref().map_or(usize::MAX, |relative_to| { + distance_between_paths(candidate.path, relative_to.as_ref()) + }), + }); } Ok(()) } @@ -296,14 +229,14 @@ pub fn match_fixed_path_set( max_results: usize, path_style: PathStyle, ) -> Vec { + let Some(query) = Query::build(query, case) else { + return Vec::new(); + }; + let mut config = nucleo::Config::DEFAULT; config.set_match_paths(); let mut matcher = matcher::get_matcher(config); - let atoms = make_atoms(query); - let source_words = make_source_words(query, case); - let query_bag = CharBag::from(query); - let root_is_file = worktree_root_name.is_some() && candidates.iter().all(|c| c.path.is_empty()); let path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into()); @@ -312,9 +245,7 @@ pub fn match_fixed_path_set( path_match_helper( &mut matcher, - &atoms, - source_words.as_deref(), - query_bag, + &query, candidates.into_iter(), &mut results, worktree_id, @@ -352,9 +283,9 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( query.to_owned() }; - let atoms = make_atoms(&query); - let source_words = make_source_words(&query, case); - let query_bag = CharBag::from(query.as_str()); + let Some(query) = Query::build(&query, case) else { + return Vec::new(); + }; let num_cpus = executor.num_cpus().min(path_count); let segment_size = path_count.div_ceil(num_cpus); @@ -371,8 +302,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( .zip(matchers.iter_mut()) .enumerate() { - let atoms = atoms.clone(); - let source_words = source_words.clone(); + let query = &query; let relative_to = relative_to.clone(); scope.spawn(async move { let segment_start = segment_idx * segment_size; @@ -389,9 +319,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( if path_match_helper( matcher, - &atoms, - source_words.as_deref(), - query_bag, + query, candidates, results, candidate_set.id(), diff --git a/crates/fuzzy_nucleo/src/strings.rs b/crates/fuzzy_nucleo/src/strings.rs index 4f3f02767a8..b72c7da205d 100644 --- a/crates/fuzzy_nucleo/src/strings.rs +++ b/crates/fuzzy_nucleo/src/strings.rs @@ -8,61 +8,14 @@ use std::{ use gpui::{BackgroundExecutor, SharedString}; use nucleo::Utf32Str; -use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; use crate::{ - Cancelled, Case, LengthPenalty, + Cancelled, Case, LengthPenalty, Query, case_penalty, count_case_mismatches, matcher::{self, LENGTH_PENALTY}, positions_from_sorted, }; use fuzzy::CharBag; -// String matching is always case-insensitive at the nucleo level — using -// `CaseMatching::Smart` there would reject queries whose capitalization -// doesn't match the candidate, breaking pickers like the command palette -// (`"Editor: Backspace"` against the action named `"editor: backspace"`). -// `Case::Smart` is still honored as a *scoring hint*: when the query -// contains uppercase, candidates whose matched characters disagree in case -// are downranked rather than dropped. -const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9; - -struct Query { - atoms: Vec, - source_words: Option>>, - char_bag: CharBag, -} - -impl Query { - fn build(query: &str, case: Case) -> Option { - let mut atoms = Vec::new(); - let mut source_words = Vec::new(); - let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase()); - - for word in query.split_whitespace() { - atoms.push(Atom::new( - word, - CaseMatching::Ignore, - Normalization::Smart, - AtomKind::Fuzzy, - false, - )); - if wants_case_penalty { - source_words.push(word.chars().collect()); - } - } - - if atoms.is_empty() { - return None; - } - - Some(Query { - atoms, - source_words: wants_case_penalty.then_some(source_words), - char_bag: CharBag::from(query), - }) - } -} - #[derive(Clone, Debug)] pub struct StringMatchCandidate { pub id: usize, @@ -281,7 +234,6 @@ where { let mut buf = Vec::new(); let mut matched_chars: Vec = Vec::new(); - let mut atom_matched_chars = Vec::new(); let mut candidate_chars: Vec = Vec::new(); for candidate in candidates { @@ -297,69 +249,37 @@ where continue; } - let haystack: Utf32Str = Utf32Str::new(&borrowed.string, &mut buf); + let haystack: Utf32Str = Utf32Str::new(borrowed.string.as_ref(), &mut buf); - if query.source_words.is_some() { - candidate_chars.clear(); - candidate_chars.extend(borrowed.string.chars()); - } + let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else { + continue; + }; - let mut total_score: u32 = 0; - let mut case_mismatches: u32 = 0; - let mut all_matched = true; + let case_mismatches = count_case_mismatches( + query.query_chars.as_deref(), + &matched_chars, + borrowed.string.as_ref(), + &mut candidate_chars, + ); - for (atom_idx, atom) in query.atoms.iter().enumerate() { - atom_matched_chars.clear(); - let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else { - all_matched = false; - break; - }; - total_score = total_score.saturating_add(score as u32); - if let Some(source_words) = query.source_words.as_deref() { - let query_chars = &source_words[atom_idx]; - if query_chars.len() == atom_matched_chars.len() { - for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) { - if let Some(&candidate_char) = candidate_chars.get(pos as usize) - && candidate_char != query_char - && candidate_char.eq_ignore_ascii_case(&query_char) - { - case_mismatches += 1; - } - } - } - } - matched_chars.extend_from_slice(&atom_matched_chars); - } + matched_chars.sort_unstable(); + matched_chars.dedup(); - if all_matched { - matched_chars.sort_unstable(); - matched_chars.dedup(); + let positive = score as f64 * case_penalty(case_mismatches); + let adjusted_score = + positive - length_penalty_for(borrowed.string.as_ref(), length_penalty); + let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars); - let positive = total_score as f64 * case_penalty(case_mismatches); - let adjusted_score = - positive - length_penalty_for(borrowed.string.as_ref(), length_penalty); - let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars); - - results.push(StringMatch { - candidate_id: borrowed.id, - score: adjusted_score, - positions, - string: borrowed.string.clone(), - }); - } + results.push(StringMatch { + candidate_id: borrowed.id, + score: adjusted_score, + positions, + string: borrowed.string.clone(), + }); } Ok(()) } -#[inline] -fn case_penalty(mismatches: u32) -> f64 { - if mismatches == 0 { - 1.0 - } else { - SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32) - } -} - #[inline] fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 { if length_penalty.is_on() { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 90ac06d959a..a0fa3c8a95b 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -721,6 +721,15 @@ pub struct SearchCommitArgs { pub case_sensitive: bool, } +pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str { + match (is_remote_tracking_ref, force) { + (true, true) => "-Dr", + (true, false) => "-dr", + (false, true) => "-D", + (false, false) => "-d", + } +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -775,7 +784,12 @@ pub trait GitRepository: Send + Sync { -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; - fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>; + fn delete_branch( + &self, + is_remote: bool, + name: String, + force: bool, + ) -> BoxFuture<'_, Result<()>>; fn worktrees(&self) -> BoxFuture<'_, Result>>; @@ -2033,14 +2047,18 @@ impl GitRepository for RealGitRepository { .boxed() } - fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { + fn delete_branch( + &self, + is_remote: bool, + name: String, + force: bool, + ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - git_binary? - .run(&["branch", if is_remote { "-dr" } else { "-d" }, &name]) - .await?; + let flag = delete_branch_flag(is_remote, force); + git_binary?.run(&["branch", flag, &name]).await?; anyhow::Ok(()) }) .boxed() diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 73ad9293e17..ac9a01deb9f 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -640,7 +640,7 @@ impl GraphData { let commit_lane = self .parent_to_lanes .get(&commit.sha) - .and_then(|lanes| lanes.first().copied()); + .and_then(|lanes| lanes.iter().min().copied()); let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx()); @@ -4049,6 +4049,74 @@ mod tests { Ok(()) } + fn verify_keep_shared_parents_on_leftmost_lane(graph: &GraphData) -> Result<()> { + let mut active_lane_parents: Vec> = Vec::new(); + let mut parent_to_lanes: HashMap> = HashMap::default(); + + for (row, entry) in graph.commits.iter().enumerate() { + let pending_lanes = parent_to_lanes.remove(&entry.data.sha).unwrap_or_default(); + + if pending_lanes.len() > 1 + && let Some(expected_lane) = pending_lanes.iter().copied().min() + && entry.lane != expected_lane + { + bail!( + "commit {:?} at row {} uses lane {}, but shared parent should use leftmost pending lane {} from {:?}", + entry.data.sha, + row, + entry.lane, + expected_lane, + pending_lanes + ); + } + + for lane in pending_lanes { + let Some(active_lane_parent) = active_lane_parents.get_mut(lane) else { + bail!( + "commit {:?} at row {} was pending on missing lane {}", + entry.data.sha, + row, + lane + ); + }; + + if *active_lane_parent != Some(entry.data.sha) { + bail!( + "commit {:?} at row {} was pending on lane {}, but that lane points to {:?}", + entry.data.sha, + row, + lane, + active_lane_parent + ); + } + + *active_lane_parent = None; + } + + for (parent_index, parent) in entry.data.parents.iter().enumerate() { + let lane = if parent_index == 0 { + entry.lane + } else if let Some(empty_lane) = + active_lane_parents.iter().position(Option::is_none) + { + empty_lane + } else { + active_lane_parents.push(None); + active_lane_parents.len() - 1 + }; + + if lane >= active_lane_parents.len() { + active_lane_parents.resize(lane + 1, None); + } + + active_lane_parents[lane] = Some(*parent); + parent_to_lanes.entry(*parent).or_default().push(lane); + } + } + + Ok(()) + } + fn verify_coverage(graph: &GraphData) -> Result<()> { let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default(); for entry in &graph.commits { @@ -4197,6 +4265,8 @@ mod tests { verify_column_correctness(graph, &oid_to_row).context("column correctness")?; verify_segment_continuity(graph).context("segment continuity")?; verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?; + verify_keep_shared_parents_on_leftmost_lane(graph) + .context("keep shared parents on leftmost lane")?; verify_coverage(graph).context("coverage")?; verify_line_overlaps(graph).context("line overlaps")?; Ok(()) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 69829231619..839997cc588 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -3,12 +3,12 @@ use editor::Editor; use fuzzy_nucleo::StringMatchCandidate; use collections::HashSet; -use git::repository::Branch; +use git::repository::{Branch, delete_branch_flag}; use gpui::http_client::Url; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, - SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, + InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel, + Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::{Repository, RepositoryEvent}; @@ -29,6 +29,8 @@ actions!( [ /// Deletes the selected git branch or remote. DeleteBranch, + /// Force deletes the selected git branch or remote. + ForceDeleteBranch, /// Filter the list of remotes FilterRemotes ] @@ -254,8 +256,10 @@ impl BranchList { _: &mut Window, cx: &mut Context, ) { - self.picker - .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) + self.picker.update(cx, |picker, cx| { + picker.delegate.modifiers = ev.modifiers; + cx.notify(); + }) } pub fn handle_delete( @@ -267,7 +271,20 @@ impl BranchList { self.picker.update(cx, |picker, cx| { picker .delegate - .delete_at(picker.delegate.selected_index, window, cx) + .delete_at(picker.delegate.selected_index, false, window, cx) + }) + } + + pub fn handle_force_delete( + &mut self, + _: &branch_picker::ForceDeleteBranch, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker + .delegate + .delete_at(picker.delegate.selected_index, true, window, cx) }) } @@ -301,6 +318,7 @@ impl Render for BranchList { .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_action(cx.listener(Self::handle_delete)) + .on_action(cx.listener(Self::handle_force_delete)) .on_action(cx.listener(Self::handle_filter)) .child(self.picker.clone()) .when(!self.embedded, |this| { @@ -393,6 +411,7 @@ pub struct BranchListDelegate { focus_handle: FocusHandle, restore_selected_branch: Option, show_footer: bool, + hovered_delete_index: Option, } #[derive(Debug)] @@ -407,6 +426,77 @@ enum PickerState { NewBranch, } +fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String { + format!( + "branch {} {branch_name}", + delete_branch_flag(is_remote, force) + ) +} + +// Git only reports "not fully merged" via localized stderr, so this +// best-effort check may miss some locales and fall back to the raw error toast. +fn is_unmerged_branch_delete_error(error: &anyhow::Error) -> bool { + error + .to_string() + .to_lowercase() + .contains("not fully merged") +} + +struct DeleteBranchTooltip { + picker: WeakEntity>, + focus_handle: FocusHandle, + delete_index: usize, + _subscription: Subscription, +} + +impl DeleteBranchTooltip { + fn new( + picker: Entity>, + focus_handle: FocusHandle, + delete_index: usize, + cx: &mut Context, + ) -> Self { + let subscription = cx.observe(&picker, |_, _, cx| cx.notify()); + Self { + picker: picker.downgrade(), + focus_handle, + delete_index, + _subscription: subscription, + } + } +} + +impl Render for DeleteBranchTooltip { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let force_delete = self + .picker + .read_with(cx, |picker, _| { + picker + .delegate + .is_force_delete_hovering_index(self.delete_index) + }) + .unwrap_or(false); + if force_delete { + Tooltip::for_action_in( + "Force Delete Branch", + &branch_picker::ForceDeleteBranch, + &self.focus_handle, + cx, + ) + .into_any_element() + } else { + Tooltip::with_meta_in( + "Delete Branch", + Some(&branch_picker::DeleteBranch), + "Hold alt to force delete", + &self.focus_handle, + cx, + ) + .into_any_element() + } + } +} + fn process_branches(branches: &Arc<[Branch]>) -> Vec { let remote_upstreams: HashSet<_> = branches .iter() @@ -460,9 +550,14 @@ impl BranchListDelegate { focus_handle: cx.focus_handle(), restore_selected_branch: None, show_footer: false, + hovered_delete_index: None, } } + fn is_force_delete_hovering_index(&self, index: usize) -> bool { + self.modifiers.alt && self.hovered_delete_index == Some(index) + } + fn create_branch( &self, from_branch: Option, @@ -509,7 +604,13 @@ impl BranchListDelegate { cx.emit(DismissEvent); } - fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + fn delete_at( + &self, + idx: usize, + force: bool, + window: &mut Window, + cx: &mut Context>, + ) { let Some(entry) = self.matches.get(idx).cloned() else { return; }; @@ -520,49 +621,75 @@ impl BranchListDelegate { let workspace = self.workspace.clone(); cx.spawn_in(window, async move |picker, cx| { - let is_remote; - let result = match &entry { - Entry::Branch { branch, .. } => { - if branch.is_head { - return Ok(()); + let Entry::Branch { branch, .. } = &entry else { + log::error!("Failed to delete entry: wrong entry to delete"); + return Ok(()); + }; + + if branch.is_head { + return Ok(()); + } + + let is_remote = branch.is_remote(); + let branch_name = branch.name().to_string(); + let initial_result = repo + .update(cx, |repo, _| { + repo.delete_branch(is_remote, branch_name.clone(), force) + }) + .await?; + + let (result, attempted_force) = match initial_result { + Ok(()) => (Ok(()), force), + Err(error) => { + if is_remote { + log::error!("Failed to delete remote branch: {error}"); + } else { + log::error!("Failed to delete branch: {error}"); } - is_remote = branch.is_remote(); - repo.update(cx, |repo, _| { - repo.delete_branch(is_remote, branch.name().to_string()) - }) - .await? - } - _ => { - log::error!("Failed to delete entry: wrong entry to delete"); - return Ok(()); + if force || !is_unmerged_branch_delete_error(&error) { + (Err(error), force) + } else { + let answer = cx.update(|window, cx| { + window.prompt( + PromptLevel::Warning, + &format!( + "Branch \"{}\" is not fully merged. Force delete it?", + entry.name() + ), + None, + &["Force Delete", "Cancel"], + cx, + ) + })?; + + if answer.await != Ok(0) { + return Ok(()); + } + + let retry = repo + .update(cx, |repo, _| { + repo.delete_branch(is_remote, branch_name, true) + }) + .await?; + + if let Err(error) = &retry { + log::error!("Failed to force delete branch: {error}"); + } + (retry, true) + } } }; - if let Err(e) = result { - if is_remote { - log::error!("Failed to delete remote branch: {}", e); - } else { - log::error!("Failed to delete branch: {}", e); - } - + if let Err(error) = result { if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { - if is_remote { - show_error_toast( - workspace, - format!("branch -dr {}", entry.name()), - e, - cx, - ) - } else { - show_error_toast( - workspace, - format!("branch -d {}", entry.name()), - e, - cx, - ) - } + show_error_toast( + workspace, + delete_branch_command(is_remote, entry.name(), attempted_force), + error, + cx, + ) })?; } @@ -585,6 +712,8 @@ impl BranchListDelegate { picker.delegate.selected_index = picker.delegate.matches.len() - 1; } + picker.delegate.hovered_delete_index = None; + cx.notify(); })?; @@ -980,6 +1109,7 @@ impl PickerDelegate for BranchListDelegate { }; let focus_handle = self.focus_handle.clone(); + let picker = cx.entity(); let is_new_items = matches!( entry, Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } @@ -988,19 +1118,44 @@ impl PickerDelegate for BranchListDelegate { let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head); let deleted_branch_icon = |entry_ix: usize| { - IconButton::new(("delete", entry_ix), IconName::Trash) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Delete Branch", - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.delete_at(entry_ix, window, cx); + let picker = picker.clone(); + let focus_handle = focus_handle.clone(); + let force_delete = self.is_force_delete_hovering_index(entry_ix); + + div() + .id(("delete-hover", entry_ix)) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered { + this.delegate.hovered_delete_index = Some(entry_ix); + } else if this.delegate.hovered_delete_index == Some(entry_ix) { + this.delegate.hovered_delete_index = None; + } + cx.notify(); })) + .child( + IconButton::new(("delete", entry_ix), IconName::Trash) + .icon_size(IconSize::Small) + .when(force_delete, |this| this.icon_color(Color::Error)) + .tooltip(move |_, cx| { + cx.new(|cx| { + DeleteBranchTooltip::new( + picker.clone(), + focus_handle.clone(), + entry_ix, + cx, + ) + }) + .into() + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.delete_at( + entry_ix, + this.delegate.modifiers.alt, + window, + cx, + ); + })), + ) }; let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { @@ -1480,9 +1635,9 @@ mod tests { (branch_list, cx) } - async fn init_fake_repository( + async fn init_fake_repository_with_fs( cx: &mut TestAppContext, - ) -> (Entity, Entity) { + ) -> (Arc, Entity, Entity) { let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), @@ -1505,7 +1660,14 @@ mod tests { let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let repository = cx.read(|cx| project.read(cx).active_repository(cx)); - (project, repository.unwrap()) + (fs, project, repository.unwrap()) + } + + async fn init_fake_repository( + cx: &mut TestAppContext, + ) -> (Entity, Entity) { + let (_, project, repository) = init_fake_repository_with_fs(cx).await; + (project, repository) } #[gpui::test] @@ -1597,7 +1759,7 @@ mod tests { branch_list.picker.update(cx, |picker, cx| { assert_eq!(picker.delegate.matches.len(), 4); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); - picker.delegate.delete_at(1, window, cx); + picker.delegate.delete_at(1, false, window, cx); branch_to_delete }) }); @@ -1641,6 +1803,238 @@ mod tests { }); } + #[gpui::test] + async fn test_delete_unmerged_branch_prompts_for_force_delete(cx: &mut TestAppContext) { + init_test(cx); + let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; + + let branches = create_test_branches(); + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let branch_to_delete = "feature-auth"; + fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { + state + .branches_requiring_force_delete + .insert(branch_to_delete.to_string()); + }) + .expect("failed to mark test branch as requiring force delete"); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let branch_index = picker + .delegate + .matches + .iter() + .position(|entry| entry.name() == branch_to_delete) + .unwrap(); + picker.delegate.delete_at(branch_index, false, window, cx); + }) + }); + cx.run_until_parked(); + assert!(cx.has_pending_prompt()); + + cx.simulate_prompt_answer("Force Delete"); + cx.run_until_parked(); + + let repo_branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + assert!( + repo_branches + .iter() + .all(|branch| branch.name() != branch_to_delete) + ); + } + + #[gpui::test] + async fn test_delete_unmerged_branch_cancel_keeps_branch(cx: &mut TestAppContext) { + init_test(cx); + let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; + + let branches = create_test_branches(); + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let branch_to_delete = "feature-auth"; + fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { + state + .branches_requiring_force_delete + .insert(branch_to_delete.to_string()); + }) + .expect("failed to mark test branch as requiring force delete"); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + let initial_match_count = branch_list.update(cx, |branch_list, cx| { + branch_list + .picker + .update(cx, |picker, _| picker.delegate.matches.len()) + }); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let branch_index = picker + .delegate + .matches + .iter() + .position(|entry| entry.name() == branch_to_delete) + .unwrap(); + picker.delegate.delete_at(branch_index, false, window, cx); + }) + }); + cx.run_until_parked(); + assert!(cx.has_pending_prompt()); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + assert!(!cx.has_pending_prompt()); + + let repo_branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + assert!( + repo_branches + .iter() + .any(|branch| branch.name() == branch_to_delete), + "branch should still exist after cancelling the force-delete prompt" + ); + + let final_match_count = branch_list.update(cx, |branch_list, cx| { + branch_list + .picker + .update(cx, |picker, _| picker.delegate.matches.len()) + }); + assert_eq!( + initial_match_count, final_match_count, + "picker matches should be unchanged after cancel" + ); + } + + #[gpui::test] + async fn test_force_delete_click_deletes_branch_without_prompt(cx: &mut TestAppContext) { + init_test(cx); + let (fs, _project, repository) = init_fake_repository_with_fs(cx).await; + + let branches = create_test_branches(); + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let branch_to_delete = "feature-auth"; + fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| { + state + .branches_requiring_force_delete + .insert(branch_to_delete.to_string()); + }) + .expect("failed to mark test branch as requiring force delete"); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.modifiers = Modifiers::alt(); + let branch_index = picker + .delegate + .matches + .iter() + .position(|entry| entry.name() == branch_to_delete) + .unwrap(); + picker.delegate.delete_at(branch_index, true, window, cx); + }) + }); + cx.run_until_parked(); + assert!(!cx.has_pending_prompt()); + + let repo_branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + assert!( + repo_branches + .iter() + .all(|branch| branch.name() != branch_to_delete) + ); + } + #[gpui::test] async fn test_delete_remote_branch(cx: &mut TestAppContext) { init_test(cx); @@ -1683,7 +2077,7 @@ mod tests { branch_list.picker.update(cx, |picker, cx| { assert_eq!(picker.delegate.matches.len(), 4); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); - picker.delegate.delete_at(1, window, cx); + picker.delegate.delete_at(1, false, window, cx); branch_to_delete }) }); diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 921bcd19608..7532a971ee0 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -469,11 +469,7 @@ impl CommitModal { if can_commit { Tooltip::with_meta_in( tooltip, - Some(if is_amend_pending { - &git::Amend - } else { - &git::Commit - }), + Some(&git::Commit), format!( "git commit{}{}", if is_amend_pending { " --amend" } else { "" }, @@ -506,10 +502,16 @@ impl CommitModal { } fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.git_panel.update(cx, |git_panel, cx| { + let is_amend = self.git_panel.read(cx).amend_pending(); + let did_execute = self.git_panel.update(cx, |git_panel, cx| { git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx) - }) { - telemetry::event!("Git Committed", source = "Git Modal"); + }); + if did_execute { + if is_amend { + telemetry::event!("Git Amended", source = "Git Modal"); + } else { + telemetry::event!("Git Committed", source = "Git Modal"); + } cx.emit(DismissEvent); } } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 19dea7adafe..c39c175e32c 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -201,7 +201,21 @@ impl CommitView { .is_some_and(|view| view.read(cx).commit.sha == commit_sha) }); if let Some(ix) = ix { - pane.activate_item(ix, true, true, window, cx); + let existing = pane + .items() + .filter_map(|item| item.downcast::()) + .find(|view| view.read(cx).commit.sha == commit_sha) + .unwrap(); + + pane.remove_item(existing.item_id(), false, false, window, cx); + pane.add_item( + Box::new(commit_view), + true, + true, + Some(ix), + window, + cx, + ); } else { pane.add_item(Box::new(commit_view), true, true, None, window, cx); } diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index d5c5fe02bfe..70e10168adf 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -18,7 +18,7 @@ use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; -use workspace::{StatusItemView, Workspace, item::ItemHandle}; +use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, }; @@ -678,4 +678,13 @@ impl StatusItemView for MergeConflictIndicator { _: &mut Context, ) { } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .agent + .get_or_insert_default() + .show_merge_conflict_indicator = Some(false); + })) + } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 820c880a1bd..7b8898b9ab8 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -31,7 +31,7 @@ use git::repository::{ }; use git::stash::GitStash; use git::status::{DiffStat, StageStatus}; -use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; +use git::{Amend, Commit, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll, @@ -39,8 +39,8 @@ use git::{ use gpui::{ AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, - Point, PromptLevel, ScrollStrategy, Subscription, Task, TextStyle, UniformListScrollHandle, - WeakEntity, actions, anchored, deferred, point, size, uniform_list, + Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt, TextStyle, + UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, size, uniform_list, }; use itertools::Itertools; use language::{Buffer, File}; @@ -120,6 +120,14 @@ actions!( ] ); +actions!( + dev, + [ + /// Shows the current git job queue debug state for the active repository. + ShowGitJobQueue, + ] +); + actions!( git_graph, [ @@ -259,6 +267,13 @@ pub fn register(workspace: &mut Workspace) { panel.update(cx, |panel, cx| panel.git_init(window, cx)); } }); + workspace.register_action(|workspace, _: &ShowGitJobQueue, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.show_git_job_queue(window, cx); + }); + } + }); } #[derive(Debug, Clone)] @@ -2110,13 +2125,19 @@ impl GitPanel { } } - fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context) { + let is_amend = self.amend_pending; if self.commit(&self.commit_editor.focus_handle(cx), window, cx) { - telemetry::event!("Git Committed", source = "Git Panel"); + if is_amend { + telemetry::event!("Git Amended", source = "Git Panel"); + } else { + telemetry::event!("Git Committed", source = "Git Panel"); + } } } /// Commits staged changes with the current commit message. + /// When `amend_pending` is true, performs an amend commit instead. /// /// Returns `true` if the commit was executed, `false` otherwise. pub(crate) fn commit( @@ -2125,14 +2146,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> bool { - if self.amend_pending { - return false; - } - if commit_editor_focus_handle.contains_focused(window, cx) { self.commit_changes( CommitOptions { - amend: false, + amend: self.amend_pending, signoff: self.signoff_enabled, allow_empty: false, }, @@ -2146,17 +2163,16 @@ impl GitPanel { } } - fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + fn on_amend(&mut self, _: &Amend, window: &mut Window, cx: &mut Context) { if self.amend(&self.commit_editor.focus_handle(cx), window, cx) { telemetry::event!("Git Amended", source = "Git Panel"); } } - /// Amends the most recent commit with staged changes and/or an updated commit message. - /// - /// Uses a two-stage workflow where the first invocation loads the commit - /// message for editing, second invocation performs the amend. Returns - /// `true` if the amend was executed, `false` otherwise. + /// Enters the amend state on first invocation, loading the last commit + /// message for editing. On second invocation, performs the amend commit + /// by delegating to [`Self::commit`]. Returns `true` if a commit was + /// executed. pub(crate) fn amend( &mut self, commit_editor_focus_handle: &FocusHandle, @@ -2166,28 +2182,15 @@ impl GitPanel { if commit_editor_focus_handle.contains_focused(window, cx) { if self.head_commit(cx).is_some() { if !self.amend_pending { - self.set_amend_pending(true, cx); - self.load_last_commit_message(cx); - - return false; + self.toggle_amend_pending(cx); } else { - self.commit_changes( - CommitOptions { - amend: true, - signoff: self.signoff_enabled, - allow_empty: false, - }, - window, - cx, - ); - - return true; + return self.commit(commit_editor_focus_handle, window, cx); } } - return false; + false } else { cx.propagate(); - return false; + false } } pub fn head_commit(&self, cx: &App) -> Option { @@ -2244,7 +2247,7 @@ impl GitPanel { let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)); let wrapped_message = editor.update(cx, |editor, cx| { editor.select_all(&Default::default(), window, cx); - editor.rewrap_impl( + editor.rewrap( RewrapOptions { override_language_settings: false, preserve_existing_whitespace: true, @@ -3880,6 +3883,74 @@ impl GitPanel { show_error_toast(workspace, action, e, cx) } + fn show_git_job_queue(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repo) = self.active_repository.as_ref() else { + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + struct GitJobQueueToast; + workspace.show_toast( + workspace::Toast::new( + NotificationId::unique::(), + "No active repository", + ) + .autohide(), + cx, + ); + }); + } + }); + return; + }; + + let repo_path = repo.read(cx).work_directory_abs_path.display().to_string(); + let text = repo.read(cx).job_debug_queue().to_debug_string(); + let title = format!("Git Job Queue: {repo_path}"); + + let json_language = self.project.read(cx).languages().language_for_name("JSON"); + let project = self.project.clone(); + let workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| { + let json_language = json_language.await.ok(); + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(json_language, false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(text, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = + cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(title); + editor.disable_mouse_wheel_zoom(); + editor + })), + None, + true, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn show_commit_message_error(weak_this: &WeakEntity, err: &E, cx: &mut AsyncApp) where E: std::fmt::Debug + std::fmt::Display, @@ -4657,7 +4728,7 @@ impl GitPanel { if can_commit { Tooltip::with_meta_in( tooltip, - Some(if amend { &git::Amend } else { &git::Commit }), + Some(&git::Commit), format!( "git commit{}{}", if amend { " --amend" } else { "" }, @@ -6143,6 +6214,12 @@ impl Panel for GitPanel { fn activation_priority(&self) -> u32 { 3 } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.git_panel.get_or_insert_default().button = Some(false); + })) + } } impl PanelHeader for GitPanel {} diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index a1f55ce9fad..02299e5f5e6 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -12,7 +12,7 @@ use ui::{ }; use workspace::{ModalView, Workspace, pane}; -use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes}; +use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); @@ -295,6 +295,19 @@ impl GitPicker { } } + fn handle_force_delete_branch( + &mut self, + _: &ForceDeleteBranch, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(branch_list) = &self.branch_list { + branch_list.update(cx, |list, cx| { + list.handle_force_delete(&ForceDeleteBranch, window, cx); + }); + } + } + fn handle_filter_remotes( &mut self, _: &FilterRemotes, @@ -407,6 +420,7 @@ impl Render for GitPicker { .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .when(self.tab == GitPickerTab::Branches, |el| { el.on_action(cx.listener(Self::handle_delete_branch)) + .on_action(cx.listener(Self::handle_force_delete_branch)) .on_action(cx.listener(Self::handle_filter_remotes)) }) .when(self.tab == GitPickerTab::Stash, |el| { diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index f4c2a441d45..4fda322cc89 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -17,7 +17,7 @@ use git::{ }; use gpui::{ App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - SharedString, Subscription, Task, Window, + SharedString, Subscription, Task, TaskExt, Window, }; use menu::{Cancel, Confirm}; use project::git_store::Repository; diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 6e6833f3cb4..190fca9fa51 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -4,7 +4,7 @@ use git::stash::StashEntry; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, - SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, + SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate}; use project::git_store::{Repository, RepositoryEvent}; diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 49a42438f45..8b22dfdd614 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -7,8 +7,8 @@ use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, - Window, actions, rems, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TaskExt, + WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::Project; diff --git a/crates/git_ui/src/worktree_service.rs b/crates/git_ui/src/worktree_service.rs index ba411cb0642..0ec34f3d915 100644 --- a/crates/git_ui/src/worktree_service.rs +++ b/crates/git_ui/src/worktree_service.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::anyhow; use collections::HashSet; use fs::Fs; -use gpui::{AsyncWindowContext, Entity, SharedString, WeakEntity}; +use gpui::{AsyncWindowContext, Entity, SharedString, TaskExt, WeakEntity}; use project::Project; use project::git_store::Repository; use project::project_settings::ProjectSettings; diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index 03bec51ac20..f1f3a61c977 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -8,7 +8,7 @@ use ui::{ Render, Tooltip, Window, div, }; use util::paths::FILE_ROW_COLUMN_DELIMITER; -use workspace::{StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; +use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; #[derive(Copy, Clone, Debug, Default, PartialOrd, PartialEq)] pub(crate) struct SelectionStats { @@ -290,6 +290,15 @@ impl StatusItemView for CursorPosition { cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .cursor_position_button = Some(false); + })) + } } #[derive(Clone, Copy, PartialEq, Eq, RegisterSetting)] diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index c7409687faf..d417c14e49b 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -71,12 +71,27 @@ struct StateInner { scroll_handler: Option>, scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, - pending_scroll: Option, + pending_scroll: Option, follow_state: FollowState, } +/// Deferred scroll adjustment applied after the scroll-top item has been remeasured. +/// +/// An absolute pending scroll preserves the same pixel offset into the item, which keeps +/// visible text stable while content is appended to or removed from that item. A +/// proportional pending scroll preserves the same fractional position within the item, +/// which is useful when the whole list is being resized and each item scales similarly. +#[derive(Clone)] +enum PendingScroll { + /// Preserve the same pixel offset into the item after it is remeasured. + Absolute { item_ix: usize, offset: Pixels }, + /// Preserve the same fractional offset into the item after it is remeasured. + Proportional(PendingScrollFraction), +} + /// Keeps track of a fractional scroll position within an item for restoration /// after remeasurement. +#[derive(Clone)] struct PendingScrollFraction { /// The index of the item to scroll within. item_ix: usize, @@ -84,6 +99,15 @@ struct PendingScrollFraction { fraction: f32, } +/// Determines how remeasurement preserves the scroll position when the scroll-top item +/// changes height. +enum ScrollAnchor { + /// Preserve the same pixel offset into the scroll-top item. + Absolute, + /// Preserve the same fractional position within the scroll-top item. + Proportional, +} + /// Controls whether the list automatically follows new content at the end. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FollowMode { @@ -271,6 +295,7 @@ struct ListItemSummary { unrendered_count: usize, height: Pixels, has_focus_handles: bool, + has_unknown_height: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -335,7 +360,7 @@ impl ListState { /// but the number and identity of items remains the same. pub fn remeasure(&self) { let count = self.item_count(); - self.remeasure_items(0..count); + self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional); } /// Mark items in `range` as needing remeasurement while preserving @@ -346,31 +371,47 @@ impl ListState { /// height may be different (e.g., streaming text, tool results /// loading), but the item itself still exists at the same index. pub fn remeasure_items(&self, range: Range) { + self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute); + } + + fn remeasure_items_with_scroll_anchor(&self, range: Range, scroll_anchor: ScrollAnchor) { let state = &mut *self.0.borrow_mut(); - // If the scroll-top item falls within the remeasured range, - // store a fractional offset so the layout can restore the - // proportional scroll position after the item is re-rendered - // at its new height. if let Some(scroll_top) = state.logical_scroll_top { if range.contains(&scroll_top.item_ix) { - let mut cursor = state.items.cursor::(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + state.pending_scroll = match scroll_anchor { + ScrollAnchor::Absolute => Some(PendingScroll::Absolute { + item_ix: scroll_top.item_ix, + offset: scroll_top.offset_in_item, + }), + ScrollAnchor::Proportional => { + // If the scroll-top item falls within the remeasured range, + // store a fractional offset so the layout can restore the + // proportional scroll position after the item is re-rendered + // at its new height. + let mut cursor = state.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); - if let Some(item) = cursor.item() { - if let Some(size) = item.size() { - let fraction = if size.height.0 > 0.0 { - (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) - } else { - 0.0 - }; + cursor + .item() + .and_then(|item| { + item.size().map(|size| { + let fraction = if size.height.0 > 0.0 { + (scroll_top.offset_in_item.0 / size.height.0) + .clamp(0.0, 1.0) + } else { + 0.0 + }; - state.pending_scroll = Some(PendingScrollFraction { - item_ix: scroll_top.item_ix, - fraction, - }); + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }) + }) + }) + .or_else(|| state.pending_scroll.clone()) } - } + }; } } @@ -399,6 +440,25 @@ impl ListState { self.0.borrow().items.summary().count } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable or the total content height is not yet known. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let bounds = state.last_layout_bounds?; + let summary = state.items.summary(); + if summary.has_unknown_height { + return None; + } + let padding = state.last_padding.unwrap_or_default(); + let content_height = summary.height + padding.top + padding.bottom; + let scroll_max = (content_height - bounds.size.height).max(px(0.)); + if scroll_max <= px(0.) { + return None; + } + let scroll_top = state.scroll_top(&state.logical_scroll_top()); + Some(scroll_top >= scroll_max) + } + /// Inform the list state that the items in `old_range` have been replaced /// by `count` new items that must be recalculated. pub fn splice(&self, old_range: Range, count: usize) { @@ -874,14 +934,26 @@ impl StateInner { size = Some(element_size); // If there's a pending scroll adjustment for the scroll-top - // item, apply it, ensuring proportional scroll position is - // maintained after re-measuring. + // item, apply it. if ix == 0 { if let Some(pending_scroll) = self.pending_scroll.take() { - if pending_scroll.item_ix == scroll_top.item_ix { - scroll_top.offset_in_item = - Pixels(pending_scroll.fraction * element_size.height.0); - self.logical_scroll_top = Some(scroll_top); + match pending_scroll { + PendingScroll::Absolute { item_ix, offset } + if item_ix == scroll_top.item_ix => + { + scroll_top.offset_in_item = offset.min(element_size.height); + self.logical_scroll_top = Some(scroll_top); + } + PendingScroll::Proportional(pending_scroll) + if pending_scroll.item_ix == scroll_top.item_ix => + { + // Ensuring proportional scroll position is + // maintained after re-measuring. + scroll_top.offset_in_item = + Pixels(pending_scroll.fraction * element_size.height.0); + self.logical_scroll_top = Some(scroll_top); + } + _ => {} } } } @@ -1385,6 +1457,7 @@ impl sum_tree::Item for ListItem { px(0.) }, has_focus_handles: focus_handle.is_some(), + has_unknown_height: size_hint.is_none(), }, ListItem::Measured { size, focus_handle, .. @@ -1394,6 +1467,7 @@ impl sum_tree::Item for ListItem { unrendered_count: 0, height: size.height, has_focus_handles: focus_handle.is_some(), + has_unknown_height: false, }, } } @@ -1410,6 +1484,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary { self.unrendered_count += summary.unrendered_count; self.height += summary.height; self.has_focus_handles |= summary.has_focus_handles; + self.has_unknown_height |= summary.has_unknown_height; } } @@ -1646,6 +1721,60 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + item_height.set(200); + state.remeasure_items(5..6); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(40.)); + } + #[gpui::test] fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index a7486f0c00a..0a3314573f0 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -8,7 +8,7 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, - StyleRefinement, Styled, Window, point, size, + StyleRefinement, Styled, Window, point, px, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; @@ -236,6 +236,18 @@ impl UniformListScrollHandle { } } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let max_offset = state.base_handle.max_offset(); + if max_offset.y <= px(0.) { + return None; + } + let offset = state.base_handle.offset(); + Some(-offset.y >= max_offset.y) + } + /// Scroll to the bottom of the list. pub fn scroll_to_bottom(&self) { self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index ab253472ad8..c1afce81073 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -4,12 +4,11 @@ use futures::prelude::*; use gpui_util::{TryFutureExt, TryFutureExtBacktrace}; use scheduler::Instant; use scheduler::Scheduler; -use std::{ - fmt::Debug, future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, - time::Duration, -}; +use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration}; -pub use scheduler::{FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority}; +pub use scheduler::{ + FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task, +}; /// A pointer to the executor that is currently running, /// for spawning background tasks. @@ -28,83 +27,32 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } -/// Task is a primitive that allows work to happen in the background. +/// Extension trait for `Task>` that adds `detach_and_log_err` with an `&App` context. /// -/// It implements [`Future`] so you can `.await` on it. -/// -/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows -/// the task to continue running, but with no way to return a value. -#[must_use] -#[derive(Debug)] -pub struct Task(scheduler::Task); - -impl Task { - /// Creates a new task that will resolve with the value. - pub fn ready(val: T) -> Self { - Task(scheduler::Task::ready(val)) - } - - /// Returns true if the task has completed or was created with `Task::ready`. - pub fn is_ready(&self) -> bool { - self.0.is_ready() - } - - /// Detaching a task runs it to completion in the background. - pub fn detach(self) { - self.0.detach() - } - - /// Wraps a scheduler::Task. - pub fn from_scheduler(task: scheduler::Task) -> Self { - Task(task) - } - - /// Converts this task into a fallible task that returns `Option`. - /// - /// Unlike the standard `Task`, a [`FallibleTask`] will return `None` - /// if the task was cancelled. - /// - /// # Example - /// - /// ```ignore - /// // Background task that gracefully handles cancellation: - /// cx.background_spawn(async move { - /// let result = foreground_task.fallible().await; - /// if let Some(value) = result { - /// // Process the value - /// } - /// // If None, task was cancelled - just exit gracefully - /// }).detach(); - /// ``` - pub fn fallible(self) -> FallibleTask { - self.0.fallible() - } +/// This trait is automatically implemented for all `Task>` types. +pub trait TaskExt { + /// Run the task to completion in the background and log any errors that occur. + fn detach_and_log_err(self, cx: &App); + /// Like [`Self::detach_and_log_err`], but uses `{:?}` formatting on failure so `anyhow::Error` + /// values emit their full backtrace. Prefer `detach_and_log_err` unless a backtrace is wanted. + fn detach_and_log_err_with_backtrace(self, cx: &App); } -impl Task> +impl TaskExt for Task> where T: 'static, - E: 'static + std::fmt::Display, + E: 'static + std::fmt::Display + std::fmt::Debug, { - /// Run the task to completion in the background and log any errors that occur. #[track_caller] - pub fn detach_and_log_err(self, cx: &App) { + fn detach_and_log_err(self, cx: &App) { let location = core::panic::Location::caller(); cx.foreground_executor() .spawn(self.log_tracked_err(*location)) .detach(); } -} -impl Task> -where - T: 'static, - E: 'static + std::fmt::Debug, -{ - /// Like [`Self::detach_and_log_err`], but uses `{:?}` formatting on failure so `anyhow::Error` - /// values emit their full backtrace. Prefer `detach_and_log_err` unless a backtrace is wanted. #[track_caller] - pub fn detach_and_log_err_with_backtrace(self, cx: &App) { + fn detach_and_log_err_with_backtrace(self, cx: &App) { let location = *core::panic::Location::caller(); cx.foreground_executor() .spawn(self.log_tracked_err_with_backtrace(location)) @@ -112,20 +60,6 @@ where } } -impl std::future::Future for Task { - type Output = T; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - // SAFETY: Task is a repr(transparent) wrapper around scheduler::Task, - // and we're just projecting the pin through to the inner task. - let inner = unsafe { self.map_unchecked_mut(|t| &mut t.0) }; - inner.poll(cx) - } -} - impl BackgroundExecutor { /// Creates a new BackgroundExecutor from the given PlatformDispatcher. pub fn new(dispatcher: Arc) -> Self { @@ -175,66 +109,12 @@ impl BackgroundExecutor { R: Send + 'static, { if priority == Priority::RealtimeAudio { - Task::from_scheduler(self.inner.spawn_realtime(future)) + self.inner.spawn_realtime(future) } else { - Task::from_scheduler(self.inner.spawn_with_priority(priority, future)) + self.inner.spawn_with_priority(priority, future) } } - /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. - /// - /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to - /// completion before the current task is resumed, even if the current task is slated for cancellation. - pub async fn await_on_background(&self, future: impl Future + Send) -> R - where - R: Send, - { - use crate::RunnableMeta; - use parking_lot::{Condvar, Mutex}; - - struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for NotifyOnDrop<'_> { - fn drop(&mut self) { - *self.0.1.lock() = true; - self.0.0.notify_all(); - } - } - - struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for WaitOnDrop<'_> { - fn drop(&mut self) { - let mut done = self.0.1.lock(); - if !*done { - self.0.0.wait(&mut done); - } - } - } - - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - let pair = &(Condvar::new(), Mutex::new(false)); - let _wait_guard = WaitOnDrop(pair); - - let (runnable, task) = unsafe { - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn_unchecked( - move |_| async { - let _notify_guard = NotifyOnDrop(pair); - future.await - }, - move |runnable| { - dispatcher.dispatch(runnable, Priority::default()); - }, - ) - }; - runnable.schedule(); - task.await - } - /// Scoped lets you start a number of tasks and waits /// for all of them to complete before returning. pub async fn scoped<'scope, F>(&self, scheduler: F) @@ -426,7 +306,7 @@ impl ForegroundExecutor { where R: 'static, { - Task::from_scheduler(self.inner.spawn(future.boxed_local())) + self.inner.spawn(future.boxed_local()) } /// Enqueues the given Task to run on the main thread with the given priority. @@ -440,7 +320,7 @@ impl ForegroundExecutor { R: 'static, { // Priority is ignored for foreground tasks - they run in order on the main thread - Task::from_scheduler(self.inner.spawn(future)) + self.inner.spawn(future) } /// Used by the test harness to run an async test in a synchronous fashion. diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index a468315e6c6..4407d5e3b47 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -334,22 +334,22 @@ pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] -pub struct DisplayId(pub(crate) u32); +pub struct DisplayId(pub(crate) u64); impl DisplayId { /// Create a new `DisplayId` from a raw platform display identifier. - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { Self(id) } } -impl From for DisplayId { - fn from(id: u32) -> Self { +impl From for DisplayId { + fn from(id: u64) -> Self { Self(id) } } -impl From for u32 { +impl From for u64 { fn from(id: DisplayId) -> Self { id.0 } diff --git a/crates/gpui/src/prelude.rs b/crates/gpui/src/prelude.rs index 191d0a0e6d4..b5185a25e86 100644 --- a/crates/gpui/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -5,5 +5,5 @@ pub use crate::{ AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, - VisualContext, util::FluentBuilder, + TaskExt as _, VisualContext, util::FluentBuilder, }; diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dc387c67f39..659a34dec9b 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1402,6 +1402,11 @@ impl Window { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { + if request_frame_options.force_render { + // Bypass cached view reuse so we don't replay stale + // atlas tile references after a GPU device recovery. + window.refresh(); + } let arena_clear_needed = window.draw(cx); window.present(); arena_clear_needed.clear(); @@ -5759,7 +5764,7 @@ impl From> for ElementId { impl From<&'static str> for ElementId { fn from(name: &'static str) -> Self { - ElementId::Name(name.into()) + ElementId::Name(SharedString::new_static(name)) } } @@ -5771,13 +5776,13 @@ impl<'a> From<&'a FocusHandle> for ElementId { impl From<(&'static str, EntityId)> for ElementId { fn from((name, id): (&'static str, EntityId)) -> Self { - ElementId::NamedInteger(name.into(), id.as_u64()) + ElementId::NamedInteger(SharedString::new_static(name), id.as_u64()) } } impl From<(&'static str, usize)> for ElementId { fn from((name, id): (&'static str, usize)) -> Self { - ElementId::NamedInteger(name.into(), id as u64) + ElementId::NamedInteger(SharedString::new_static(name), id as u64) } } @@ -5789,7 +5794,7 @@ impl From<(SharedString, usize)> for ElementId { impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { - ElementId::NamedInteger(name.into(), id) + ElementId::NamedInteger(SharedString::new_static(name), id) } } @@ -5801,7 +5806,7 @@ impl From for ElementId { impl From<(&'static str, u32)> for ElementId { fn from((name, id): (&'static str, u32)) -> Self { - ElementId::NamedInteger(name.into(), id.into()) + ElementId::NamedInteger(SharedString::new_static(name), u64::from(id)) } } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index dab25fa1e2e..233e424331c 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -885,7 +885,7 @@ impl LinuxClient for WaylandClient { .outputs .iter() .find_map(|(object_id, output)| { - (object_id.protocol_id() == u32::from(id)).then(|| { + (object_id.protocol_id() as u64 == u64::from(id)).then(|| { Rc::new(WaylandDisplay { id: object_id.clone(), name: output.name.clone(), @@ -927,11 +927,11 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); let target_output = params.display_id.and_then(|display_id| { - let target_protocol_id: u32 = display_id.into(); + let target_protocol_id: u64 = display_id.into(); state .wl_outputs .iter() - .find(|(id, _)| id.protocol_id() == target_protocol_id) + .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) .map(|(_, output)| output.clone()) }); diff --git a/crates/gpui_linux/src/linux/wayland/display.rs b/crates/gpui_linux/src/linux/wayland/display.rs index 874cae87838..8fa9122d629 100644 --- a/crates/gpui_linux/src/linux/wayland/display.rs +++ b/crates/gpui_linux/src/linux/wayland/display.rs @@ -25,7 +25,7 @@ impl Hash for WaylandDisplay { impl PlatformDisplay for WaylandDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.id.protocol_id()) + DisplayId::new(self.id.protocol_id() as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index 857289a1d97..37d0f492d25 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -117,6 +117,7 @@ pub struct WaylandWindowState { active: bool, hovered: bool, pub(crate) force_render_after_recovery: bool, + renderer_presented: bool, in_progress_configure: Option, resize_throttle: bool, in_progress_window_controls: Option, @@ -392,6 +393,7 @@ impl WaylandWindowState { active: false, hovered: false, force_render_after_recovery: false, + renderer_presented: false, in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, @@ -1398,7 +1400,7 @@ impl PlatformWindow for WaylandWindow { return; } - state.renderer.draw(scene); + state.renderer_presented = state.renderer.draw(scene); if state.renderer.needs_redraw() { state.force_render_after_recovery = true; @@ -1406,8 +1408,15 @@ impl PlatformWindow for WaylandWindow { } fn completed_frame(&self) { - let state = self.borrow(); - state.surface.commit(); + let mut state = self.borrow_mut(); + + // Work around a bug in old versions of wlroots where committing without a buffer attached + // can cause invalid synchronization that leads to graphical corruption. + if !state.renderer_presented { + state.surface.commit(); + } + + state.renderer_presented = false; } fn sprite_atlas(&self) -> Arc { diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index f0d5e5657e5..64aeacc87c6 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1691,7 +1691,7 @@ impl LinuxClient for X11Client { X11Display::new( &state.xcb_connection, state.scale_factor, - u32::from(id) as usize, + u64::from(id) as usize, ) .ok()?, )) diff --git a/crates/gpui_linux/src/linux/x11/display.rs b/crates/gpui_linux/src/linux/x11/display.rs index 900c55e759a..582d76f7f60 100644 --- a/crates/gpui_linux/src/linux/x11/display.rs +++ b/crates/gpui_linux/src/linux/x11/display.rs @@ -38,7 +38,7 @@ impl X11Display { impl PlatformDisplay for X11Display { fn id(&self) -> DisplayId { - DisplayId::new(self.x_screen_index as u32) + DisplayId::new(self.x_screen_index as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index e8045b29c11..934e0a09d18 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -345,7 +345,7 @@ impl rwh::HasDisplayHandle for X11Window { }; let screen_id = { let state = self.0.state.borrow(); - u32::from(state.display.id()) as i32 + u64::from(state.display.id()) as i32 }; let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) @@ -431,7 +431,7 @@ impl X11WindowState { ) -> anyhow::Result { let x_screen_index = params .display_id - .map_or(x_main_screen_index, |did| u32::from(did) as usize); + .map_or(x_main_screen_index, |did| u64::from(did) as usize); let visual_set = find_visuals(xcb, x_screen_index); diff --git a/crates/gpui_macos/src/display.rs b/crates/gpui_macos/src/display.rs index b9338bff846..8e5db589359 100644 --- a/crates/gpui_macos/src/display.rs +++ b/crates/gpui_macos/src/display.rs @@ -73,7 +73,7 @@ unsafe extern "C" { impl PlatformDisplay for MacDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.0) + DisplayId::new(self.0 as u64) } fn uuid(&self) -> Result { diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index d25e1dc71c9..9dd55993934 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -278,9 +278,7 @@ impl WgpuContext { }; let backend_priority: u8 = match info.backend { - wgpu::Backend::Vulkan => 0, - wgpu::Backend::Metal => 0, - wgpu::Backend::Dx12 => 0, + wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, _ => 1, }; diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index da7e71c726b..08f30dc0090 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1079,13 +1079,13 @@ impl WgpuRenderer { self.max_texture_size } - pub fn draw(&mut self, scene: &Scene) { + pub fn draw(&mut self, scene: &Scene) -> bool { // Bail out early if the surface has been unconfigured (e.g. during // Android background/rotation transitions). Attempting to acquire // a texture from an unconfigured surface can block indefinitely on // some drivers (Adreno). if !self.surface_configured { - return; + return false; } let last_error = self.last_error.lock().unwrap().take(); @@ -1106,7 +1106,7 @@ impl WgpuRenderer { self.atlas.clear(); self.needs_redraw = true; self.failed_frame_count = 0; - return; + return false; } } else { self.failed_frame_count = 0; @@ -1124,7 +1124,7 @@ impl WgpuRenderer { resources .surface .configure(&resources.device, &surface_config); - return; + return false; } wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { let surface_config = self.surface_config.clone(); @@ -1132,15 +1132,15 @@ impl WgpuRenderer { resources .surface .configure(&resources.device, &surface_config); - return; + return false; } wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { - return; + return false; } wgpu::CurrentSurfaceTexture::Validation => { *self.last_error.lock().unwrap() = Some("Surface texture validation error".to_string()); - return; + return false; } }; @@ -1321,7 +1321,7 @@ impl WgpuRenderer { self.instance_buffer_capacity ); frame.present(); - return; + return true; } self.grow_instance_buffer(); continue; @@ -1331,7 +1331,7 @@ impl WgpuRenderer { .queue .submit(std::iter::once(encoder.finish())); frame.present(); - return; + return true; } } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index 60b9898cef3..2b2bf402d2b 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -13,10 +13,7 @@ use windows::{ Win32::{ Foundation::{LPARAM, WPARAM}, Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{ - GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_TIME_CRITICAL, - }, + System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, UI::WindowsAndMessaging::PostMessageW, }, }; @@ -163,12 +160,7 @@ impl PlatformDispatcher for WindowsDispatcher { // SAFETY: always safe to call let thread_handle = unsafe { GetCurrentThread() }; - // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } - .context("thread priority class") - .log_err(); - - // SAFETY: thread_handle is a valid handle to a thread + // SAFETY: thread_handle is a valid handle to the current thread unsafe { SetThreadPriority(thread_handle, THREAD_PRIORITY_TIME_CRITICAL) } .context("thread priority") .log_err(); diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index 1931a6949fd..3b81dc63a00 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -35,102 +35,18 @@ unsafe impl Sync for WindowsDisplay {} impl WindowsDisplay { pub(crate) fn new(display_id: DisplayId) -> Option { - let screen = available_monitors() - .into_iter() - .nth(u32::from(display_id) as _)?; - let info = get_monitor_info(screen).log_err()?; + let handle = HMONITOR(u64::from(display_id) as _); + let info = get_monitor_info(handle).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; + let scale_factor = get_scale_factor_for_monitor(handle).log_err()?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); Some(WindowsDisplay { - handle: screen, - display_id, - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { - let info = get_monitor_info(monitor)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let display_id = available_monitors() - .iter() - .position(|handle| handle.0 == monitor.0) - .unwrap(); - let scale_factor = get_scale_factor_for_monitor(monitor)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle: monitor, - display_id: DisplayId::new(display_id as _), - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { - let info = get_monitor_info(handle)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(handle)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { handle, display_id, scale_factor, @@ -158,6 +74,10 @@ impl WindowsDisplay { }) } + pub(crate) fn display_id_for_monitor(monitor: HMONITOR) -> DisplayId { + DisplayId::new(monitor.0 as u64) + } + pub fn primary_monitor() -> Option { // https://devblogs.microsoft.com/oldnewthing/20070809-00/?p=25643 const POINT_ZERO: POINT = POINT { x: 0, y: 0 }; @@ -169,7 +89,7 @@ impl WindowsDisplay { ); return None; } - WindowsDisplay::new_with_handle(monitor).log_err() + WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } /// Check if the center point of given bounds is inside this monitor @@ -183,7 +103,7 @@ impl WindowsDisplay { if monitor.is_invalid() { false } else { - let Ok(display) = WindowsDisplay::new_with_handle(monitor) else { + let Some(display) = WindowsDisplay::new(Self::display_id_for_monitor(monitor)) else { return false; }; display.uuid == self.uuid @@ -193,11 +113,11 @@ impl WindowsDisplay { pub fn displays() -> Vec> { available_monitors() .into_iter() - .enumerate() - .filter_map(|(id, handle)| { - Some(Rc::new( - WindowsDisplay::new_with_handle_and_id(handle, DisplayId::new(id as _)).ok()?, - ) as Rc) + .filter_map(|handle| { + Some( + Rc::new(WindowsDisplay::new(Self::display_id_for_monitor(handle))?) + as Rc, + ) }) .collect() } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 370582e83b5..77c4cde9788 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -143,9 +143,9 @@ impl WindowsWindowInner { // monitor is invalid, we do nothing. if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - self.state - .display - .set(WindowsDisplay::new_with_handle(monitor).log_err()?); + self.state.display.set(WindowsDisplay::new( + WindowsDisplay::display_id_for_monitor(monitor), + )?); } } if let Some(mut callback) = self.state.callbacks.moved.take() { @@ -853,7 +853,7 @@ impl WindowsWindowInner { log::error!("No monitor detected!"); return None; } - let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; + let new_display = WindowsDisplay::new(WindowsDisplay::display_id_for_monitor(new_monitor))?; self.state.display.set(new_display); Some(0) } @@ -1174,6 +1174,11 @@ impl WindowsWindowInner { { panic!("Device lost: {err}"); } + // Make sure the first `draw_window` after recovery (whether it comes + // from the forced WM_GPUI_FORCE_UPDATE_WINDOW or a stray WM_PAINT in + // between) is treated as a forced render so it both clears + // `skip_draws` and bypasses the view cache. + self.state.force_render_after_recovery.set(true); Some(0) } @@ -1198,6 +1203,7 @@ impl WindowsWindowInner { } } + let force_render = force_render || self.state.force_render_after_recovery.take(); if force_render { // Re-enable drawing after a device loss recovery. The forced render // will rebuild the scene with fresh atlas textures. diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 2fd7c3c6461..178d750024f 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -63,6 +63,12 @@ pub struct WindowsWindowState { pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, + /// Set after a GPU device-lost recovery so the next `draw_window` call is + /// treated as a forced render. This guarantees the next frame both + /// re-enables drawing (via `mark_drawable`) and bypasses the GPUI view + /// cache, which would otherwise replay stale atlas tile references from + /// the previous frame and panic in `DirectXAtlasState::texture`. + pub force_render_after_recovery: Cell, pub click_state: ClickState, pub current_cursor: Cell>, @@ -159,6 +165,7 @@ impl WindowsWindowState { last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), renderer: RefCell::new(renderer), + force_render_after_recovery: Cell::new(false), click_state, current_cursor: Cell::new(current_cursor), cursor_visible, @@ -465,11 +472,12 @@ impl WindowsWindow { let hinstance = get_module_handle(); let display = if let Some(display_id) = params.display_id { - // if we obtain a display_id, then this ID must be valid. - WindowsDisplay::new(display_id).unwrap() + WindowsDisplay::new(display_id) } else { - WindowsDisplay::primary_monitor().unwrap() - }; + None + } + .or_else(WindowsDisplay::primary_monitor) + .context("failed to find any monitor")?; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index 6eedb13ed1a..c819970051b 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -1,9 +1,9 @@ -use gpui::{Context, Entity, IntoElement, ParentElement, Render, Subscription, div}; +use gpui::{App, Context, Entity, IntoElement, ParentElement, Render, Subscription, div}; use project::image_store::{ImageFormat, ImageMetadata}; use settings::Settings; use ui::prelude::*; use util::size::format_file_size; -use workspace::{ItemHandle, StatusItemView, Workspace}; +use workspace::{HideStatusItem, ItemHandle, StatusItemView, Workspace}; use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings}; @@ -102,4 +102,9 @@ impl StatusItemView for ImageInfo { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + // The image info is only visible when an image viewer item is active. + None + } } diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index b687ea70a57..36eed3bc72c 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,5 +1,5 @@ use anyhow::{Context as _, anyhow}; -use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window}; +use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, TaskExt, Window}; use std::{cell::OnceCell, path::Path, sync::Arc}; use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height}; use util::{ResultExt as _, command::new_command}; diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index b8028c79b3d..713317b70db 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -1,7 +1,7 @@ use chrono::{Datelike, Local, NaiveTime, Timelike}; use editor::scroll::Autoscroll; use editor::{Editor, SelectionEffects}; -use gpui::{App, AppContext as _, Context, Window, actions}; +use gpui::{App, AppContext as _, Context, TaskExt, Window, actions}; pub use settings::HourFormat; use settings::{RegisterSetting, Settings}; use std::{ diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index ef495d462b9..cb19b5e6dbb 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -25,7 +25,10 @@ mod toolchain; #[cfg(test)] pub mod buffer_tests; -pub use crate::language_settings::{AutoIndentMode, EditPredictionsMode, IndentGuideSettings}; +pub use crate::language_settings::{ + AutoIndentMode, EditPredictionPromptFormat, EditPredictionsMode, IndentGuideSettings, + ZetaVersion, +}; use anyhow::{Context as _, Result}; use async_trait::async_trait; use collections::{HashMap, HashSet}; @@ -129,6 +132,12 @@ where .unwrap(); parser }); + // Tree-sitter auto-resets the parser at the end of a successful parse, + // but the cancellation paths (progress callback returning `Break`, + // cancelled balancing) leave outstanding state on the parser. The next + // call to `parse_with_options` would then *resume* that cancelled parse + // instead of starting fresh. + parser.reset(); parser.set_included_ranges(&[]).unwrap(); let result = func(&mut parser); PARSERS.lock().push(parser); @@ -617,8 +626,8 @@ pub trait LspInstaller { &self, _version: &Self::BinaryVersion, _container_dir: &PathBuf, - _delegate: &dyn LspAdapterDelegate, - ) -> impl Send + Future> { + _delegate: &Arc, + ) -> impl Send + Future> + use { async { None } } @@ -626,8 +635,8 @@ pub trait LspInstaller { &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> impl Send + Future>; + _delegate: &Arc, + ) -> impl Send + Future> + use; fn cached_server_binary( &self, @@ -680,11 +689,7 @@ where if let Some(binary) = cx .background_executor() - .await_on_background(self.check_if_version_installed( - &latest_version, - &container_dir, - delegate.as_ref(), - )) + .spawn(self.check_if_version_installed(&latest_version, &container_dir, &delegate)) .await { log::debug!("language server {:?} is already installed", name.0); @@ -695,11 +700,7 @@ where delegate.update_status(name.clone(), BinaryStatus::Downloading); let binary = cx .background_executor() - .await_on_background(self.fetch_server_binary( - latest_version, - container_dir, - delegate.as_ref(), - )) + .spawn(self.fetch_server_binary(latest_version, container_dir, delegate)) .await; delegate.update_status(name.clone(), BinaryStatus::None); @@ -1415,13 +1416,15 @@ impl LspInstaller for FakeLspAdapter { Some(self.language_server_binary.clone()) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - unreachable!(); + _: &Arc, + ) -> impl Send + Future> + use<> { + async { + unreachable!(); + } } async fn cached_server_binary( @@ -1677,6 +1680,82 @@ mod tests { ); } + #[test] + fn test_with_parser_resets_after_cancellation() { + use std::ops::ControlFlow; + use tree_sitter::{Language as TsLanguage, ParseOptions}; + + let rust_language: TsLanguage = tree_sitter_rust::LANGUAGE.into(); + + // Drain the shared pool so this test sees a deterministic LIFO order: + // the parser we push at the end of the first `with_parser` call is the + // one we pop at the start of the second call. + PARSERS.lock().clear(); + + // Large enough that tree-sitter invokes the progress callback before + // the parse completes; otherwise the cancellation never fires. + let large_input = format!("fn a() {{ {} }}", "b(c, d); e(f, g); ".repeat(5000)); + let small_input = "fn z() {}"; + + // Cancel a parse via the progress callback. Tree-sitter retains the + // in-progress parse state on the parser (its `canceled_balancing` flag + // and/or non-empty parse stack), and the next call to + // `parse_with_options` will *resume* that parse unless the parser is + // reset first. + let cancelled = with_parser(|parser| { + parser.set_language(&rust_language).unwrap(); + let bytes = large_input.as_bytes(); + let mut break_immediately = |_: &_| ControlFlow::Break(()); + parser.parse_with_options( + &mut |offset, _| { + if offset < bytes.len() { + &bytes[offset..] + } else { + &[] + } + }, + None, + Some(ParseOptions { + progress_callback: Some(&mut break_immediately), + }), + ) + }); + assert!( + cancelled.is_none(), + "first parse should be cancelled by the progress callback" + ); + + // Deliberately do NOT call `set_language` here: tree-sitter's + // `ts_parser_set_language` internally calls `ts_parser_reset`, which + // would mask the very bug we're checking for. Instead we rely on the + // language being preserved across `parser.reset()` (it is) and verify + // that `with_parser` itself produces a clean parser for the next user. + let tree = with_parser(|parser| { + let bytes = small_input.as_bytes(); + parser + .parse_with_options( + &mut |offset, _| { + if offset < bytes.len() { + &bytes[offset..] + } else { + &[] + } + }, + None, + None, + ) + .expect("parse of small_input should succeed") + }); + + assert_eq!(tree.root_node().byte_range(), 0..small_input.len()); + assert_eq!(tree.root_node().kind(), "source_file"); + assert!( + !tree.root_node().has_error(), + "tree should be error-free, got: {}", + tree.root_node().to_sexp() + ); + } + #[gpui::test(iterations = 10)] async fn test_language_loading(cx: &mut TestAppContext) { diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 701b363d9eb..3d90d8d06e6 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -17,7 +17,7 @@ use settings::{DocumentFoldingRanges, DocumentSymbols, IntoGpui, SemanticTokens} pub use settings::{ AutoIndentMode, CompletionSettingsContent, EditPredictionDataCollectionChoice, - EditPredictionPromptFormat, EditPredictionProvider, EditPredictionsMode, FormatOnSave, + EditPredictionPromptFormatContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LineEndingSetting, LspInsertMode, RewrapBehavior, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode, }; @@ -540,6 +540,46 @@ pub struct OpenAiCompatibleEditPredictionSettings { pub prompt_format: EditPredictionPromptFormat, } +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum EditPredictionPromptFormat { + #[default] + Infer, + Zeta(ZetaVersion), + CodeLlama, + StarCoder, + DeepseekCoder, + Qwen, + CodeGemma, + Codestral, + Glm, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ZetaVersion { + Zeta1, + Zeta2, + #[default] // NOTE: make latest version default when adding + Zeta2_1, +} + +impl From for EditPredictionPromptFormat { + fn from(value: EditPredictionPromptFormatContent) -> Self { + match value { + EditPredictionPromptFormatContent::Infer => Self::Infer, + EditPredictionPromptFormatContent::Zeta => Self::Zeta(ZetaVersion::Zeta1), + EditPredictionPromptFormatContent::Zeta2 => Self::Zeta(ZetaVersion::Zeta2), + EditPredictionPromptFormatContent::Zeta2_1 => Self::Zeta(ZetaVersion::Zeta2_1), + EditPredictionPromptFormatContent::CodeLlama => Self::CodeLlama, + EditPredictionPromptFormatContent::StarCoder => Self::StarCoder, + EditPredictionPromptFormatContent::DeepseekCoder => Self::DeepseekCoder, + EditPredictionPromptFormatContent::Qwen => Self::Qwen, + EditPredictionPromptFormatContent::CodeGemma => Self::CodeGemma, + EditPredictionPromptFormatContent::Codestral => Self::Codestral, + EditPredictionPromptFormatContent::Glm => Self::Glm, + } + } +} + impl AllLanguageSettings { /// Returns the [`LanguageSettings`] for the language with the specified name. pub fn language<'a>( @@ -816,7 +856,7 @@ impl settings::Settings for AllLanguageSettings { model: model.0, max_output_tokens: ollama.max_output_tokens.unwrap(), api_url: ollama.api_url.unwrap().into(), - prompt_format: ollama.prompt_format.unwrap(), + prompt_format: ollama.prompt_format.unwrap().into(), }); let openai_compatible_settings = edit_predictions.open_ai_compatible_api.unwrap(); let openai_compatible_settings = openai_compatible_settings @@ -831,7 +871,7 @@ impl settings::Settings for AllLanguageSettings { model, max_output_tokens: openai_compatible_settings.max_output_tokens.unwrap(), api_url: api_url.into(), - prompt_format: openai_compatible_settings.prompt_format.unwrap(), + prompt_format: openai_compatible_settings.prompt_format.unwrap().into(), }); let mut file_types: FxHashMap, (GlobSet, Vec)> = FxHashMap::default(); diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index af5e53300a7..7f19b81d6dc 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -5,7 +5,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::HttpClient; use language_model::{ ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError, diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index fb48e7d73a2..b145669d460 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -25,7 +25,8 @@ use collections::{BTreeMap, HashMap}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{ - AnyView, App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, Window, actions, + AnyView, App, AsyncApp, Context, Entity, FocusHandle, Subscription, Task, TaskExt, Window, + actions, }; use gpui_tokio::Tokio; use http_client::HttpClient; @@ -112,7 +113,6 @@ pub struct AmazonBedrockSettings { pub role_arn: Option, pub authentication_method: Option, pub allow_global: Option, - pub allow_extended_context: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, EnumIter, IntoStaticStr, JsonSchema)] @@ -385,13 +385,6 @@ impl State { .and_then(|s| s.allow_global) .unwrap_or(false) } - - fn get_allow_extended_context(&self) -> bool { - self.settings - .as_ref() - .and_then(|s| s.allow_extended_context) - .unwrap_or(false) - } } pub struct BedrockLanguageModelProvider { @@ -717,14 +710,9 @@ impl LanguageModel for BedrockModel { LanguageModelCompletionError, >, > { - let (region, allow_global, allow_extended_context) = - cx.read_entity(&self.state, |state, _cx| { - ( - state.get_region(), - state.get_allow_global(), - state.get_allow_extended_context(), - ) - }); + let (region, allow_global) = cx.read_entity(&self.state, |state, _cx| { + (state.get_region(), state.get_allow_global()) + }); let model_id = match self.model.cross_region_inference_id(®ion, allow_global) { Ok(s) => s, @@ -735,8 +723,6 @@ impl LanguageModel for BedrockModel { let deny_tool_calls = request.tool_choice == Some(LanguageModelToolChoice::None); - let use_extended_context = allow_extended_context && self.model.supports_extended_context(); - let request = match into_bedrock( request, model_id, @@ -745,7 +731,6 @@ impl LanguageModel for BedrockModel { self.model.thinking_mode(), self.model.supports_caching(), self.model.supports_tool_use(), - use_extended_context, ) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -838,7 +823,6 @@ pub fn into_bedrock( thinking_mode: BedrockModelMode, supports_caching: bool, supports_tool_use: bool, - allow_extended_context: bool, ) -> Result { let mut new_messages: Vec = Vec::new(); let mut system_message = String::new(); @@ -1143,7 +1127,6 @@ pub fn into_bedrock( temperature: request.temperature.or(Some(default_temperature)), top_k: None, top_p: None, - allow_extended_context, }) } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 0dae88fc307..c37b0162d45 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -7,7 +7,7 @@ use cloud_api_types::Plan; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; -use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task}; +use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, ZED_CLOUD_PROVIDER_ID, diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 9f10da20c12..757539a0895 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -5,7 +5,7 @@ use deepseek::DEEPSEEK_API_URL; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 87f2eeb26ab..d5b47bf4583 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; pub use google_ai::completion::{GoogleEventMapper, into_google}; use google_ai::{GenerateContentResponse, GoogleModelMode}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 50ac1286524..ea19c265e9c 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -4,7 +4,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task}; +use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task, TaskExt}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 403d94e9832..9776dfffbc8 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -3,7 +3,7 @@ use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, @@ -1021,7 +1021,7 @@ mod tests { speed: None, }; - let (mistral_request, _) = into_mistral(request, mistral::Model::Pixtral12BLatest, None); + let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None); assert_eq!(mistral_request.messages.len(), 1); assert!(matches!( diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index f38321b7c88..ea3a6f65035 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -3,7 +3,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task}; +use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, @@ -269,13 +269,15 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { let mut models: HashMap = HashMap::new(); let settings = OllamaLanguageModelProvider::settings(cx); - // Add models from the Ollama API - for model in self.state.read(cx).fetched_models.iter() { - let mut model = model.clone(); - if let Some(context_window) = settings.context_window { - model.max_tokens = context_window; + if settings.auto_discover { + // Add models from the Ollama API + for model in self.state.read(cx).fetched_models.iter() { + let mut model = model.clone(); + if let Some(context_window) = settings.context_window { + model.max_tokens = context_window; + } + models.insert(model.name.clone(), model); } - models.insert(model.name.clone(), model); } // Override with available models from settings @@ -381,11 +383,14 @@ impl OllamaLanguageModel { } } Role::Assistant => { - let content = msg.string_contents(); + let mut text_content = String::new(); let mut thinking = None; let mut tool_calls = Vec::new(); for content in msg.content.into_iter() { match content { + MessageContent::Text(text) => { + text_content.push_str(&text); + } MessageContent::Thinking { text, .. } if !text.is_empty() => { thinking = Some(text) } @@ -402,7 +407,7 @@ impl OllamaLanguageModel { } } messages.push(ChatMessage::Assistant { - content, + content: text_content, tool_calls: Some(tool_calls), images: if images.is_empty() { None diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 5557ce2d047..4957eea9635 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 5f7f6db3d36..a80965eced5 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -2,7 +2,7 @@ use anyhow::Result; use convert_case::{Case, Casing}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index bc4fbcc9aa7..c0b0e330629 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::HashMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 4380d2e1a13..647e8496b05 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -3,7 +3,7 @@ use collections::BTreeMap; use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{AsyncBody, HttpClient, http}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 789e8e35e85..312cdee5a66 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{AsyncReadExt, FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 12f195417b5..623853b5214 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -2,7 +2,7 @@ use anyhow::Result; use collections::BTreeMap; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; use http_client::HttpClient; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index d7272a483be..4acc42f9f76 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -61,7 +61,6 @@ impl settings::Settings for AllLanguageModelSettings { role_arn: None, // todo(was never a setting for this...) authentication_method: bedrock.authentication_method.map(Into::into), allow_global: bedrock.allow_global, - allow_extended_context: bedrock.allow_extended_context, }, deepseek: DeepSeekSettings { api_url: deepseek.api_url.unwrap(), diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 1f280282af9..e9e6dc82ffc 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -1,12 +1,12 @@ use editor::Editor; use gpui::{ - Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, - div, + App, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, + Window, div, }; use language::LanguageName; use settings::Settings as _; use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, Tooltip}; -use workspace::{StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; +use workspace::{HideStatusItem, StatusBarSettings, StatusItemView, Workspace, item::ItemHandle}; use crate::{LanguageSelector, Toggle}; @@ -86,4 +86,13 @@ impl StatusItemView for ActiveBufferLanguage { cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .active_language_button = Some(false); + })) + } } diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 70a03514f45..cd457cb50f9 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -6,7 +6,7 @@ use editor::Editor; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement, - Render, Styled, WeakEntity, Window, actions, + Render, Styled, TaskExt, WeakEntity, Window, actions, }; use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry}; use open_path_prompt::file_finder_settings::FileFinderSettings; diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 8fbe4385a17..8b7088dc228 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -13,7 +13,7 @@ use language::language_settings::{EditPredictionProvider, all_language_settings} use client::proto; use collections::HashSet; use editor::{Editor, EditorEvent}; -use gpui::{Anchor, Entity, Subscription, Task, WeakEntity, actions}; +use gpui::{Anchor, App, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; use project::{ @@ -1248,6 +1248,12 @@ impl StatusItemView for LspButton { self.refresh_lsp_menu(false, window, cx); } } + + fn hide_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.global_lsp_settings.get_or_insert_default().button = Some(false); + })) + } } impl Render for LspButton { diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index a947eefd13d..438090e2aa9 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -1,5 +1,14 @@ +use anyhow::Result; +use async_trait::async_trait; +use collections::HashMap; +use language::{LanguageServerName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain}; +use lsp::LanguageServerBinary; +use node_runtime::{NodeRuntime, VersionStrategy}; use project::ContextProviderWithTasks; +use semver::Version; +use std::{future::Future, path::PathBuf, sync::Arc, vec}; use task::{TaskTemplate, TaskTemplates, VariableName}; +use util::{ResultExt, maybe}; pub(super) fn bash_task_context() -> ContextProviderWithTasks { ContextProviderWithTasks::new(TaskTemplates(vec![ @@ -17,6 +26,157 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks { ])) } +pub struct BashLspAdapter { + node: NodeRuntime, +} + +impl BashLspAdapter { + const PACKAGE_NAME: &str = "bash-language-server"; + const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "bash-language-server/out/cli.js"; + + pub fn new(node: NodeRuntime) -> Self { + Self { node } + } + + async fn get_cached_server_binary( + container_dir: PathBuf, + env: HashMap, + node: &NodeRuntime, + ) -> Option { + maybe!(async { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + anyhow::ensure!( + server_path.exists(), + "missing executable in directory {server_path:?}" + ); + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + }) + .await + .log_err() + } +} + +impl LspInstaller for BashLspAdapter { + type BinaryVersion = Version; + + async fn cached_server_binary( + &self, + container_dir: std::path::PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let env = delegate.shell_env().await; + Self::get_cached_server_binary(container_dir, env, &self.node).await + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + _: Option, + _: &gpui::AsyncApp, + ) -> Option { + let path = delegate.which(Self::PACKAGE_NAME.as_ref()).await?; + let env = delegate.shell_env().await; + + Some(LanguageServerBinary { + path, + env: Some(env), + arguments: vec!["start".into()], + }) + } + + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + let delegate = delegate.clone(); + + async move { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + } + } + } + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + _: bool, + _: &mut gpui::AsyncApp, + ) -> Result { + self.node + .npm_package_latest_version(Self::PACKAGE_NAME) + .await + } + + fn fetch_server_binary( + &self, + latest_version: Self::BinaryVersion, + container_dir: std::path::PathBuf, + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let delegate = delegate.clone(); + + async move { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( + &container_dir, + &[(Self::PACKAGE_NAME, latest_version.as_str())], + ) + .await?; + + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + } + } +} + +#[async_trait(?Send)] +impl LspAdapter for BashLspAdapter { + fn name(&self) -> LanguageServerName { + LanguageServerName::new_static(Self::PACKAGE_NAME) + } +} + #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext}; diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 6585863f993..d2e92904c6d 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -9,7 +9,7 @@ use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; use project::lsp_store::clangd_ext; use serde_json::json; use smol::fs; -use std::{env::consts, path::PathBuf, sync::Arc}; +use std::{env::consts, future::Future, path::PathBuf, sync::Arc}; use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into}; pub struct CLspAdapter; @@ -66,82 +66,88 @@ impl LspInstaller for CLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - ensure_arch_compatibility()?; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = version; - let version_dir = container_dir.join(format!("clangd_{name}")); - let binary_path = version_dir - .join("bin") - .join(format!("clangd{}", consts::EXE_SUFFIX)); + async move { + ensure_arch_compatibility()?; - let binary = LanguageServerBinary { - path: binary_path.clone(), - env: None, - arguments: Default::default(), - }; - - let metadata_path = version_dir.join("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: binary_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {binary_path:?} asset, redownloading: {err:#}",) - }) - }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); - } - } else { - log::info!( - "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); - } - } else if validity_check().await.is_ok() { - return Ok(binary); - } - } - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &container_dir, - AssetKind::Zip, - ) - .await?; - remove_matching(&container_dir, |entry| entry != version_dir).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, + let GitHubLspBinaryVersion { + name, + url, digest: expected_digest, - }, - &metadata_path, - ) - .await?; + } = version; + let version_dir = container_dir.join(format!("clangd_{name}")); + let binary_path = version_dir + .join("bin") + .join(format!("clangd{}", consts::EXE_SUFFIX)); - Ok(binary) + let binary = LanguageServerBinary { + path: binary_path.clone(), + env: None, + arguments: Default::default(), + }; + + let metadata_path = version_dir.join("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: binary_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {binary_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &container_dir, + AssetKind::Zip, + ) + .await?; + remove_matching(&container_dir, |entry| entry != version_dir).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(binary) + } } async fn cached_server_binary( diff --git a/crates/languages/src/css.rs b/crates/languages/src/css.rs index 6a8fb730a0f..dfa0bc9fd3d 100644 --- a/crates/languages/src/css.rs +++ b/crates/languages/src/css.rs @@ -9,6 +9,7 @@ use semver::Version; use serde_json::json; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -64,58 +65,66 @@ impl LspInstaller for CssLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: None, arguments: server_binary_arguments(&server_path), }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 7ef55c64ef1..063cf85affd 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -17,6 +17,7 @@ use settings::SettingsLocation; use smol::{fs, stream::StreamExt}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -99,60 +100,63 @@ impl LspInstaller for EsLintLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let destination_path = Self::build_destination_path(&container_dir); - let server_path = destination_path.join(Self::SERVER_PATH); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); - if fs::metadata(&server_path).await.is_err() { - remove_matching(&container_dir, |_| true).await; + async move { + let destination_path = Self::build_destination_path(&container_dir); + let server_path = destination_path.join(Self::SERVER_PATH); - download_server_binary( - &*delegate.http_client(), - &version.url, - None, - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; + if fs::metadata(&server_path).await.is_err() { + remove_matching(&container_dir, |_| true).await; - let mut dir = fs::read_dir(&destination_path).await?; - let first = dir.next().await.context("missing first file")??; - let repo_root = destination_path.join("vscode-eslint"); - fs::rename(first.path(), &repo_root).await?; - - #[cfg(target_os = "windows")] - { - handle_symlink( - repo_root.join("$shared"), - repo_root.join("client").join("src").join("shared"), - ) - .await?; - handle_symlink( - repo_root.join("$shared"), - repo_root.join("server").join("src").join("shared"), + download_server_binary( + &*delegate.http_client(), + &version.url, + None, + &destination_path, + Self::GITHUB_ASSET_KIND, ) .await?; + + let mut dir = fs::read_dir(&destination_path).await?; + let first = dir.next().await.context("missing first file")??; + let repo_root = destination_path.join("vscode-eslint"); + fs::rename(first.path(), &repo_root).await?; + + #[cfg(target_os = "windows")] + { + handle_symlink( + repo_root.join("$shared"), + repo_root.join("client").join("src").join("shared"), + ) + .await?; + handle_symlink( + repo_root.join("$shared"), + repo_root.join("server").join("src").join("shared"), + ) + .await?; + } + + node.run_npm_subcommand(Some(&repo_root), "install", &[]) + .await?; + + node.run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) + .await?; } - self.node - .run_npm_subcommand(Some(&repo_root), "install", &[]) - .await?; - - self.node - .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) - .await?; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: eslint_server_binary_arguments(&server_path), + }) } - - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: eslint_server_binary_arguments(&server_path), - }) } async fn cached_server_binary( @@ -254,7 +258,9 @@ impl LspAdapter for EsLintLspAdapter { "mode": "auto" }, "workspaceFolder": { - "uri": worktree_root, + "uri": Uri::from_file_path(worktree_root) + .map(|uri| uri.as_str().to_owned()) + .unwrap_or_default(), "name": worktree_root.file_name() .unwrap_or(worktree_root.as_os_str()) .to_string_lossy(), diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index f4d0ce5f4d4..3bedd62b8e6 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -19,6 +19,7 @@ use smol::fs; use std::{ borrow::Cow, ffi::{OsStr, OsString}, + future::Future, ops::Range, path::{Path, PathBuf}, process::Output, @@ -117,75 +118,79 @@ impl LspInstaller for GoLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: Option, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let go = delegate.which("go".as_ref()).await.unwrap_or("go".into()); - let go_version_output = util::command::new_command(&go) - .args(["version"]) - .output() - .await - .context("failed to get go version via `go version` command`")?; - let go_version = parse_version_output(&go_version_output)?; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - if let Some(version) = version { - let binary_path = container_dir.join(format!("gopls_{version}_go_{go_version}")); - if let Ok(metadata) = fs::metadata(&binary_path).await - && metadata.is_file() - { - remove_matching(&container_dir, |entry| { - entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) - }) - .await; + async move { + let go = delegate.which("go".as_ref()).await.unwrap_or("go".into()); + let go_version_output = util::command::new_command(&go) + .args(["version"]) + .output() + .await + .context("failed to get go version via `go version` command`")?; + let go_version = parse_version_output(&go_version_output)?; - return Ok(LanguageServerBinary { - path: binary_path.to_path_buf(), - arguments: server_binary_arguments(), - env: None, - }); + if let Some(version) = version { + let binary_path = container_dir.join(format!("gopls_{version}_go_{go_version}")); + if let Ok(metadata) = fs::metadata(&binary_path).await + && metadata.is_file() + { + remove_matching(&container_dir, |entry| { + entry != binary_path && entry.file_name() != Some(OsStr::new("gobin")) + }) + .await; + + return Ok(LanguageServerBinary { + path: binary_path.to_path_buf(), + arguments: server_binary_arguments(), + env: None, + }); + } + } else if let Some(path) = get_cached_server_binary(&container_dir).await { + return Ok(path); } - } else if let Some(path) = get_cached_server_binary(&container_dir).await { - return Ok(path); + + let gobin_dir = container_dir.join("gobin"); + fs::create_dir_all(&gobin_dir).await?; + let install_output = util::command::new_command(go) + .env("GO111MODULE", "on") + .env("GOBIN", &gobin_dir) + .args(["install", "golang.org/x/tools/gopls@latest"]) + .output() + .await?; + + if !install_output.status.success() { + log::error!( + "failed to install gopls via `go install`. stdout: {:?}, stderr: {:?}", + String::from_utf8_lossy(&install_output.stdout), + String::from_utf8_lossy(&install_output.stderr) + ); + anyhow::bail!( + "failed to install gopls with `go install`. Is `go` installed and in the PATH? Check logs for more information." + ); + } + + let installed_binary_path = gobin_dir.join(BINARY); + let version_output = util::command::new_command(&installed_binary_path) + .arg("version") + .output() + .await + .context("failed to run installed gopls binary")?; + let gopls_version = parse_version_output(&version_output)?; + let binary_path = container_dir.join(format!("gopls_{gopls_version}_go_{go_version}")); + fs::rename(&installed_binary_path, &binary_path).await?; + + Ok(LanguageServerBinary { + path: binary_path.to_path_buf(), + arguments: server_binary_arguments(), + env: None, + }) } - - let gobin_dir = container_dir.join("gobin"); - fs::create_dir_all(&gobin_dir).await?; - let install_output = util::command::new_command(go) - .env("GO111MODULE", "on") - .env("GOBIN", &gobin_dir) - .args(["install", "golang.org/x/tools/gopls@latest"]) - .output() - .await?; - - if !install_output.status.success() { - log::error!( - "failed to install gopls via `go install`. stdout: {:?}, stderr: {:?}", - String::from_utf8_lossy(&install_output.stdout), - String::from_utf8_lossy(&install_output.stderr) - ); - anyhow::bail!( - "failed to install gopls with `go install`. Is `go` installed and in the PATH? Check logs for more information." - ); - } - - let installed_binary_path = gobin_dir.join(BINARY); - let version_output = util::command::new_command(&installed_binary_path) - .arg("version") - .output() - .await - .context("failed to run installed gopls binary")?; - let gopls_version = parse_version_output(&version_output)?; - let binary_path = container_dir.join(format!("gopls_{gopls_version}_go_{go_version}")); - fs::rename(&installed_binary_path, &binary_path).await?; - - Ok(LanguageServerBinary { - path: binary_path.to_path_buf(), - arguments: server_binary_arguments(), - env: None, - }) } async fn cached_server_binary( diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index b1bcd0043e5..9cd6c1565ad 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -24,6 +24,7 @@ use std::{ borrow::Cow, env::consts, ffi::OsString, + future::Future, path::{Path, PathBuf}, str::FromStr, sync::Arc, @@ -176,56 +177,64 @@ impl LspInstaller for JsonLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; + async move { + let server_path = container_dir.join(SERVER_PATH); - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } } async fn cached_server_binary( @@ -478,51 +487,55 @@ impl LspInstaller for NodeVersionAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let version = &latest_version; - let destination_path = container_dir.join(format!( - "{}-{}{}", - Self::SERVER_NAME, - version.name, - std::env::consts::EXE_SUFFIX - )); - let destination_container_path = - container_dir.join(format!("{}-{}-tmp", Self::SERVER_NAME, version.name)); - if fs::metadata(&destination_path).await.is_err() { - let mut response = delegate - .http_client() - .get(&version.url, Default::default(), true) - .await - .context("downloading release")?; - if version.url.ends_with(".zip") { - extract_zip(&destination_container_path, response.body_mut()).await?; - } else if version.url.ends_with(".tar.gz") { - let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); - let archive = Archive::new(decompressed_bytes); - archive.unpack(&destination_container_path).await?; - } + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - fs::copy( - destination_container_path.join(format!( - "{}{}", - Self::SERVER_NAME, - std::env::consts::EXE_SUFFIX - )), - &destination_path, - ) - .await?; - remove_matching(&container_dir, |entry| entry != destination_path).await; + async move { + let version = &latest_version; + let destination_path = container_dir.join(format!( + "{}-{}{}", + Self::SERVER_NAME, + version.name, + std::env::consts::EXE_SUFFIX + )); + let destination_container_path = + container_dir.join(format!("{}-{}-tmp", Self::SERVER_NAME, version.name)); + if fs::metadata(&destination_path).await.is_err() { + let mut response = delegate + .http_client() + .get(&version.url, Default::default(), true) + .await + .context("downloading release")?; + if version.url.ends_with(".zip") { + extract_zip(&destination_container_path, response.body_mut()).await?; + } else if version.url.ends_with(".tar.gz") { + let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); + let archive = Archive::new(decompressed_bytes); + archive.unpack(&destination_container_path).await?; + } + + fs::copy( + destination_container_path.join(format!( + "{}{}", + Self::SERVER_NAME, + std::env::consts::EXE_SUFFIX + )), + &destination_path, + ) + .await?; + remove_matching(&container_dir, |entry| entry != destination_path).await; + } + Ok(LanguageServerBinary { + path: destination_path, + env: None, + arguments: Default::default(), + }) } - Ok(LanguageServerBinary { - path: destination_path, - env: None, - arguments: Default::default(), - }) } async fn cached_server_binary( diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 9010bbde022..fe07a3f9988 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -57,6 +57,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime #[cfg(feature = "load-grammars")] languages.register_native_grammars(grammars::native_grammars()); + let bash_lsp_adapter = Arc::new(bash::BashLspAdapter::new(node.clone())); let c_lsp_adapter = Arc::new(c::CLspAdapter); let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone())); let eslint_adapter = Arc::new(eslint::EsLintLspAdapter::new(node.clone(), fs.clone())); @@ -88,6 +89,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime LanguageInfo { name: "bash", context: Some(Arc::new(bash::bash_task_context())), + adapters: vec![bash_lsp_adapter], ..Default::default() }, LanguageInfo { diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 26e96789a0d..483430bd75d 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1,5 +1,5 @@ +use anyhow::Result; use anyhow::{Context as _, ensure}; -use anyhow::{Result, anyhow}; use async_trait::async_trait; use collections::HashMap; use futures::future::BoxFuture; @@ -45,6 +45,7 @@ use std::str::FromStr; use std::{ borrow::Cow, fmt::Write, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -447,92 +448,98 @@ impl LspInstaller for TyLspAdapter { None } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = latest_version; - let destination_path = container_dir.join(format!("ty-{name}")); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - async_fs::create_dir_all(&destination_path).await?; - - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path - .join(Self::build_asset_name()?.0) - .join("ty"), - AssetKind::Zip => destination_path.clone().join("ty.exe"), - }; - - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: vec!["server".into()], - }; - - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) - }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); - } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); - } - } else if validity_check().await.is_ok() { - return Ok(binary); - } - } - - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, + async move { + let GitHubLspBinaryVersion { + name, + url, digest: expected_digest, - }, - &metadata_path, - ) - .await?; + } = latest_version; + let destination_path = container_dir.join(format!("ty-{name}")); - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: vec!["server".into()], - }) + async_fs::create_dir_all(&destination_path).await?; + + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ty"), + AssetKind::Zip => destination_path.clone().join("ty.exe"), + }; + + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: vec!["server".into()], + }) + } } async fn cached_server_binary( @@ -777,60 +784,70 @@ impl LspInstaller for PyrightLspAdapter { } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], ) .await?; - let env = delegate.shell_env().await; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::SERVER_NAME.as_ref(), - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: Some(env), arguments: vec![server_path.into(), "--stdio".into()], }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, @@ -1949,46 +1966,50 @@ impl LspInstaller for PyLspAdapter { Ok(()) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?; - let pip_path = venv.join(BINARY_DIR).join("pip3"); - ensure!( - util::command::new_command(pip_path.as_path()) - .arg("install") - .arg("python-lsp-server[all]") - .arg("--upgrade") - .output() - .await? - .status - .success(), - "python-lsp-server[all] installation failed" - ); - ensure!( - util::command::new_command(pip_path) - .arg("install") - .arg("pylsp-mypy") - .arg("--upgrade") - .output() - .await? - .status - .success(), - "pylsp-mypy installation failed" - ); - let pylsp = venv.join(BINARY_DIR).join("pylsp"); - ensure!( - delegate.which(pylsp.as_os_str()).await.is_some(), - "pylsp installation was incomplete" - ); - Ok(LanguageServerBinary { - path: pylsp, - env: None, - arguments: vec![], - }) + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + + async move { + let venv = Self::ensure_venv(delegate.as_ref()).await?; + let pip_path = venv.join(BINARY_DIR).join("pip3"); + ensure!( + util::command::new_command(pip_path.as_path()) + .arg("install") + .arg("python-lsp-server[all]") + .arg("--upgrade") + .output() + .await? + .status + .success(), + "python-lsp-server[all] installation failed" + ); + ensure!( + util::command::new_command(pip_path) + .arg("install") + .arg("pylsp-mypy") + .arg("--upgrade") + .output() + .await? + .status + .success(), + "pylsp-mypy installation failed" + ); + let pylsp = venv.join(BINARY_DIR).join("pylsp"); + ensure!( + delegate.which(pylsp.as_os_str()).await.is_some(), + "pylsp installation was incomplete" + ); + Ok(LanguageServerBinary { + path: pylsp, + env: None, + arguments: vec![], + }) + } } async fn cached_server_binary( @@ -2229,60 +2250,70 @@ impl LspInstaller for BasedPyrightLspAdapter { } } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); - let latest_version = latest_version.to_string(); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::SERVER_NAME.as_ref(), latest_version.as_str())], ) .await?; - let env = delegate.shell_env().await; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: Some(env), - arguments: vec![server_path.into(), "--stdio".into()], - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::SERVER_NAME.as_ref(), - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: Some(env), arguments: vec![server_path.into(), "--stdio".into()], }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(Self::SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::SERVER_NAME.as_ref(), + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: Some(env), + arguments: vec![server_path.into(), "--stdio".into()], + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, @@ -2566,89 +2597,95 @@ impl LspInstaller for RuffLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = latest_version; - let destination_path = container_dir.join(format!("ruff-{name}")); - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path - .join(Self::build_asset_name()?.0) - .join("ruff"), - AssetKind::Zip => destination_path.clone().join("ruff.exe"), - }; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: vec!["server".into()], - }; - - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) - }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); - } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); - } - } else if validity_check().await.is_ok() { - return Ok(binary); - } - } - - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, + async move { + let GitHubLspBinaryVersion { + name, + url, digest: expected_digest, - }, - &metadata_path, - ) - .await?; + } = latest_version; + let destination_path = container_dir.join(format!("ruff-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path + .join(Self::build_asset_name()?.0) + .join("ruff"), + AssetKind::Zip => destination_path.clone().join("ruff.exe"), + }; - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: vec!["server".into()], - }) + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: vec!["server".into()], + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: vec!["server".into()], + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 57d86ea91f3..de219d30928 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -19,6 +19,7 @@ use smallvec::SmallVec; use smol::fs::{self}; use std::cmp::Reverse; use std::fmt::Display; +use std::future::Future; use std::ops::Range; use std::{ borrow::Cow, @@ -729,87 +730,93 @@ impl LspInstaller for RustLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, version: GitHubLspBinaryVersion, container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let GitHubLspBinaryVersion { - name, - url, - digest: expected_digest, - } = version; - let destination_path = container_dir.join(format!("rust-analyzer-{name}")); - let server_path = match Self::GITHUB_ASSET_KIND { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. - AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe - }; + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let delegate = delegate.clone(); - let binary = LanguageServerBinary { - path: server_path.clone(), - env: None, - arguments: Default::default(), - }; - - let metadata_path = destination_path.with_extension("metadata"); - let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) - .await - .ok(); - if let Some(metadata) = metadata { - let validity_check = async || { - delegate - .try_exec(LanguageServerBinary { - path: server_path.clone(), - arguments: vec!["--version".into()], - env: None, - }) - .await - .inspect_err(|err| { - log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) - }) - }; - if let (Some(actual_digest), Some(expected_digest)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest { - if validity_check().await.is_ok() { - return Ok(binary); - } - } else { - log::info!( - "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" - ); - } - } else if validity_check().await.is_ok() { - return Ok(binary); - } - } - - download_server_binary( - &*delegate.http_client(), - &url, - expected_digest.as_deref(), - &destination_path, - Self::GITHUB_ASSET_KIND, - ) - .await?; - make_file_executable(&server_path).await?; - remove_matching(&container_dir, |path| path != destination_path).await; - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, + async move { + let GitHubLspBinaryVersion { + name, + url, digest: expected_digest, - }, - &metadata_path, - ) - .await?; + } = version; + let destination_path = container_dir.join(format!("rust-analyzer-{name}")); + let server_path = match Self::GITHUB_ASSET_KIND { + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Gz => destination_path.clone(), // Tar and gzip extract in place. + AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe + }; - Ok(LanguageServerBinary { - path: server_path, - env: None, - arguments: Default::default(), - }) + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: Default::default(), + }; + + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { + delegate + .try_exec(LanguageServerBinary { + path: server_path.clone(), + arguments: vec!["--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!( + "Unable to run {server_path:?} asset, redownloading: {err:#}", + ) + }) + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, + ) + .await?; + make_file_executable(&server_path).await?; + remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; + + Ok(LanguageServerBinary { + path: server_path, + env: None, + arguments: Default::default(), + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index c78790b74c8..41fa248a935 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -10,6 +10,7 @@ use semver::Version; use serde_json::{Value, json}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -69,58 +70,66 @@ impl LspInstaller for TailwindLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: None, arguments: server_binary_arguments(&server_path), }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, diff --git a/crates/languages/src/tailwindcss.rs b/crates/languages/src/tailwindcss.rs index aa310fac3f5..dcc9e8bf4ef 100644 --- a/crates/languages/src/tailwindcss.rs +++ b/crates/languages/src/tailwindcss.rs @@ -9,6 +9,7 @@ use semver::Version; use serde_json::json; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -65,58 +66,66 @@ impl LspInstaller for TailwindCssLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); - let latest_version = latest_version.to_string(); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: None, arguments: server_binary_arguments(&server_path), }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index a83e36270d2..d6889d8cbb8 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -18,6 +18,7 @@ use smol::lock::RwLock; use std::{ borrow::Cow, ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; @@ -669,76 +670,80 @@ impl LspInstaller for TypeScriptLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(Self::NEW_SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let typescript_version = version.typescript_version.clone(); + let server_version = version.server_version.clone(); + let container_dir = container_dir.clone(); - if self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(&version.typescript_version), - ) - .await - { - return None; + async move { + let server_path = container_dir.join(Self::NEW_SERVER_PATH); + + if node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&typescript_version), + ) + .await + { + return None; + } + + if node + .should_install_npm_package( + Self::SERVER_PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&server_version), + ) + .await + { + return None; + } + + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) } - - if self - .node - .should_install_npm_package( - Self::SERVER_PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(&version.server_version), - ) - .await - { - return None; - } - - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::NEW_SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(Self::NEW_SERVER_PATH); + let typescript_version = latest_version.typescript_version.to_string(); + let server_version = latest_version.server_version.to_string(); + + node.npm_install_packages( &container_dir, &[ - ( - Self::PACKAGE_NAME, - &latest_version.typescript_version.to_string(), - ), - ( - Self::SERVER_PACKAGE_NAME, - &latest_version.server_version.to_string(), - ), + (Self::PACKAGE_NAME, typescript_version.as_str()), + (Self::SERVER_PACKAGE_NAME, server_version.as_str()), ], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) + } } async fn cached_server_binary( diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 23434b81a98..4bc4401ff30 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -15,6 +15,7 @@ use serde_json::json; use settings::update_settings_file; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; @@ -123,54 +124,56 @@ impl LspInstaller for VtslsLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(Self::SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - let typescript_version = latest_version.typescript_version.to_string(); - let server_version = latest_version.server_version.to_string(); + async move { + let server_path = container_dir.join(Self::SERVER_PATH); - let mut packages_to_install = Vec::new(); + let typescript_version = latest_version.typescript_version.to_string(); + let server_version = latest_version.server_version.to_string(); - if self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - &container_dir, - VersionStrategy::Latest(&latest_version.server_version), - ) - .await - { - packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + let mut packages_to_install = Vec::new(); + + if node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&latest_version.server_version), + ) + .await + { + packages_to_install.push((Self::PACKAGE_NAME, server_version.as_str())); + } + + if node + .should_install_npm_package( + Self::TYPESCRIPT_PACKAGE_NAME, + &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), + &container_dir, + VersionStrategy::Latest(&latest_version.typescript_version), + ) + .await + { + packages_to_install + .push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); + } + + node.npm_install_packages(&container_dir, &packages_to_install) + .await?; + + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: None, + arguments: typescript_server_binary_arguments(&server_path), + }) } - - if self - .node - .should_install_npm_package( - Self::TYPESCRIPT_PACKAGE_NAME, - &container_dir.join(Self::TYPESCRIPT_TSDK_PATH), - &container_dir, - VersionStrategy::Latest(&latest_version.typescript_version), - ) - .await - { - packages_to_install.push((Self::TYPESCRIPT_PACKAGE_NAME, typescript_version.as_str())); - } - - self.node - .npm_install_packages(&container_dir, &packages_to_install) - .await?; - - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: typescript_server_binary_arguments(&server_path), - }) } async fn cached_server_binary( diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index e8bad8eb205..22781acf25a 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -12,6 +12,7 @@ use serde_json::Value; use settings::{Settings, SettingsLocation}; use std::{ ffi::OsString, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -65,57 +66,66 @@ impl LspInstaller for YamlLspAdapter { }) } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir.join(SERVER_PATH); + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); - self.node - .npm_install_packages( + async move { + let server_path = container_dir.join(SERVER_PATH); + let latest_version = latest_version.to_string(); + + node.npm_install_packages( &container_dir, - &[(Self::PACKAGE_NAME, &latest_version.to_string())], + &[(Self::PACKAGE_NAME, latest_version.as_str())], ) .await?; - Ok(LanguageServerBinary { - path: self.node.binary_path().await?, - env: None, - arguments: server_binary_arguments(&server_path), - }) - } - - async fn check_if_version_installed( - &self, - version: &Self::BinaryVersion, - container_dir: &PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir.join(SERVER_PATH); - - let should_install_language_server = self - .node - .should_install_npm_package( - Self::PACKAGE_NAME, - &server_path, - container_dir, - VersionStrategy::Latest(version), - ) - .await; - - if should_install_language_server { - None - } else { - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, + Ok(LanguageServerBinary { + path: node.binary_path().await?, env: None, arguments: server_binary_arguments(&server_path), }) } } + fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + _: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let version = version.clone(); + let container_dir = container_dir.clone(); + + async move { + let server_path = container_dir.join(SERVER_PATH); + + let should_install_language_server = node + .should_install_npm_package( + Self::PACKAGE_NAME, + &server_path, + &container_dir, + VersionStrategy::Latest(&version), + ) + .await; + + if should_install_language_server { + None + } else { + Some(LanguageServerBinary { + path: node.binary_path().await.ok()?, + env: None, + arguments: server_binary_arguments(&server_path), + }) + } + } + } + async fn cached_server_binary( &self, container_dir: PathBuf, diff --git a/crates/line_ending_selector/src/line_ending_indicator.rs b/crates/line_ending_selector/src/line_ending_indicator.rs index 9c493344e75..419c63d0ac8 100644 --- a/crates/line_ending_selector/src/line_ending_indicator.rs +++ b/crates/line_ending_selector/src/line_ending_indicator.rs @@ -1,8 +1,10 @@ use editor::Editor; -use gpui::{Entity, Subscription, WeakEntity}; +use gpui::{App, Entity, Subscription, WeakEntity}; use language::LineEnding; use ui::{Tooltip, prelude::*}; -use workspace::{StatusBarSettings, StatusItemView, item::ItemHandle, item::Settings}; +use workspace::{ + HideStatusItem, StatusBarSettings, StatusItemView, item::ItemHandle, item::Settings, +}; use crate::{LineEndingSelector, Toggle}; @@ -65,4 +67,13 @@ impl StatusItemView for LineEndingIndicator { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings + .status_bar + .get_or_insert_default() + .line_endings_button = Some(false); + })) + } } diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs index 8aa5da0cea7..5ab9a48b720 100644 --- a/crates/markdown/src/html/html_parser.rs +++ b/crates/markdown/src/html/html_parser.rs @@ -867,6 +867,51 @@ mod tests { assert_eq!(table.body[1].columns.len(), 2); } + #[test] + fn parses_html_table_th_defaults_to_center() { + let html = "
H1H2
ab
"; + let parsed = parse_html_block(html, 0..html.len()).unwrap(); + + let ParsedHtmlElement::Table(table) = &parsed.children[0] else { + panic!("expected table"); + }; + + assert_eq!(table.header.len(), 1); + for column in &table.header[0].columns { + assert!(column.is_header); + assert_eq!(column.alignment, Alignment::Center); + } + + for column in &table.body[0].columns { + assert!(!column.is_header); + assert_eq!(column.alignment, Alignment::None); + } + } + + #[test] + fn parses_html_table_explicit_align_attribute_preserved() { + let html = "\ + \ + \ + \ + \ + \ + \ + \ + \ +
H1H2
ab
"; + let parsed = parse_html_block(html, 0..html.len()).unwrap(); + + let ParsedHtmlElement::Table(table) = &parsed.children[0] else { + panic!("expected table"); + }; + + assert_eq!(table.header[0].columns[0].alignment, Alignment::Right); + assert_eq!(table.header[0].columns[1].alignment, Alignment::Left); + assert_eq!(table.body[0].columns[0].alignment, Alignment::Center); + assert_eq!(table.body[0].columns[1].alignment, Alignment::Right); + } + #[test] fn parses_html_list_as_explicit_list_node() { let parsed = parse_html_block( diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 27e9b70e8e8..af46dfe2b2c 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -1,6 +1,8 @@ use std::ops::Range; -use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle}; +use gpui::{ + App, FontStyle, FontWeight, StrikethroughStyle, TextAlign, TextStyleRefinement, UnderlineStyle, +}; use pulldown_cmark::Alignment; use ui::prelude::*; @@ -245,14 +247,24 @@ impl MarkdownElement { } let max_span = max_column_count.saturating_sub(column_index); + let text_align = match cell.alignment { + Alignment::Left => TextAlign::Left, + Alignment::Center => TextAlign::Center, + Alignment::Right => TextAlign::Right, + _ => self.style.base_text_style.text_align, + }; + let mut cell_div = div() .col_span(cell.col_span.min(max_span) as u16) .row_span(cell.row_span.min(total_rows - row_index) as u16) + .flex() + .flex_col() .when(column_index > 0, |this| this.border_l_1()) .when(row_index > 0, |this| this.border_t_1()) .border_color(cx.theme().colors().border) .px_2() .py_1() + .h_full() .when(cell.is_header, |this| { this.bg(cx.theme().colors().title_bar_background) }) @@ -266,7 +278,22 @@ impl MarkdownElement { _ => cell_div, }; + builder.push_text_style(TextStyleRefinement { + text_align: Some(text_align), + ..Default::default() + }); builder.push_div(cell_div, &table.source_range, markdown_end); + builder.push_div( + div() + .flex() + .flex_col() + .flex_1() + .w_full() + .justify_center() + .text_align(text_align), + &table.source_range, + markdown_end, + ); self.render_html_paragraph( &cell.children, source_allocator, @@ -275,6 +302,8 @@ impl MarkdownElement { markdown_end, ); builder.pop_div(); + builder.pop_div(); + builder.pop_text_style(); for row_offset in 0..cell.row_span { for column_offset in 0..cell.col_span { diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index dce9633c87b..a900837b98e 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1730,15 +1730,28 @@ impl Element for MarkdownElement { } } MarkdownTag::Paragraph => { - self.push_markdown_paragraph(&mut builder, range, markdown_end, None); + let text_align_override = builder + .table + .current_cell_alignment() + .and_then(alignment_to_text_align); + self.push_markdown_paragraph( + &mut builder, + range, + markdown_end, + text_align_override, + ); } MarkdownTag::Heading { level, .. } => { + let text_align_override = builder + .table + .current_cell_alignment() + .and_then(alignment_to_text_align); self.push_markdown_heading( &mut builder, *level, range, markdown_end, - None, + text_align_override, ); } MarkdownTag::BlockQuote(kind) => { @@ -2000,20 +2013,46 @@ impl Element for MarkdownElement { let is_header = builder.table.in_head; let row_index = builder.table.row_index; let col_index = builder.table.col_index; + let alignment = builder.table.current_cell_alignment(); + let text_align = alignment + .and_then(alignment_to_text_align) + .unwrap_or(self.style.base_text_style.text_align); + let mut cell_div = div() + .flex() + .flex_col() + .h_full() + .when(col_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .px_1() + .py_0p5() + .when(is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }); + + cell_div = match alignment { + Some(Alignment::Center) => cell_div.items_center(), + Some(Alignment::Right) => cell_div.items_end(), + _ => cell_div, + }; + + builder.push_text_style(TextStyleRefinement { + text_align: Some(text_align), + ..Default::default() + }); + builder.push_div(cell_div, range, markdown_end); builder.push_div( div() - .when(col_index > 0, |this| this.border_l_1()) - .when(row_index > 0, |this| this.border_t_1()) - .border_color(cx.theme().colors().border) - .px_1() - .py_0p5() - .when(is_header, |this| { - this.bg(cx.theme().colors().title_bar_background) - }) - .when(!is_header && row_index % 2 == 1, |this| { - this.bg(cx.theme().colors().panel_background) - }), + .flex() + .flex_col() + .flex_1() + .w_full() + .justify_center() + .text_align(text_align), range, markdown_end, ); @@ -2113,6 +2152,8 @@ impl Element for MarkdownElement { MarkdownTagEnd::TableCell => { builder.replace_pending_checkbox(self.on_checkbox_toggle.clone()); builder.pop_div(); + builder.pop_div(); + builder.pop_text_style(); builder.table.end_cell(); } MarkdownTagEnd::FootnoteDefinition => { @@ -2414,6 +2455,25 @@ impl TableState { fn end_cell(&mut self) { self.col_index += 1; } + + fn current_cell_alignment(&self) -> Option { + if self.alignments.is_empty() { + return None; + } + if self.in_head { + return Some(Alignment::Center); + } + self.alignments.get(self.col_index).copied() + } +} + +fn alignment_to_text_align(alignment: Alignment) -> Option { + match alignment { + Alignment::Left => Some(TextAlign::Left), + Alignment::Center => Some(TextAlign::Center), + Alignment::Right => Some(TextAlign::Right), + Alignment::None => None, + } } struct MarkdownElementBuilder { @@ -2702,7 +2762,7 @@ impl MarkdownElementBuilder { ) .fill(); - let element = if let Some(on_toggle) = on_toggle { + let checkbox = if let Some(on_toggle) = on_toggle { checkbox .on_click(move |_state, window, cx| { on_toggle(marker_source.clone(), !checked, window, cx); @@ -2711,7 +2771,18 @@ impl MarkdownElementBuilder { } else { checkbox.visualization_only(true).into_any_element() }; - self.div_stack.last_mut().unwrap().extend([element]); + + let mut checkbox_container = h_flex().w_full(); + checkbox_container = match self.text_style().text_align { + TextAlign::Left => checkbox_container.justify_start(), + TextAlign::Center => checkbox_container.justify_center(), + TextAlign::Right => checkbox_container.justify_end(), + }; + + self.div_stack + .last_mut() + .unwrap() + .extend([checkbox_container.child(checkbox).into_any_element()]); } fn source_range_for_rendered(&self, rendered: &Range) -> Option> { @@ -3432,6 +3503,37 @@ mod tests { assert_eq!(second_word, "b"); } + #[test] + fn test_table_state_current_cell_alignment_centers_headers() { + let mut table = TableState::default(); + table.start(vec![Alignment::Left, Alignment::Right, Alignment::None]); + + table.start_head(); + for _ in 0..3 { + assert_eq!(table.current_cell_alignment(), Some(Alignment::Center)); + table.end_cell(); + } + + table.end_head(); + table.start_row(); + assert_eq!(table.current_cell_alignment(), Some(Alignment::Left)); + table.end_cell(); + assert_eq!(table.current_cell_alignment(), Some(Alignment::Right)); + table.end_cell(); + assert_eq!(table.current_cell_alignment(), Some(Alignment::None)); + table.end_cell(); + table.end_row(); + + table.end(); + assert_eq!(table.current_cell_alignment(), None); + } + + #[test] + fn test_table_state_current_cell_alignment_outside_table() { + let table = TableState::default(); + assert_eq!(table.current_cell_alignment(), None); + } + #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 76b46a520d5..333a1b2a2b3 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -975,6 +975,16 @@ impl Item for MarkdownPreviewView { .unwrap_or_else(|| Task::ready(Ok(()))) } + fn reload( + &mut self, + _project: Entity, + _window: &mut Window, + _cx: &mut Context, + ) -> Task> { + // The preview is not the owner of the source editor's buffer, so force-closing it should not discard editor changes. + Task::ready(Ok(())) + } + fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {} fn buffer_kind(&self, _cx: &App) -> ItemBufferKind { @@ -1354,6 +1364,92 @@ mod tests { ); } + #[gpui::test] + async fn force_closing_preview_preserves_source_editor_changes(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/dir"), + json!({ + "todo.md": "- [ ] Finish work\n" + }), + ) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir/todo.md"))], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let (preview, editor) = multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + let editor: Entity = workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .unwrap(); + + let preview = workspace.update(cx, |workspace, cx| { + let preview = MarkdownPreviewView::create_markdown_view( + workspace, + editor.clone(), + window, + cx, + ); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx) + }); + preview + }); + + (preview, editor) + }) + .unwrap(); + cx.run_until_parked(); + + multi_workspace + .update(cx, |_, window, cx| { + let view_handle = preview.downgrade(); + assert!(preview.read(cx).focus_handle.contains_focused(window, cx)); + MarkdownPreviewView::apply_checkbox_toggle_to_editor(&editor, 2..5, true, cx); + MarkdownPreviewView::refresh_preview(view_handle, window, cx); + }) + .unwrap(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), + "- [x] Finish work\n" + ); + + let close_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_item_by_id(preview.entity_id(), SaveIntent::Skip, window, cx) + }) + }) + }) + .unwrap(); + + close_task.await.unwrap(); + cx.run_until_parked(); + + assert_eq!( + editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read(cx).text()), + "- [x] Finish work\n" + ); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/mistral/src/mistral.rs b/crates/mistral/src/mistral.rs index e8227ca833e..e7a3ee421eb 100644 --- a/crates/mistral/src/mistral.rs +++ b/crates/mistral/src/mistral.rs @@ -58,23 +58,19 @@ pub enum Model { #[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")] MagistralMediumLatest, - #[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")] - MagistralSmallLatest, #[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")] OpenMistralNemo, - #[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")] - OpenCodestralMamba, #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] DevstralMediumLatest, - #[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")] - DevstralSmallLatest, - #[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] - Pixtral12BLatest, - #[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")] - PixtralLargeLatest, + #[serde(rename = "ministral-3b-latest", alias = "ministral-3b-latest")] + Ministral3bLatest, + #[serde(rename = "ministral-8b-latest", alias = "ministral-8b-latest")] + Ministral8bLatest, + #[serde(rename = "ministral-14b-latest", alias = "ministral-14b-latest")] + Ministral14bLatest, #[serde(rename = "custom")] Custom { @@ -102,13 +98,8 @@ impl Model { "mistral-medium-latest" => Ok(Self::MistralMediumLatest), "mistral-small-latest" => Ok(Self::MistralSmallLatest), "magistral-medium-latest" => Ok(Self::MagistralMediumLatest), - "magistral-small-latest" => Ok(Self::MagistralSmallLatest), "open-mistral-nemo" => Ok(Self::OpenMistralNemo), - "open-codestral-mamba" => Ok(Self::OpenCodestralMamba), "devstral-medium-latest" => Ok(Self::DevstralMediumLatest), - "devstral-small-latest" => Ok(Self::DevstralSmallLatest), - "pixtral-12b-latest" => Ok(Self::Pixtral12BLatest), - "pixtral-large-latest" => Ok(Self::PixtralLargeLatest), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -120,13 +111,11 @@ impl Model { Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", Self::MagistralMediumLatest => "magistral-medium-latest", - Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", - Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", - Self::DevstralSmallLatest => "devstral-small-latest", - Self::Pixtral12BLatest => "pixtral-12b-latest", - Self::PixtralLargeLatest => "pixtral-large-latest", + Self::Ministral3bLatest => "ministral-3b-latest", + Self::Ministral8bLatest => "ministral-8b-latest", + Self::Ministral14bLatest => "ministral-14b-latest", Self::Custom { name, .. } => name, } } @@ -138,13 +127,11 @@ impl Model { Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralSmallLatest => "mistral-small-latest", Self::MagistralMediumLatest => "magistral-medium-latest", - Self::MagistralSmallLatest => "magistral-small-latest", Self::OpenMistralNemo => "open-mistral-nemo", - Self::OpenCodestralMamba => "open-codestral-mamba", Self::DevstralMediumLatest => "devstral-medium-latest", - Self::DevstralSmallLatest => "devstral-small-latest", - Self::Pixtral12BLatest => "pixtral-12b-latest", - Self::PixtralLargeLatest => "pixtral-large-latest", + Self::Ministral3bLatest => "ministral-3b-latest", + Self::Ministral8bLatest => "ministral-8b-latest", + Self::Ministral14bLatest => "ministral-14b-latest", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -153,18 +140,16 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::CodestralLatest => 256000, + Self::CodestralLatest => 128000, Self::MistralLargeLatest => 256000, Self::MistralMediumLatest => 128000, - Self::MistralSmallLatest => 32000, + Self::MistralSmallLatest => 256000, Self::MagistralMediumLatest => 128000, - Self::MagistralSmallLatest => 128000, - Self::OpenMistralNemo => 131000, - Self::OpenCodestralMamba => 256000, + Self::OpenMistralNemo => 128000, Self::DevstralMediumLatest => 256000, - Self::DevstralSmallLatest => 256000, - Self::Pixtral12BLatest => 128000, - Self::PixtralLargeLatest => 128000, + Self::Ministral3bLatest => 256000, + Self::Ministral8bLatest => 256000, + Self::Ministral14bLatest => 256000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -185,31 +170,25 @@ impl Model { | Self::MistralMediumLatest | Self::MistralSmallLatest | Self::MagistralMediumLatest - | Self::MagistralSmallLatest | Self::OpenMistralNemo - | Self::OpenCodestralMamba | Self::DevstralMediumLatest - | Self::DevstralSmallLatest - | Self::Pixtral12BLatest - | Self::PixtralLargeLatest => true, + | Self::Ministral3bLatest + | Self::Ministral8bLatest + | Self::Ministral14bLatest => true, Self::Custom { supports_tools, .. } => supports_tools.unwrap_or(false), } } pub fn supports_images(&self) -> bool { match self { - Self::Pixtral12BLatest - | Self::PixtralLargeLatest + Self::MistralLargeLatest | Self::MistralMediumLatest - | Self::MistralSmallLatest => true, - Self::CodestralLatest - | Self::MistralLargeLatest + | Self::MistralSmallLatest | Self::MagistralMediumLatest - | Self::MagistralSmallLatest - | Self::OpenMistralNemo - | Self::OpenCodestralMamba - | Self::DevstralMediumLatest - | Self::DevstralSmallLatest => false, + | Self::Ministral3bLatest + | Self::Ministral8bLatest + | Self::Ministral14bLatest => true, + Self::CodestralLatest | Self::OpenMistralNemo | Self::DevstralMediumLatest => false, Self::Custom { supports_images, .. } => supports_images.unwrap_or(false), @@ -218,7 +197,7 @@ impl Model { pub fn supports_thinking(&self) -> bool { match self { - Self::MagistralMediumLatest | Self::MagistralSmallLatest => true, + Self::MagistralMediumLatest => true, Self::Custom { supports_thinking, .. } => supports_thinking.unwrap_or(false), diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index de0a43bac91..74eaeef53eb 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -5258,6 +5258,16 @@ impl MultiBufferSnapshot { Some(Anchor::in_buffer(path_key_index, anchor)) } + /// Lifts a buffer anchor range to a multibuffer anchor range without checking against excerpt boundaries. Returns `None` if there are no excerpts for the buffer. + pub fn anchor_range_in_buffer(&self, range: Range) -> Option> { + if range.start.buffer_id != range.end.buffer_id { + return None; + } + + let path_key_index = self.path_key_index_for_buffer(range.start.buffer_id)?; + Some(Anchor::range_in_buffer(path_key_index, range)) + } + /// Creates a multibuffer anchor for the given buffer anchor, if it is contained in any excerpt. pub fn anchor_in_excerpt(&self, text_anchor: text::Anchor) -> Option { let excerpts = { @@ -5295,6 +5305,19 @@ impl MultiBufferSnapshot { &self, text_anchor: Range, ) -> Option> { + if self.is_singleton() { + let excerpt = self.excerpts.first()?; + let buffer_snapshot = excerpt.buffer_snapshot(self); + if excerpt.range.contains(&text_anchor.start, &buffer_snapshot) + && excerpt.range.contains(&text_anchor.end, &buffer_snapshot) + { + return Some(Anchor::range_in_buffer(excerpt.path_key_index, text_anchor)); + } + } + + // for each search match + + let mut buffer_snapshot = None; for excerpt in { let this = &self; let buffer_id = text_anchor.start.buffer_id; @@ -5316,7 +5339,8 @@ impl MultiBufferSnapshot { .into_iter() .flatten() } { - let buffer_snapshot = excerpt.buffer_snapshot(self); + let buffer_snapshot = + buffer_snapshot.get_or_insert_with(|| excerpt.buffer_snapshot(self)); if excerpt.range.contains(&text_anchor.start, &buffer_snapshot) && excerpt.range.contains(&text_anchor.end, &buffer_snapshot) { diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 7d021c54447..30e69a320ea 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -5,7 +5,7 @@ use client::{Client, TelemetrySettings, UserStore, zed_urls}; use cloud_api_types::Plan; use collections::HashMap; use fs::Fs; -use gpui::{Action, Animation, AnimationExt, App, Entity, IntoElement, pulsating_between}; +use gpui::{Action, Animation, AnimationExt, App, Entity, IntoElement, TaskExt, pulsating_between}; use project::agent_server_store::AllAgentServersSettings; use project::project_settings::ProjectSettings; use project::{AgentRegistryStore, RegistryAgent}; diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 7b378c6fb82..7c5bb7bcf62 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -18,7 +18,7 @@ use gpui::{ DismissEvent, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveElement, IntoElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollStrategy, - SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, + SharedString, Stateful, StatefulInteractiveElement as _, Styled, Subscription, Task, TaskExt, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, point, px, size, uniform_list, }; @@ -4963,6 +4963,12 @@ impl Panel for OutlinePanel { fn activation_priority(&self) -> u32 { 6 } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.outline_panel.get_or_insert_default().button = Some(false); + })) + } } impl Focusable for OutlinePanel { diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7afab7e8169..800ac414be1 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -11,6 +11,41 @@ use util::rel_path::RelPath; /// A default editorconfig file name to use when resolving project settings. pub const EDITORCONFIG_NAME: &str = ".editorconfig"; +/// The application name, used to derive platform-specific data, config, cache, +/// and state directory paths. +/// +/// Forks should change this to avoid colliding with Zed's user data. +pub const APP_NAME: &str = "Zed"; + +/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on +/// Linux/FreeBSD and the macOS `~/.config` fallback. +pub const APP_NAME_LOWERCASE: &str = { + assert!(!APP_NAME.is_empty(), "APP_NAME must not be empty"); + assert!(APP_NAME.as_bytes().is_ascii(), "APP_NAME must be ASCII"); + const BYTES: [u8; APP_NAME.len()] = { + let mut bytes = [0u8; APP_NAME.len()]; + let mut i = 0; + while i < APP_NAME.len() { + assert!( + APP_NAME.as_bytes()[i] != b'/' && APP_NAME.as_bytes()[i] != b'\\', + "APP_NAME must not contain path separators", + ); + assert!( + APP_NAME.as_bytes()[i] >= 0x20, + "APP_NAME must not contain control characters" + ); + bytes[i] = APP_NAME.as_bytes()[i]; + i += 1; + } + bytes.make_ascii_lowercase(); + bytes + }; + match std::str::from_utf8(&BYTES) { + Ok(s) => s, + Err(_) => unreachable!(), + } +}; + /// A custom data directory override, set only by `set_custom_data_dir`. /// This is used to override the default data directory location. /// The directory will be created if it doesn't exist when set. @@ -91,16 +126,16 @@ pub fn config_dir() -> &'static PathBuf { } else if cfg!(target_os = "windows") { dirs::config_dir() .expect("failed to determine RoamingAppData directory") - .join("Zed") + .join(APP_NAME) } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") { flatpak_xdg_config.into() } else { dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory") } - .join("zed") + .join(APP_NAME_LOWERCASE) } else { - home_dir().join(".config").join("zed") + home_dir().join(".config").join(APP_NAME_LOWERCASE) } }) } @@ -111,18 +146,20 @@ pub fn data_dir() -> &'static PathBuf { if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { custom_dir.clone() } else if cfg!(target_os = "macos") { - home_dir().join("Library/Application Support/Zed") + home_dir() + .join("Library/Application Support") + .join(APP_NAME) } else if cfg!(any(target_os = "linux", target_os = "freebsd")) { if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") { flatpak_xdg_data.into() } else { dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory") } - .join("zed") + .join(APP_NAME_LOWERCASE) } else if cfg!(target_os = "windows") { dirs::data_local_dir() .expect("failed to determine LocalAppData directory") - .join("Zed") + .join(APP_NAME) } else { config_dir().clone() // Fallback } @@ -133,7 +170,7 @@ pub fn state_dir() -> &'static PathBuf { static STATE_DIR: OnceLock = OnceLock::new(); STATE_DIR.get_or_init(|| { if cfg!(target_os = "macos") { - return home_dir().join(".local").join("state").join("Zed"); + return home_dir().join(".local").join("state").join(APP_NAME); } if cfg!(any(target_os = "linux", target_os = "freebsd")) { @@ -142,12 +179,12 @@ pub fn state_dir() -> &'static PathBuf { } else { dirs::state_dir().expect("failed to determine XDG_STATE_HOME directory") } - .join("zed"); + .join(APP_NAME_LOWERCASE); } else { // Windows return dirs::data_local_dir() .expect("failed to determine LocalAppData directory") - .join("Zed"); + .join(APP_NAME); } }) } @@ -159,13 +196,13 @@ pub fn temp_dir() -> &'static PathBuf { if cfg!(target_os = "macos") { return dirs::cache_dir() .expect("failed to determine cachesDirectory directory") - .join("Zed"); + .join(APP_NAME); } if cfg!(target_os = "windows") { return dirs::cache_dir() .expect("failed to determine LocalAppData directory") - .join("Zed"); + .join(APP_NAME); } if cfg!(any(target_os = "linux", target_os = "freebsd")) { @@ -174,10 +211,10 @@ pub fn temp_dir() -> &'static PathBuf { } else { dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory") } - .join("zed"); + .join(APP_NAME_LOWERCASE); } - home_dir().join(".cache").join("zed") + home_dir().join(".cache").join(APP_NAME_LOWERCASE) }) } @@ -192,7 +229,7 @@ pub fn logs_dir() -> &'static PathBuf { static LOGS_DIR: OnceLock = OnceLock::new(); LOGS_DIR.get_or_init(|| { if cfg!(target_os = "macos") { - home_dir().join("Library/Logs/Zed") + home_dir().join("Library/Logs").join(APP_NAME) } else { data_dir().join("logs") } @@ -208,13 +245,13 @@ pub fn remote_server_state_dir() -> &'static PathBuf { /// Returns the path to the `Zed.log` file. pub fn log_file() -> &'static PathBuf { static LOG_FILE: OnceLock = OnceLock::new(); - LOG_FILE.get_or_init(|| logs_dir().join("Zed.log")) + LOG_FILE.get_or_init(|| logs_dir().join(format!("{}.log", APP_NAME))) } /// Returns the path to the `Zed.log.old` file. pub fn old_log_file() -> &'static PathBuf { static OLD_LOG_FILE: OnceLock = OnceLock::new(); - OLD_LOG_FILE.get_or_init(|| logs_dir().join("Zed.log.old")) + OLD_LOG_FILE.get_or_init(|| logs_dir().join(format!("{}.log.old", APP_NAME))) } /// Returns the path to the database directory. diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index f2f90db1e63..0ff12127237 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -35,6 +35,12 @@ pub enum Direction { Down, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScrollBehavior { + RevealSelected, + PreserveOffset, +} + actions!( picker, [ @@ -687,9 +693,19 @@ impl Picker { } pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context) { + self.update_matches_with_options(query, ScrollBehavior::RevealSelected, window, cx); + } + + pub fn update_matches_with_options( + &mut self, + query: String, + scroll_behavior: ScrollBehavior, + window: &mut Window, + cx: &mut Context, + ) { let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx); - self.matches_updated(window, cx); + self.matches_updated(scroll_behavior, window, cx); // This struct ensures that we can synchronously drop the task returned by the // delegate's `update_matches` method and the task that the picker is spawning. // If we simply capture the delegate's task into the picker's task, when the picker's @@ -709,19 +725,40 @@ impl Picker { })?; delegate_pending_update_matches.await; this.update_in(cx, |this, window, cx| { - this.matches_updated(window, cx); + this.matches_updated(scroll_behavior, window, cx); }) }), }); } - fn matches_updated(&mut self, window: &mut Window, cx: &mut Context) { - if let ElementContainer::List(state) = &mut self.element_container { - state.reset(self.delegate.match_count()); + fn matches_updated( + &mut self, + scroll_behavior: ScrollBehavior, + window: &mut Window, + cx: &mut Context, + ) { + let match_count = self.delegate.match_count(); + match &mut self.element_container { + ElementContainer::List(state) => match scroll_behavior { + ScrollBehavior::RevealSelected => { + state.reset(match_count); + let index = self.delegate.selected_index(); + self.scroll_to_item_index(index); + } + ScrollBehavior::PreserveOffset => { + let offset = state.logical_scroll_top(); + state.reset(match_count); + state.scroll_to(offset); + } + }, + ElementContainer::UniformList(_) => match scroll_behavior { + ScrollBehavior::RevealSelected => { + let index = self.delegate.selected_index(); + self.scroll_to_item_index(index); + } + ScrollBehavior::PreserveOffset => {} + }, } - - let index = self.delegate.selected_index(); - self.scroll_to_item_index(index); self.pending_update_matches = None; if let Some(secondary) = self.confirm_on_update.take() { self.do_confirm(secondary, window, cx); @@ -752,6 +789,13 @@ impl Picker { } } + pub fn is_scrolled_to_end(&self) -> Option { + match &self.element_container { + ElementContainer::List(state) => state.is_scrolled_to_end(), + ElementContainer::UniformList(scroll_handle) => scroll_handle.is_scrolled_to_end(), + } + } + fn render_element( &self, window: &mut Window, diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index b2010da65d9..21c07b0feba 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -6,7 +6,7 @@ use anyhow::{Context as _, Result, bail}; use collections::HashMap; use fs::Fs; use futures::AsyncReadExt; -use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; +use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task, TaskExt}; use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; use settings::Settings as _; diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index cdde687ec63..e0e044e5638 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::{Context as _, Result, bail}; use collections::HashMap; use fs::Fs; -use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use gpui::{AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, TaskExt}; use http_client::{HttpClient, github::AssetKind}; use node_runtime::NodeRuntime; use percent_encoding::percent_decode_str; @@ -1606,6 +1606,13 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { /// security settings, as the args don't change often. The registry will need to support this better /// at some point, but until then, this is a best-effort workaround that hopefully solves the issue /// for most users. +/// +/// We use npm's hyphen-range syntax (`0.0.0 - `, equivalent to `<=`) instead of +/// the more compact `<=` form because on Windows, `npm` is `npm.cmd` (a batch file run by +/// cmd.exe), and the quotes our shell builder emits are PowerShell string-literal syntax that PS +/// strips during parsing. PS only re-adds CRT-style transport quotes around native command args +/// containing whitespace, so `package@<=0.25.3` reaches cmd.exe bare and the unquoted `<` is +/// interpreted as input redirection. See zed-industries/zed#55921. fn bounded_npm_package_spec(package_spec: &str) -> String { let Some((package_name, version)) = package_spec.rsplit_once('@') else { return package_spec.to_string(); @@ -1614,7 +1621,7 @@ fn bounded_npm_package_spec(package_spec: &str) -> String { return package_spec.to_string(); } - format!("{package_name}@<={version}") + format!("{package_name}@0.0.0 - {version}") } struct LocalCustomAgent { @@ -2025,11 +2032,11 @@ mod tests { fn builds_bounded_npm_package_specs() { assert_eq!( bounded_npm_package_spec("agent-package@1.2.3"), - "agent-package@<=1.2.3" + "agent-package@0.0.0 - 1.2.3" ); assert_eq!( bounded_npm_package_spec("@scope/agent-package@1.2.3-beta.1"), - "@scope/agent-package@<=1.2.3-beta.1" + "@scope/agent-package@0.0.0 - 1.2.3-beta.1" ); assert_eq!( bounded_npm_package_spec("@scope/agent-package"), diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 2e234a7f936..f9076753998 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -8,7 +8,8 @@ use client::Client; use collections::{HashMap, HashSet, hash_map}; use futures::{Future, FutureExt as _, channel::oneshot, future::Shared}; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, + App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, TaskExt, + WeakEntity, }; use language::{ Buffer, BufferEvent, Capability, DiskState, File as _, Language, LineEnding, Operation, diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 7b9fc16f100..9effe4a4638 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -13,7 +13,9 @@ use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use credentials_provider::CredentialsProvider; use futures::future::Either; use futures::{FutureExt as _, StreamExt as _, future::join_all}; -use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions}; +use gpui::{ + App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, TaskExt, WeakEntity, actions, +}; use http_client::HttpClient; use itertools::Itertools; use rand::Rng as _; @@ -605,10 +607,7 @@ impl ContextServerStore { let server = state.server(); let configuration = state.configuration(); - let mut result = Ok(()); - if let ContextServerState::Running { server, .. } = &state { - result = server.stop(); - } + let result = server.stop(); drop(state); self.update_server_state( diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 7ac9c02fe4f..e0594467f09 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -30,7 +30,7 @@ use futures::{ channel::mpsc::{self, UnboundedSender}, future::{Shared, join_all}, }; -use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, TaskExt}; use http_client::HttpClient; use language::{Buffer, LanguageToolchainStore}; use node_runtime::NodeRuntime; diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index feba6ff5520..39578eaf8f0 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -38,7 +38,7 @@ use futures::{AsyncBufReadExt as _, SinkExt, StreamExt, TryStreamExt}; use futures::{FutureExt, future::Shared}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, - Task, WeakEntity, + Task, TaskExt, WeakEntity, }; use http_client::HttpClient; use node_runtime::NodeRuntime; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 20facc32640..9f97f829b0c 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1,6 +1,7 @@ pub mod branch_diff; mod conflict_set; pub mod git_traversal; +pub mod job_debug_queue; pub mod pending_op; use crate::{ @@ -37,7 +38,7 @@ use git::{ CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, - Worktree as GitWorktree, + Worktree as GitWorktree, delete_branch_flag, }, stash::{GitStash, StashEntry}, status::{ @@ -47,7 +48,7 @@ use git::{ }; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, SharedString, - Subscription, Task, WeakEntity, + Subscription, Task, TaskExt, WeakEntity, }; use language::{ Buffer, BufferEvent, Language, LanguageRegistry, @@ -361,6 +362,7 @@ pub struct InitialGitGraphData { pub error: Option, pub commit_data: Vec>, pub commit_oid_to_index: HashMap, + subscribers: Vec>, SharedString>>>, } pub struct GraphDataResponse<'a> { @@ -379,6 +381,7 @@ pub struct Repository { paths_needing_status_update: Vec>, job_sender: mpsc::UnboundedSender, active_jobs: HashMap, + job_debug_queue: job_debug_queue::GitJobDebugQueue, pending_ops: SumTree, job_id: JobId, askpass_delegates: Arc>>, @@ -506,6 +509,7 @@ impl EventEmitter for Repository {} impl EventEmitter for GitStore {} pub struct GitJob { + id: JobId, job: Box Task<()>>, key: Option, } @@ -680,6 +684,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_edit_ref); client.add_entity_request_handler(Self::handle_repair_worktrees); client.add_entity_request_handler(Self::handle_get_commit_data); + client.add_entity_stream_request_handler(Self::handle_get_initial_graph_data); client.add_entity_stream_request_handler(Self::handle_search_commits); } @@ -1382,7 +1387,7 @@ impl GitStore { .to_string(); let rx = repo.update(cx, |repo, _| { - repo.send_job(None, move |state, cx| async move { + repo.send_job("get_permalink_to_line", None, move |state, cx| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let origin_url = backend @@ -1493,13 +1498,6 @@ impl GitStore { else { return; }; - if !worktree.read(cx).is_visible() { - log::debug!( - "not adding repositories for local worktree {:?} because it's not visible", - worktree.read(cx).abs_path() - ); - return; - } self.update_repositories_from_worktree( *worktree_id, project_environment.clone(), @@ -1850,6 +1848,10 @@ impl GitStore { if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event { let buffer_id = diff.read(cx).buffer_id; if let Some(diff_state) = self.diffs.get(&buffer_id) { + let new_index_text = new_index_text.as_ref().map(|rope| rope.to_string()); + if new_index_text.as_deref() == diff_state.read(cx).index_text.as_deref() { + return; + } let hunk_staging_operation_count = diff_state.update(cx, |diff_state, _| { diff_state.hunk_staging_operation_count += 1; diff_state.hunk_staging_operation_count @@ -1859,7 +1861,7 @@ impl GitStore { log::debug!("hunks changed for {}", path.as_unix_str()); repo.spawn_set_index_text_job( path, - new_index_text.as_ref().map(|rope| rope.to_string()), + new_index_text, Some(hunk_staging_operation_count), cx, ) @@ -2670,6 +2672,105 @@ impl GitStore { Ok(proto::GetCommitDataResponse { commits }) } + async fn handle_get_initial_graph_data( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result>> { + const CHUNK_SIZE: usize = git::repository::GRAPH_CHUNK_SIZE; + let payload = envelope.payload; + + let repository_id = RepositoryId::from_proto(payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let log_order = log_order_from_proto(payload.log_order()); + let log_source = log_source_from_proto( + payload + .log_source + .context("missing initial graph data log source")?, + )?; + + let (subscriber_sender, subscriber_receiver) = async_channel::unbounded(); + let (cached_commits, error, is_loading) = + repository_handle.update(&mut cx, |repository, cx| { + let response = + repository.graph_data(log_source.clone(), log_order, 0..usize::MAX, cx); + let cached_commits = response.commits.to_vec(); + let error = response.error.clone(); + let is_loading = response.is_loading; + + if is_loading { + if let Some(graph_data) = repository + .initial_graph_data + .get_mut(&(log_source.clone(), log_order)) + { + graph_data.subscribers.push(subscriber_sender); + } + } + + (cached_commits, error, is_loading) + }); + + let (mut response_tx, response_rx) = mpsc::unbounded(); + cx.background_spawn(async move { + if let Some(error) = error { + if response_tx + .send(Err(anyhow!(error.to_string()))) + .await + .is_err() + { + return; + } + return; + } + + for commits in cached_commits.chunks(CHUNK_SIZE) { + let response = proto::GetInitialGraphDataResponse { + commits: commits + .iter() + .map(|commit| initial_graph_commit_to_proto(commit)) + .collect(), + }; + if response_tx.send(Ok(response)).await.is_err() { + return; + } + } + + if !is_loading { + return; + } + + while let Ok(chunk_result) = subscriber_receiver.recv().await { + let commits = match chunk_result { + Ok(commits) => commits, + Err(error) => { + response_tx + .send(Err(anyhow!(error.to_string()))) + .await + .context("Failed to send error") + .log_err(); + return; + } + }; + + for commits in commits.chunks(CHUNK_SIZE) { + let response = proto::GetInitialGraphDataResponse { + commits: commits + .iter() + .map(|commit| initial_graph_commit_to_proto(commit)) + .collect(), + }; + if response_tx.send(Ok(response)).await.is_err() { + return; + } + } + } + }) + .detach(); + + Ok(response_rx) + } + async fn handle_search_commits( this: Entity, envelope: TypedEnvelope, @@ -2887,10 +2988,11 @@ impl GitStore { let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; let is_remote = envelope.payload.is_remote; let branch_name = envelope.payload.branch_name; + let force = envelope.payload.force; repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.delete_branch(is_remote, branch_name) + repository_handle.delete_branch(is_remote, branch_name, force) }) .await??; @@ -4413,23 +4515,7 @@ impl Repository { }); let (job_sender, state) = (refetch_repo_state)(cx); - - // todo(git_graph_remote): Make this subscription on both remote/local repo - cx.subscribe_self(move |this, event: &RepositoryEvent, _| match event { - RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { - if this.scan_id > 2 { - this.initial_graph_data.clear(); - } - } - RepositoryEvent::StashEntriesChanged => { - if this.scan_id > 2 { - this.initial_graph_data - .retain(|(log_source, _), _| *log_source != LogSource::All); - } - } - _ => {} - }) - .detach(); + cx.subscribe_self(Self::handle_subscribe_self).detach(); Repository { this: cx.weak_entity(), @@ -4444,6 +4530,7 @@ impl Repository { job_sender, job_id: 0, active_jobs: Default::default(), + job_debug_queue: job_debug_queue::GitJobDebugQueue::new(), initial_graph_data: Default::default(), commit_data: Default::default(), commit_data_handler: CommitDataHandlerState::Closed, @@ -4481,6 +4568,8 @@ impl Repository { }); let (job_sender, repository_state) = (refetch_repo_state)(cx); + cx.subscribe_self(Self::handle_subscribe_self).detach(); + Self { this: cx.weak_entity(), snapshot, @@ -4493,6 +4582,7 @@ impl Repository { askpass_delegates: Default::default(), latest_askpass_id: 0, active_jobs: Default::default(), + job_debug_queue: job_debug_queue::GitJobDebugQueue::new(), job_id: 0, initial_graph_data: Default::default(), commit_data: Default::default(), @@ -4501,6 +4591,25 @@ impl Repository { } } + fn handle_subscribe_self(&mut self, event: &RepositoryEvent, _: &mut Context) { + // scan id greater than 2 means the initial snapshot was calculated, + // otherwise we don't need to refresh the graph state + match event { + RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { + if self.scan_id > 2 { + self.initial_graph_data.clear(); + } + } + RepositoryEvent::StashEntriesChanged => { + if self.scan_id > 2 { + self.initial_graph_data + .retain(|(log_source, _), _| *log_source != LogSource::All); + } + } + _ => {} + } + } + pub fn git_store(&self) -> Option> { self.git_store.upgrade() } @@ -4509,6 +4618,7 @@ impl Repository { let this = cx.weak_entity(); let git_store = self.git_store.clone(); let _ = self.send_keyed_job( + "reload_buffer_diff_bases", Some(GitJobKey::ReloadBufferDiffBases), None, |state, mut cx| async move { @@ -4668,6 +4778,7 @@ impl Repository { pub fn send_job( &mut self, + description: &'static str, status: Option, job: F, ) -> oneshot::Receiver @@ -4676,11 +4787,12 @@ impl Repository { Fut: Future + 'static, R: Send + 'static, { - self.send_keyed_job(None, status, job) + self.send_keyed_job(description, None, status, job) } fn send_keyed_job( &mut self, + description: &'static str, key: Option, status: Option, job: F, @@ -4693,29 +4805,39 @@ impl Repository { let (result_tx, result_rx) = futures::channel::oneshot::channel(); let job_id = post_inc(&mut self.job_id); let this = self.this.clone(); + + let key_label = key.as_ref().map(format_job_key); + self.job_debug_queue.add(job_id, description, key_label); + self.job_sender .unbounded_send(GitJob { + id: job_id, key, job: Box::new(move |state, cx: &mut AsyncApp| { let job = job(state, cx.clone()); cx.spawn(async move |cx| { - if let Some(s) = status.clone() { - this.update(cx, |this, cx| { + this.update(cx, |this, cx| { + this.job_debug_queue.mark_running(job_id); + if let Some(s) = status { this.active_jobs.insert( job_id, JobInfo { start: Instant::now(), - message: s.clone(), + message: s, }, ); + } + cx.notify(); + }) + .ok(); - cx.notify(); - }) - .ok(); - } let result = job.await; this.update(cx, |this, cx| { + this.job_debug_queue.mark_complete( + job_id, + job_debug_queue::CompletedJobStatus::Finished, + ); this.active_jobs.remove(&job_id); cx.notify(); }) @@ -4798,43 +4920,47 @@ impl Repository { } let this = cx.weak_entity(); - let rx = self.send_job(None, move |state, mut cx| async move { - let Some(this) = this.upgrade() else { - bail!("git store was dropped"); - }; - match state { - RepositoryState::Local(..) => { - this.update(&mut cx, |_, cx| { - Self::open_local_commit_buffer(languages, buffer_store, cx) - }) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let request = client.request(proto::OpenCommitMessageBuffer { - project_id: project_id.0, - repository_id: id.to_proto(), - }); - let response = request.await.context("requesting to open commit buffer")?; - let buffer_id = BufferId::new(response.buffer_id)?; - let buffer = buffer_store - .update(&mut cx, |buffer_store, cx| { - buffer_store.wait_for_remote_buffer(buffer_id, cx) + let rx = self.send_job( + "open_commit_buffer", + None, + move |state, mut cx| async move { + let Some(this) = this.upgrade() else { + bail!("git store was dropped"); + }; + match state { + RepositoryState::Local(..) => { + this.update(&mut cx, |_, cx| { + Self::open_local_commit_buffer(languages, buffer_store, cx) }) - .await?; - if let Some(language_registry) = languages { - let git_commit_language = - language_registry.language_for_name("Git Commit").await?; - buffer.update(&mut cx, |buffer, cx| { - buffer.set_language(Some(git_commit_language), cx); - }); + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let request = client.request(proto::OpenCommitMessageBuffer { + project_id: project_id.0, + repository_id: id.to_proto(), + }); + let response = request.await.context("requesting to open commit buffer")?; + let buffer_id = BufferId::new(response.buffer_id)?; + let buffer = buffer_store + .update(&mut cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + }) + .await?; + if let Some(language_registry) = languages { + let git_commit_language = + language_registry.language_for_name("Git Commit").await?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(git_commit_language), cx); + }); + } + this.update(&mut cx, |this, _| { + this.commit_message_buffer = Some(buffer.clone()); + }); + Ok(buffer) } - this.update(&mut cx, |this, _| { - this.commit_message_buffer = Some(buffer.clone()); - }); - Ok(buffer) } - } - }); + }, + ); cx.spawn(|_, _: &mut AsyncApp| async move { rx.await? }) } @@ -4880,6 +5006,7 @@ impl Repository { async move |this, cx| { this.update(cx, |this, _cx| { this.send_job( + "checkout_files", Some(format!("git checkout {}", commit).into()), move |git_repo, _| async move { match git_repo { @@ -4927,7 +5054,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, _| async move { + self.send_job("reset", None, move |git_repo, _| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -4955,7 +5082,7 @@ impl Repository { pub fn show(&mut self, commit: String) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, _cx| async move { + self.send_job("show", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.show(commit).await @@ -4983,7 +5110,7 @@ impl Repository { pub fn load_commit_diff(&mut self, commit: String) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |git_repo, cx| async move { + self.send_job("load_commit_diff", None, move |git_repo, cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.load_commit(commit, cx).await @@ -5121,28 +5248,51 @@ impl Repository { ) .await } - Ok(RepositoryState::Remote(_)) => { - Err("Git graph is not supported for collab yet".into()) + Ok(RepositoryState::Remote(remote)) => { + Self::remote_git_graph_data( + repository.clone(), + remote, + log_source.clone(), + log_order, + cx, + ) + .await } Err(e) => Err(SharedString::from(e)), }; - if let Err(fetch_task_error) = result { - repository - .update(cx, |repository, _| { - if let Some(data) = repository - .initial_graph_data - .get_mut(&(log_source, log_order)) - { - data.error = Some(fetch_task_error); - } else { - debug_panic!( - "This task would be dropped if this entry doesn't exist" - ); + repository + .update(cx, |repository, cx| { + if let Some(data) = repository + .initial_graph_data + .get_mut(&(log_source.clone(), log_order)) + { + match &result { + Ok(()) => { + cx.emit(RepositoryEvent::GraphEvent( + (log_source.clone(), log_order), + GitGraphEvent::FullyLoaded, + )); + } + Err(fetch_task_error) => { + data.subscribers.retain(|sender| { + sender.try_send(Err(fetch_task_error.clone())).is_ok() + }); + data.error = Some(fetch_task_error.clone()); + cx.emit(RepositoryEvent::GraphEvent( + (log_source.clone(), log_order), + GitGraphEvent::LoadingError, + )); + } } - }) - .ok(); - } + data.subscribers.clear(); + } else { + debug_panic!( + "This task would be dropped if this entry doesn't exist" + ); + } + }) + .log_err(); }); InitialGitGraphData { @@ -5150,6 +5300,7 @@ impl Repository { error: None, commit_data: Vec::new(), commit_oid_to_index: HashMap::default(), + subscribers: Vec::new(), } }); @@ -5164,6 +5315,47 @@ impl Repository { } } + async fn append_initial_graph_commits( + this: &WeakEntity, + graph_data_key: &(LogSource, LogOrder), + initial_graph_commit_data: Vec>, + cx: &mut AsyncApp, + ) { + this.update(cx, |repository, cx| { + let graph_data = repository + .initial_graph_data + .entry(graph_data_key.clone()) + .and_modify(|graph_data| { + if !graph_data.subscribers.is_empty() { + graph_data.subscribers.retain(|sender| { + sender + .try_send(Ok(initial_graph_commit_data.clone())) + .is_ok() + }); + } + + for commit_data in initial_graph_commit_data { + graph_data + .commit_oid_to_index + .insert(commit_data.sha, graph_data.commit_data.len()); + graph_data.commit_data.push(commit_data); + } + cx.emit(RepositoryEvent::GraphEvent( + graph_data_key.clone(), + GitGraphEvent::CountUpdated(graph_data.commit_data.len()), + )); + }); + + match &graph_data { + Entry::Occupied(_) => {} + Entry::Vacant(_) => { + debug_panic!("This task should be dropped if data doesn't exist"); + } + } + }) + .log_err(); + } + async fn local_git_graph_data( this: WeakEntity, backend: Arc, @@ -5187,37 +5379,55 @@ impl Repository { let graph_data_key = (log_source, log_order); while let Ok(initial_graph_commit_data) = request_rx.recv().await { - this.update(cx, |repository, cx| { - let graph_data = repository - .initial_graph_data - .entry(graph_data_key.clone()) - .and_modify(|graph_data| { - for commit_data in initial_graph_commit_data { - graph_data - .commit_oid_to_index - .insert(commit_data.sha, graph_data.commit_data.len()); - graph_data.commit_data.push(commit_data); - } - cx.emit(RepositoryEvent::GraphEvent( - graph_data_key.clone(), - GitGraphEvent::CountUpdated(graph_data.commit_data.len()), - )); - }); - - match &graph_data { - Entry::Occupied(_) => {} - Entry::Vacant(_) => { - debug_panic!("This task should be dropped if data doesn't exist"); - } - } - }) - .ok(); + Self::append_initial_graph_commits( + &this, + &graph_data_key, + initial_graph_commit_data, + cx, + ) + .await; } task.await?; Ok(()) } + async fn remote_git_graph_data( + this: WeakEntity, + remote: RemoteRepositoryState, + log_source: LogSource, + log_order: LogOrder, + cx: &mut AsyncApp, + ) -> Result<(), SharedString> { + let repository_id = this + .update(cx, |repository, _| repository.id) + .map_err(|err| SharedString::from(err.to_string()))?; + let graph_data_key = (log_source.clone(), log_order); + let mut response = remote + .client + .request_stream(proto::GetInitialGraphData { + project_id: remote.project_id.to_proto(), + repository_id: repository_id.to_proto(), + log_source: Some(log_source_to_proto(&log_source)), + log_order: log_order_to_proto(log_order), + }) + .await + .map_err(|err| SharedString::from(err.to_string()))?; + + while let Some(response) = response.next().await { + let response = response.map_err(|err| SharedString::from(err.to_string()))?; + let commits = response + .commits + .into_iter() + .map(initial_graph_commit_from_proto) + .collect::>>() + .map_err(|err| SharedString::from(err.to_string()))?; + Self::append_initial_graph_commits(&this, &graph_data_key, commits, cx).await; + } + + Ok(()) + } + pub fn fetch_commit_data( &mut self, sha: Oid, @@ -5686,6 +5896,7 @@ impl Repository { this.update(cx, |this, cx| { let weak_this = cx.weak_entity(); this.send_keyed_job( + "stage_or_unstage_entries", Some(job_key), Some(status.into()), move |git_repo, mut cx| async move { @@ -5912,7 +6123,7 @@ impl Repository { cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_entries", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -5948,7 +6159,7 @@ impl Repository { let id = self.id; cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_pop", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -5982,7 +6193,7 @@ impl Repository { let id = self.id; cx.spawn(async move |this, cx| { this.update(cx, |this, _| { - this.send_job(None, move |git_repo, _cx| async move { + this.send_job("stash_apply", None, move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6021,40 +6232,44 @@ impl Repository { path_display.to_string() }; - self.send_job(None, move |git_repo, _cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { fs, .. }) => { - let gitignore_path = work_dir.join(".gitignore"); + self.send_job( + "add_path_to_gitignore", + None, + move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { fs, .. }) => { + let gitignore_path = work_dir.join(".gitignore"); - let existing_content = fs.load(&gitignore_path).await.unwrap_or_default(); + let existing_content = fs.load(&gitignore_path).await.unwrap_or_default(); - if existing_content - .lines() - .any(|line| line.trim() == file_path_str) - { - return Ok(()); + if existing_content + .lines() + .any(|line| line.trim() == file_path_str) + { + return Ok(()); + } + + let new_content = if existing_content.is_empty() { + format!("{}\n", file_path_str) + } else if existing_content.ends_with('\n') { + format!("{}{}\n", existing_content, file_path_str) + } else { + format!("{}\n{}\n", existing_content, file_path_str) + }; + + fs.save( + &gitignore_path, + &text::Rope::from(new_content.as_str()), + text::LineEnding::Unix, + ) + .await } - - let new_content = if existing_content.is_empty() { - format!("{}\n", file_path_str) - } else if existing_content.ends_with('\n') { - format!("{}{}\n", existing_content, file_path_str) - } else { - format!("{}\n{}\n", existing_content, file_path_str) - }; - - fs.save( - &gitignore_path, - &text::Rope::from(new_content.as_str()), - text::LineEnding::Unix, - ) - .await + RepositoryState::Remote(_) => Err(anyhow::anyhow!( + "Cannot modify .gitignore on remote repository" + )), } - RepositoryState::Remote(_) => Err(anyhow::anyhow!( - "Cannot modify .gitignore on remote repository" - )), - } - }) + }, + ) } pub fn stash_drop( @@ -6072,7 +6287,7 @@ impl Repository { _ => None, }); let this = cx.weak_entity(); - self.send_job(None, move |git_repo, mut cx| async move { + self.send_job("stash_drop", None, move |git_repo, mut cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { backend, @@ -6116,6 +6331,7 @@ impl Repository { pub fn run_hook(&mut self, hook: RunHook, _cx: &mut App) -> oneshot::Receiver> { let id = self.id; self.send_job( + "run_hook", Some(format!("git hook {}", hook.as_str()).into()), move |git_repo, _cx| async move { match git_repo { @@ -6154,46 +6370,50 @@ impl Repository { let rx = self.run_hook(RunHook::PreCommit, cx); - self.send_job(Some("git commit".into()), move |git_repo, _cx| async move { - rx.await??; + self.send_job( + "commit", + Some("git commit".into()), + move |git_repo, _cx| async move { + rx.await??; - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => { - backend - .commit(message, name_and_email, options, askpass, environment) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); - let (name, email) = name_and_email.unzip(); - client - .request(proto::Commit { - project_id: project_id.0, - repository_id: id.to_proto(), - message: String::from(message), - name: name.map(String::from), - email: email.map(String::from), - options: Some(proto::commit::CommitOptions { - amend: options.amend, - signoff: options.signoff, - allow_empty: options.allow_empty, - }), - askpass_id, - }) - .await?; + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => { + backend + .commit(message, name_and_email, options, askpass, environment) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); + let (name, email) = name_and_email.unzip(); + client + .request(proto::Commit { + project_id: project_id.0, + repository_id: id.to_proto(), + message: String::from(message), + name: name.map(String::from), + email: email.map(String::from), + options: Some(proto::commit::CommitOptions { + amend: options.amend, + signoff: options.signoff, + allow_empty: options.allow_empty, + }), + askpass_id, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn fetch( @@ -6206,36 +6426,40 @@ impl Repository { let askpass_id = util::post_inc(&mut self.latest_askpass_id); let id = self.id; - self.send_job(Some("git fetch".into()), move |git_repo, cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => backend.fetch(fetch_options, askpass, environment, cx).await, - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); + self.send_job( + "fetch", + Some("git fetch".into()), + move |git_repo, cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => backend.fetch(fetch_options, askpass, environment, cx).await, + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); - let response = client - .request(proto::Fetch { - project_id: project_id.0, - repository_id: id.to_proto(), - askpass_id, - remote: fetch_options.to_proto(), + let response = client + .request(proto::Fetch { + project_id: project_id.0, + repository_id: id.to_proto(), + askpass_id, + remote: fetch_options.to_proto(), + }) + .await?; + + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, }) - .await?; - - Ok(RemoteCommandOutput { - stdout: response.stdout, - stderr: response.stderr, - }) + } } - } - }) + }, + ) } pub fn push( @@ -6269,6 +6493,7 @@ impl Repository { let this = cx.weak_entity(); self.send_job( + "push", Some(format!("git push {} {} {}:{}", args, remote, branch, remote_branch).into()), move |git_repo, mut cx| async move { match git_repo { @@ -6361,48 +6586,52 @@ impl Repository { status.push_str(&format!(" {}", b)); } - self.send_job(Some(status.into()), move |git_repo, cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { - backend, - environment, - .. - }) => { - backend - .pull( - branch.as_ref().map(|b| b.to_string()), - remote.to_string(), - rebase, - askpass, - environment.clone(), - cx, - ) - .await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - askpass_delegates.lock().insert(askpass_id, askpass); - let _defer = util::defer(|| { - let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); - debug_assert!(askpass_delegate.is_some()); - }); - let response = client - .request(proto::Pull { - project_id: project_id.0, - repository_id: id.to_proto(), - askpass_id, - rebase, - branch_name: branch.as_ref().map(|b| b.to_string()), - remote_name: remote.to_string(), - }) - .await?; + self.send_job( + "pull", + Some(status.into()), + move |git_repo, cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => { + backend + .pull( + branch.as_ref().map(|b| b.to_string()), + remote.to_string(), + rebase, + askpass, + environment.clone(), + cx, + ) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); + let response = client + .request(proto::Pull { + project_id: project_id.0, + repository_id: id.to_proto(), + askpass_id, + rebase, + branch_name: branch.as_ref().map(|b| b.to_string()), + remote_name: remote.to_string(), + }) + .await?; - Ok(RemoteCommandOutput { - stdout: response.stdout, - stderr: response.stderr, - }) + Ok(RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) + } } - } - }) + }, + ) } fn spawn_set_index_text_job( @@ -6417,6 +6646,7 @@ impl Repository { let git_store = self.git_store.clone(); let abs_path = self.snapshot.repo_path_to_abs_path(&path); self.send_keyed_job( + "spawn_set_index_text_job", Some(GitJobKey::WriteIndex(vec![path.clone()])), None, move |git_repo, mut cx| async move { @@ -6491,6 +6721,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "create_remote", Some(format!("git remote add {remote_name} {remote_url}").into()), move |repo, _cx| async move { match repo { @@ -6517,6 +6748,7 @@ impl Repository { pub fn remove_remote(&mut self, remote_name: String) -> oneshot::Receiver> { let id = self.id; self.send_job( + "remove_remote", Some(format!("git remove remote {remote_name}").into()), move |repo, _cx| async move { match repo { @@ -6545,7 +6777,7 @@ impl Repository { is_push: bool, ) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("get_remotes", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let remote = if let Some(branch_name) = branch_name { @@ -6589,7 +6821,7 @@ impl Repository { pub fn branches(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("branches", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.branches().await @@ -6635,15 +6867,20 @@ impl Repository { .unwrap_or(self.common_dir_abs_path.as_ref()); let project_name = repository_anchor .file_name() + .and_then(|name| name.to_str()) .ok_or_else(|| anyhow!("git repo must have a directory name"))?; - let directory = - worktrees_directory_for_repo(repository_anchor, worktree_directory_setting)?; - Ok(directory.join(branch_name).join(project_name)) + let directory = worktrees_directory_for_repo( + repository_anchor, + worktree_directory_setting, + self.path_style, + )?; + let directory = self.path_style.join_path(&directory, branch_name)?; + self.path_style.join_path(&directory, project_name) } pub fn worktrees(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("worktrees", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.worktrees().await @@ -6678,38 +6915,42 @@ impl Repository { Some(branch_name) => format!("git worktree add: {branch_name}"), None => "git worktree add (detached)".to_string(), }; - self.send_job(Some(job_description.into()), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_worktree(target, path).await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let (name, commit, use_existing_branch) = match target { - CreateWorktreeTarget::ExistingBranch { branch_name } => { - (Some(branch_name), None, true) - } - CreateWorktreeTarget::NewBranch { - branch_name, - base_sha, - } => (Some(branch_name), base_sha, false), - CreateWorktreeTarget::Detached { base_sha } => (None, base_sha, false), - }; + self.send_job( + "create_worktree", + Some(job_description.into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_worktree(target, path).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let (name, commit, use_existing_branch) = match target { + CreateWorktreeTarget::ExistingBranch { branch_name } => { + (Some(branch_name), None, true) + } + CreateWorktreeTarget::NewBranch { + branch_name, + base_sha, + } => (Some(branch_name), base_sha, false), + CreateWorktreeTarget::Detached { base_sha } => (None, base_sha, false), + }; - client - .request(proto::GitCreateWorktree { - project_id: project_id.0, - repository_id: id.to_proto(), - name: name.unwrap_or_default(), - directory: path.to_string_lossy().to_string(), - commit, - use_existing_branch, - }) - .await?; + client + .request(proto::GitCreateWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + name: name.unwrap_or_default(), + directory: path.to_string_lossy().to_string(), + commit, + use_existing_branch, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn create_worktree_detached( @@ -6736,24 +6977,30 @@ impl Repository { } else { format!("git checkout {branch_name}") }; - self.send_job(Some(description.into()), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend - .checkout_branch_in_worktree(branch_name, worktree_path, create) - .await + self.send_job( + "checkout_branch_in_worktree", + Some(description.into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend + .checkout_branch_in_worktree(branch_name, worktree_path, create) + .await + } + RepositoryState::Remote(_) => { + log::warn!( + "checkout_branch_in_worktree not supported for remote repositories" + ); + Ok(()) + } } - RepositoryState::Remote(_) => { - log::warn!("checkout_branch_in_worktree not supported for remote repositories"); - Ok(()) - } - } - }) + }, + ) } pub fn head_sha(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("head_sha", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { Ok(backend.head_sha().await) @@ -6778,7 +7025,7 @@ impl Repository { commit: Option, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("edit_ref", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => match commit { Some(commit) => backend.update_ref(ref_name, commit).await, @@ -6819,7 +7066,7 @@ impl Repository { pub fn repair_worktrees(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("repair_worktrees", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.repair_worktrees().await @@ -6839,22 +7086,26 @@ impl Repository { pub fn create_archive_checkpoint(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_archive_checkpoint().await + self.send_job( + "create_archive_checkpoint", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_archive_checkpoint().await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::GitCreateArchiveCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; + Ok((response.staged_commit_sha, response.unstaged_commit_sha)) + } } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let response = client - .request(proto::GitCreateArchiveCheckpoint { - project_id: project_id.0, - repository_id: id.to_proto(), - }) - .await?; - Ok((response.staged_commit_sha, response.unstaged_commit_sha)) - } - } - }) + }, + ) } pub fn restore_archive_checkpoint( @@ -6863,26 +7114,30 @@ impl Repository { unstaged_sha: String, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend - .restore_archive_checkpoint(staged_sha, unstaged_sha) - .await + self.send_job( + "restore_archive_checkpoint", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend + .restore_archive_checkpoint(staged_sha, unstaged_sha) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRestoreArchiveCheckpoint { + project_id: project_id.0, + repository_id: id.to_proto(), + staged_commit_sha: staged_sha, + unstaged_commit_sha: unstaged_sha, + }) + .await?; + Ok(()) + } } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - client - .request(proto::GitRestoreArchiveCheckpoint { - project_id: project_id.0, - repository_id: id.to_proto(), - staged_commit_sha: staged_sha, - unstaged_commit_sha: unstaged_sha, - }) - .await?; - Ok(()) - } - } - }) + }, + ) } pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { @@ -6893,6 +7148,7 @@ impl Repository { .unwrap_or(self.snapshot.common_dir_abs_path.as_ref()) .into(); self.send_job( + "remove_worktree", Some(format!("git worktree remove: {}", path.display()).into()), move |repo, cx| async move { match repo { @@ -6934,7 +7190,12 @@ impl Repository { let managed_worktree_base = cx.update(|cx| { let setting = &ProjectSettings::get_global(cx).git.worktree_directory; - worktrees_directory_for_repo(&repository_anchor_path, setting).log_err() + worktrees_directory_for_repo( + &repository_anchor_path, + setting, + PathStyle::local(), + ) + .log_err() }); if let Some(managed_worktree_base) = managed_worktree_base { @@ -6972,6 +7233,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "rename_worktree", Some(format!("git worktree move: {}", old_path.display()).into()), move |repo, _cx| async move { match repo { @@ -7000,7 +7262,7 @@ impl Repository { include_remote_name: bool, ) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _| async move { + self.send_job("default_branch", None, move |repo, _| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.default_branch(include_remote_name).await @@ -7025,7 +7287,7 @@ impl Repository { _cx: &App, ) -> oneshot::Receiver> { let repository_id = self.snapshot.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff_tree", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.diff_tree(diff_type).await @@ -7081,7 +7343,7 @@ impl Repository { pub fn diff(&mut self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.diff(diff_type).await @@ -7125,30 +7387,35 @@ impl Repository { } else { format!("git switch -c {branch_name}").into() }; - self.send_job(Some(status_msg), move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_branch(branch_name, base_branch).await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - client - .request(proto::GitCreateBranch { - project_id: project_id.0, - repository_id: id.to_proto(), - branch_name, - base_branch, - }) - .await?; + self.send_job( + "create_branch", + Some(status_msg), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_branch(branch_name, base_branch).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateBranch { + project_id: project_id.0, + repository_id: id.to_proto(), + branch_name, + base_branch, + }) + .await?; - Ok(()) + Ok(()) + } } - } - }) + }, + ) } pub fn change_branch(&mut self, branch_name: String) -> oneshot::Receiver> { let id = self.id; self.send_job( + "change_branch", Some(format!("git switch {branch_name}").into()), move |repo, _cx| async move { match repo { @@ -7175,21 +7442,20 @@ impl Repository { &mut self, is_remote: bool, branch_name: String, + force: bool, ) -> oneshot::Receiver> { let id = self.id; + let flag = delete_branch_flag(is_remote, force); self.send_job( - Some( - format!( - "git branch {} {}", - if is_remote { "-dr" } else { "-d" }, - branch_name - ) - .into(), - ), + "delete_branch", + Some(format!("git branch {flag} {branch_name}").into()), move |repo, _cx| async move { match repo { RepositoryState::Local(state) => { - state.backend.delete_branch(is_remote, branch_name).await + state + .backend + .delete_branch(is_remote, branch_name, force) + .await } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { client @@ -7198,6 +7464,7 @@ impl Repository { repository_id: id.to_proto(), is_remote, branch_name, + force, }) .await?; @@ -7215,6 +7482,7 @@ impl Repository { ) -> oneshot::Receiver> { let id = self.id; self.send_job( + "rename_branch", Some(format!("git branch -m {branch} {new_name}").into()), move |repo, _cx| async move { match repo { @@ -7240,30 +7508,34 @@ impl Repository { pub fn check_for_pushed_commits(&mut self) -> oneshot::Receiver>> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.check_for_pushed_commit().await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let response = client - .request(proto::CheckForPushedCommits { - project_id: project_id.0, - repository_id: id.to_proto(), - }) - .await?; + self.send_job( + "check_for_pushed_commits", + None, + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.check_for_pushed_commit().await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let response = client + .request(proto::CheckForPushedCommits { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await?; - let branches = response.pushed_to.into_iter().map(Into::into).collect(); + let branches = response.pushed_to.into_iter().map(Into::into).collect(); - Ok(branches) + Ok(branches) + } } - } - }) + }, + ) } pub fn checkpoint(&mut self) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("checkpoint", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.checkpoint().await @@ -7289,7 +7561,7 @@ impl Repository { checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("restore_checkpoint", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.restore_checkpoint(checkpoint).await @@ -7411,7 +7683,7 @@ impl Repository { right: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("compare_checkpoints", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.compare_checkpoints(left, right).await @@ -7437,7 +7709,7 @@ impl Repository { target_checkpoint: GitRepositoryCheckpoint, ) -> oneshot::Receiver> { let id = self.id; - self.send_job(None, move |repo, _cx| async move { + self.send_job("diff_checkpoints", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend @@ -7492,6 +7764,7 @@ impl Repository { ) { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "schedule_scan", Some(GitJobKey::ReloadGitState), None, |state, mut cx| async move { @@ -7523,7 +7796,7 @@ impl Repository { ) -> mpsc::UnboundedSender { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |_, cx| { + cx.spawn(async move |this, cx| { let state = state.await.map_err(|err| anyhow::anyhow!(err))?; if let Some(git_hosting_provider_registry) = cx.update(|cx| GitHostingProviderRegistry::try_global(cx)) @@ -7547,6 +7820,14 @@ impl Repository { .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) { + let skipped_job_id = job.id; + this.update(cx, |repo, _| { + repo.job_debug_queue.mark_complete( + skipped_job_id, + job_debug_queue::CompletedJobStatus::Skipped, + ); + }) + .ok(); continue; } (job.job)(state.clone(), cx).await; @@ -7569,7 +7850,7 @@ impl Repository { ) -> mpsc::UnboundedSender { let (job_tx, mut job_rx) = mpsc::unbounded::(); - cx.spawn(async move |_, cx| { + cx.spawn(async move |this, cx| { let state = RepositoryState::Remote(state); let mut jobs = VecDeque::new(); loop { @@ -7583,6 +7864,14 @@ impl Repository { .iter() .any(|other_job| other_job.key.as_ref() == Some(current_key)) { + let skipped_job_id = job.id; + this.update(cx, |repo, _| { + repo.job_debug_queue.mark_complete( + skipped_job_id, + job_debug_queue::CompletedJobStatus::Skipped, + ); + }) + .ok(); continue; } (job.job)(state.clone(), cx).await; @@ -7605,7 +7894,7 @@ impl Repository { repo_path: RepoPath, cx: &App, ) -> Task>> { - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_staged_text", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { anyhow::Ok(backend.load_index_text(repo_path).await) @@ -7630,7 +7919,7 @@ impl Repository { repo_path: RepoPath, cx: &App, ) -> Task> { - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_committed_text", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let committed_text = backend.load_committed_text(repo_path.clone()).await; @@ -7673,19 +7962,23 @@ impl Repository { pub fn load_commit_template_text( &mut self, ) -> oneshot::Receiver>> { - self.send_job(None, move |git_repo, _cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.load_commit_template().await + self.send_job( + "load_commit_template_text", + None, + move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.load_commit_template().await + } + RepositoryState::Remote(_) => Ok(None), } - RepositoryState::Remote(_) => Ok(None), - } - }) + }, + ) } fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task> { let repository_id = self.snapshot.id; - let rx = self.send_job(None, move |state, _| async move { + let rx = self.send_job("load_blob_content", None, move |state, _| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.load_blob_content(oid).await @@ -7717,6 +8010,7 @@ impl Repository { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "paths_changed", Some(GitJobKey::RefreshStatuses), None, |state, mut cx| async move { @@ -7823,8 +8117,12 @@ impl Repository { self.active_jobs.values().next().cloned() } + pub fn job_debug_queue(&self) -> &job_debug_queue::GitJobDebugQueue { + &self.job_debug_queue + } + pub fn barrier(&mut self) -> oneshot::Receiver<()> { - self.send_job(None, |_, _| async {}) + self.send_job("barrier", None, |_, _| async {}) } fn spawn_job_with_tracking( @@ -7894,7 +8192,7 @@ impl Repository { } pub fn access(&mut self, _cx: &App) -> oneshot::Receiver { - self.send_job(None, move |git_repo, _cx| async move { + self.send_job("access", None, move |git_repo, _cx| async move { match git_repo { // TODO: Correctly handle remote repositories, where the user // that's running the Zed remote may not own the `.git/` @@ -7916,6 +8214,24 @@ impl Repository { } } +fn format_job_key(key: &GitJobKey) -> SharedString { + match key { + GitJobKey::WriteIndex(paths) => { + let paths_str: Vec<_> = paths + .iter() + .map(|p| { + let rel: &RelPath = p; + format!("{}", AsRef::::as_ref(rel).display()) + }) + .collect(); + format!("WriteIndex({})", paths_str.join(", ")).into() + } + GitJobKey::ReloadBufferDiffBases => "ReloadBufferDiffBases".into(), + GitJobKey::RefreshStatuses => "RefreshStatuses".into(), + GitJobKey::ReloadGitState => "ReloadGitState".into(), + } +} + /// If `path` is a git linked worktree checkout, resolves it to the main /// repository's identity path. For regular linked worktrees this is the main /// repository's working directory; for linked worktrees backed by a bare repo @@ -7956,14 +8272,14 @@ pub async fn resolve_git_worktree_to_main_repo(fs: &dyn Fs, path: &Path) -> Opti pub fn worktrees_directory_for_repo( repository_anchor_path: &Path, worktree_directory_setting: &str, + path_style: PathStyle, ) -> Result { // Check the original setting before trimming, since a path like "///" // is absolute but becomes "" after stripping trailing separators. // Also check for leading `/` or `\` explicitly, because on Windows // `Path::is_absolute()` requires a drive letter — so `/tmp/worktrees` // would slip through even though it's clearly not a relative path. - if Path::new(worktree_directory_setting).is_absolute() - || worktree_directory_setting.starts_with('/') + if path_style.is_absolute(worktree_directory_setting) || worktree_directory_setting.starts_with('\\') { anyhow::bail!( @@ -7980,12 +8296,19 @@ pub fn worktrees_directory_for_repo( anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)"); } - let joined = repository_anchor_path.join(trimmed); - let resolved = util::normalize_path(&joined); + let joined = path_style.join_path(repository_anchor_path, trimmed)?; + let resolved = if path_style.is_posix() { + joined + } else { + util::normalize_path(&joined) + }; let resolved = if resolved.starts_with(repository_anchor_path) { resolved - } else if let Some(repo_dir_name) = repository_anchor_path.file_name() { - resolved.join(repo_dir_name) + } else if let Some(repo_dir_name) = repository_anchor_path + .file_name() + .and_then(|name| name.to_str()) + { + path_style.join_path(&resolved, repo_dir_name)? } else { resolved }; @@ -8249,6 +8572,65 @@ fn log_source_from_proto(log_source: proto::GitLogSource) -> Result { } } +fn log_order_to_proto(log_order: LogOrder) -> i32 { + match log_order { + LogOrder::DateOrder => proto::get_initial_graph_data::LogOrder::DateOrder as i32, + LogOrder::TopoOrder => proto::get_initial_graph_data::LogOrder::TopoOrder as i32, + LogOrder::AuthorDateOrder => { + proto::get_initial_graph_data::LogOrder::AuthorDateOrder as i32 + } + LogOrder::ReverseChronological => { + proto::get_initial_graph_data::LogOrder::ReverseChronological as i32 + } + } +} + +fn log_order_from_proto(log_order: proto::get_initial_graph_data::LogOrder) -> LogOrder { + match log_order { + proto::get_initial_graph_data::LogOrder::DateOrder => LogOrder::DateOrder, + proto::get_initial_graph_data::LogOrder::TopoOrder => LogOrder::TopoOrder, + proto::get_initial_graph_data::LogOrder::AuthorDateOrder => LogOrder::AuthorDateOrder, + proto::get_initial_graph_data::LogOrder::ReverseChronological => { + LogOrder::ReverseChronological + } + } +} + +fn initial_graph_commit_to_proto(commit: &InitialGraphCommitData) -> proto::InitialGraphCommit { + proto::InitialGraphCommit { + sha: commit.sha.to_string(), + parents: commit + .parents + .iter() + .map(|parent| parent.to_string()) + .collect(), + ref_names: commit + .ref_names + .iter() + .map(|ref_name| ref_name.to_string()) + .collect(), + } +} + +fn initial_graph_commit_from_proto( + commit: proto::InitialGraphCommit, +) -> Result> { + let sha = Oid::from_str(&commit.sha)?; + let mut parents = SmallVec::with_capacity(commit.parents.len()); + for parent in &commit.parents { + parents.push(Oid::from_str(parent)?); + } + Ok(Arc::new(InitialGraphCommitData { + sha, + parents, + ref_names: commit + .ref_names + .into_iter() + .map(SharedString::from) + .collect(), + })) +} + fn commit_data_to_proto(commit: &CommitData) -> proto::CommitData { proto::CommitData { sha: commit.sha.to_string(), @@ -8411,7 +8793,7 @@ mod tests { use rand::{SeedableRng, rngs::StdRng}; use serde_json::json; use settings::SettingsStore; - use std::path::Path; + use std::path::{Path, PathBuf}; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -8420,6 +8802,22 @@ mod tests { }); } + #[test] + fn test_new_worktree_path_uses_posix_style_for_remote_paths() { + let work_dir = Path::new("/home/user/dev/lsp-tests"); + let directory = + worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap(); + let directory = PathStyle::Posix + .join_path(&directory, "nimble-sky") + .unwrap(); + let path = PathStyle::Posix.join_path(&directory, "lsp-tests").unwrap(); + + assert_eq!( + path, + PathBuf::from("/home/user/dev/worktrees/lsp-tests/nimble-sky/lsp-tests") + ); + } + fn verify_invariants(repository: &Repository) -> anyhow::Result<()> { match &repository.commit_data_handler { CommitDataHandlerState::Open(handler) => { diff --git a/crates/project/src/git_store/job_debug_queue.rs b/crates/project/src/git_store/job_debug_queue.rs new file mode 100644 index 00000000000..c204451d58b --- /dev/null +++ b/crates/project/src/git_store/job_debug_queue.rs @@ -0,0 +1,222 @@ +use std::{collections::VecDeque, time::Instant}; + +use gpui::SharedString; + +use super::JobId; + +pub struct GitJobDebugQueue { + pending: VecDeque, + running: VecDeque, + completed: VecDeque, +} + +const MAX_COMPLETED_JOBS: usize = 500; + +#[derive(Clone, Debug)] +pub struct PendingJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, +} + +#[derive(Clone, Debug)] +pub struct RunningJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, + pub started_at: Instant, +} + +#[derive(Clone, Debug)] +pub struct CompletedJob { + pub id: JobId, + pub description: SharedString, + pub key: Option, + pub enqueued_at: Instant, + pub started_at: Option, + pub completed_at: Instant, + pub status: CompletedJobStatus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompletedJobStatus { + Finished, + Skipped, +} + +impl GitJobDebugQueue { + pub fn new() -> Self { + Self { + pending: VecDeque::new(), + running: VecDeque::new(), + completed: VecDeque::new(), + } + } + + pub fn add(&mut self, id: JobId, description: &'static str, key: Option) { + self.pending.push_back(PendingJob { + id, + description: description.into(), + key, + enqueued_at: Instant::now(), + }); + } + + pub fn mark_running(&mut self, id: JobId) { + let Some(index) = self.pending.iter().position(|job| job.id == id) else { + return; + }; + // Safe to unwrap: `index` was just found by `position()`, so it's in bounds. + let pending = self.pending.remove(index).unwrap(); + + self.running.push_back(RunningJob { + id: pending.id, + description: pending.description, + key: pending.key, + enqueued_at: pending.enqueued_at, + started_at: Instant::now(), + }); + } + + pub fn mark_complete(&mut self, id: JobId, status: CompletedJobStatus) { + let (enqueued_at, started_at, description, key) = + if let Some(index) = self.running.iter().position(|job| job.id == id) { + let running = self.running.remove(index).unwrap(); + ( + running.enqueued_at, + Some(running.started_at), + running.description, + running.key, + ) + } else if let Some(index) = self.pending.iter().position(|job| job.id == id) { + let pending = self.pending.remove(index).unwrap(); + (pending.enqueued_at, None, pending.description, pending.key) + } else { + return; + }; + + self.completed.push_back(CompletedJob { + id, + description, + key, + enqueued_at, + started_at, + completed_at: Instant::now(), + status, + }); + + while self.completed.len() > MAX_COMPLETED_JOBS { + self.completed.pop_front(); + } + } + + pub fn to_debug_string(&self) -> String { + let mut entries = Vec::new(); + + let mut pending_count = 0u64; + let mut running_count = 0u64; + let mut finished_count = 0u64; + let mut skipped_count = 0u64; + + for job in &self.pending { + pending_count += 1; + entries.push((job.enqueued_at, self.format_pending(job))); + } + for job in &self.running { + running_count += 1; + entries.push((job.enqueued_at, self.format_running(job))); + } + for job in &self.completed { + match job.status { + CompletedJobStatus::Finished => finished_count += 1, + CompletedJobStatus::Skipped => skipped_count += 1, + } + entries.push((job.enqueued_at, self.format_completed(job))); + } + + entries.sort_by_key(|(enqueued_at, _)| *enqueued_at); + + let json_entries: Vec = + entries.into_iter().map(|(_, json)| json).collect(); + + let json = serde_json::json!({ + "summary": { + "pending": pending_count, + "running": running_count, + "finished": finished_count, + "skipped": skipped_count, + }, + "entries": json_entries, + }); + + serde_json::to_string_pretty(&json).unwrap_or_default() + } + + fn format_pending(&self, job: &PendingJob) -> serde_json::Value { + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": "Pending", + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + }) + } + + fn format_running(&self, job: &RunningJob) -> serde_json::Value { + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": "Running", + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + "wait_time": format_duration(job.started_at.duration_since(job.enqueued_at)), + "run_time": format!("{} (still running)", format_duration(job.started_at.elapsed())), + }) + } + + fn format_completed(&self, job: &CompletedJob) -> serde_json::Value { + let status = match job.status { + CompletedJobStatus::Finished => "Finished", + CompletedJobStatus::Skipped => "Skipped", + }; + + let (wait_time, run_time) = if let Some(started) = job.started_at { + let wait = format_duration(started.duration_since(job.enqueued_at)); + let run = format_duration(job.completed_at.duration_since(started)); + (wait, Some(run)) + } else { + let wait = format!( + "{} (skipped)", + format_duration(job.completed_at.duration_since(job.enqueued_at)) + ); + (wait, None) + }; + + serde_json::json!({ + "id": job.id, + "description": job.description.as_ref(), + "key": job.key.as_ref().map(|k| k.as_ref()), + "status": status, + "enqueued": format!("{} ago", format_duration(job.enqueued_at.elapsed())), + "wait_time": wait_time, + "run_time": run_time, + }) + } +} + +fn format_duration(duration: std::time::Duration) -> String { + let secs = duration.as_secs_f64(); + if secs < 0.001 { + format!("{:.0}us", secs * 1_000_000.0) + } else if secs < 1.0 { + format!("{:.0}ms", secs * 1000.0) + } else if secs < 60.0 { + format!("{:.0}s", secs) + } else if secs < 3600.0 { + format!("{:.0}m", secs / 60.0) + } else { + format!("{:.0}h", secs / 3600.0) + } +} diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index e22f478eb9b..e110176dd20 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -14,7 +14,7 @@ use client::proto::{self, PeerId}; use clock::Global; use collections::HashMap; use futures::future; -use gpui::{App, AsyncApp, Entity, SharedString, Task, prelude::FluentBuilder}; +use gpui::{App, AsyncApp, Entity, SharedString, Task, TaskExt, prelude::FluentBuilder}; use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ad3344fa25a..57dd6740679 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -10,7 +10,7 @@ //! //! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate. pub mod clangd_ext; -mod code_lens; +pub mod code_lens; mod document_colors; mod document_symbols; mod folding_ranges; @@ -65,7 +65,7 @@ use futures::{ use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, - Subscription, Task, WeakEntity, + Subscription, Task, TaskExt, WeakEntity, }; use http_client::HttpClient; use itertools::Itertools as _; @@ -14349,13 +14349,13 @@ impl LspInstaller for SshLspAdapter { anyhow::bail!("SshLspAdapter does not support fetch_latest_server_version") } - async fn fetch_server_binary( + fn fetch_server_binary( &self, _: (), _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - anyhow::bail!("SshLspAdapter does not support fetch_server_binary") + _: &Arc, + ) -> impl Send + Future> + use<> { + async { anyhow::bail!("SshLspAdapter does not support fetch_server_binary") } } } diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 02059bc076e..0f1eae82aa3 100644 --- a/crates/project/src/lsp_store/code_lens.rs +++ b/crates/project/src/lsp_store/code_lens.rs @@ -9,7 +9,7 @@ use futures::{ future::{Shared, join_all}, }; use gpui::{AppContext as _, AsyncApp, Context, Entity, Task}; -use language::{Anchor, Buffer, ToOffset as _}; +use language::{Anchor, Buffer}; use lsp::LanguageServerId; use rpc::{TypedEnvelope, proto}; use settings::Settings as _; @@ -22,21 +22,54 @@ use crate::{ project_settings::ProjectSettings, }; +/// Opaque per-action identifier issued by [`LspStore`] at fetch time. +/// +/// LSP `CodeLens.data` is the server's private payload for resolve +/// round-trips, so we can't use it (or anything derived from it) to +/// disambiguate sibling lenses that share the same buffer `range` +/// (TypeScript's references + implementations is the canonical case). +/// We tag every cached action with this id and require it back on resolve +/// so each lens routes to its own request and slot. +/// +/// Ids are issued in fetch order; sorting by id reproduces server-emit +/// order, which is how callers recover a stable render order without +/// paying for an ordered map. +#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub struct CodeLensActionId(u64); + +pub type CodeLensActions = HashMap; + pub(super) type CodeLensTask = - Shared>, Arc>>>; + Shared, Arc>>>; + +pub type CodeLensResolveTask = Shared>>; #[derive(Debug, Default)] pub(super) struct CodeLensData { - pub(super) lens: HashMap>, + pub(super) lens: HashMap, + pub(super) next_id: u64, pub(super) update: Option<(Global, CodeLensTask)>, + pub(super) resolving: HashMap<(LanguageServerId, CodeLensActionId), CodeLensResolveTask>, } impl CodeLensData { pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) { self.lens.remove(&server_id); + self.resolving.retain(|(s, _), _| *s != server_id); } } +fn flatten_cache(lens: &HashMap) -> CodeLensActions { + let mut out = CodeLensActions::default(); + out.reserve(lens.values().map(|per_server| per_server.len()).sum()); + for per_server in lens.values() { + for (id, action) in per_server { + out.insert(*id, action.clone()); + } + } + out +} + impl LspStore { pub(super) fn invalidate_code_lens(&mut self) { for lsp_data in self.lsp_data.values_mut() { @@ -44,15 +77,14 @@ impl LspStore { } } - /// Fetches and returns all code lenses for the buffer. - /// - /// Resolution of individual lenses is the caller's responsibility; see - /// [`LspStore::resolve_visible_code_lenses`]. + /// Fetches all code lenses for the buffer, each tagged with the + /// [`CodeLensActionId`] that callers must pass back to + /// [`Self::resolve_code_lens`]. Resolution is the caller's job. pub fn code_lens_actions( &mut self, buffer: &Entity, cx: &mut Context, - ) -> Task>>> { + ) -> Task>> { let buffer_id = buffer.read(cx).remote_id(); let fetch_task = self.fetch_code_lenses(buffer, cx); @@ -66,7 +98,7 @@ impl LspStore { .lsp_data .get(&buffer_id) .and_then(|data| data.code_lens.as_ref()) - .map(|code_lens| code_lens.lens.values().flatten().cloned().collect()) + .map(|code_lens| flatten_cache(&code_lens.lens)) })?; Ok(actions) }) @@ -94,10 +126,7 @@ impl LspStore { existing_servers != cached_lens.lens.keys().copied().collect() }); if !has_different_servers { - return Task::ready(Ok(Some( - cached_lens.lens.values().flatten().cloned().collect(), - ))) - .shared(); + return Task::ready(Ok(Some(flatten_cache(&cached_lens.lens)))).shared(); } } else if let Some((updating_for, running_update)) = cached_lens.update.as_ref() { if !version_queried_for.changed_since(updating_for) { @@ -145,27 +174,34 @@ impl LspStore { }; lsp_store - .update(cx, |lsp_store, cx| { + .update(cx, |lsp_store, _| { let lsp_data = lsp_store.current_lsp_data(buffer_id)?; let code_lens = lsp_data.code_lens.as_mut()?; if let Some(fetched_lens) = fetched_lens { + let mut tagged: HashMap = + HashMap::default(); + for (server_id, actions) in fetched_lens { + let mut cache = CodeLensActions::default(); + cache.reserve(actions.len()); + for action in actions { + let id = CodeLensActionId(code_lens.next_id); + code_lens.next_id += 1; + cache.insert(id, action); + } + tagged.insert(server_id, cache); + } if lsp_data.buffer_version == query_version_queried_for { - code_lens.lens.extend(fetched_lens); + code_lens.lens.extend(tagged); } else if !lsp_data .buffer_version .changed_since(&query_version_queried_for) { lsp_data.buffer_version = query_version_queried_for; - code_lens.lens = fetched_lens; - } - let snapshot = buffer.read(cx).snapshot(); - for actions in code_lens.lens.values_mut() { - actions - .sort_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)); + code_lens.lens = tagged; } } code_lens.update = None; - Some(code_lens.lens.values().flatten().cloned().collect()) + Some(flatten_cache(&code_lens.lens)) }) .map_err(Arc::new) }) @@ -245,110 +281,107 @@ impl LspStore { } } - pub fn resolve_visible_code_lenses( + /// Resolves a single code lens via `codeLens/resolve`, identified by + /// the [`CodeLensActionId`] returned from [`Self::code_lens_actions`]. + /// The returned task is shared and cached on [`CodeLensData::resolving`] + /// keyed by `(server, lens_id)`, so concurrent callers awaiting the + /// same lens only drive a single LSP request. + /// + /// `None` is yielded when the lens cannot be resolved (id no longer + /// cached, server gone, no `resolveProvider`, request failure, etc.). + /// On success, the cached entry is updated in place before the + /// `(id, resolved_action)` pair is returned. + /// + /// All visibility / batching policy lives in the caller. Remote (proto) + /// resolves are not yet supported and currently yield `None`. + pub fn resolve_code_lens( &mut self, buffer: &Entity, - visible_range: Range, + server_id: LanguageServerId, + lens_id: CodeLensActionId, cx: &mut Context, - ) -> Task> { + ) -> CodeLensResolveTask { let buffer_id = buffer.read(cx).remote_id(); - let snapshot = buffer.read(cx).snapshot(); - let visible_start = visible_range.start.to_offset(&snapshot); - let visible_end = visible_range.end.to_offset(&snapshot); let Some(code_lens) = self .lsp_data - .get(&buffer_id) - .and_then(|data| data.code_lens.as_ref()) + .get_mut(&buffer_id) + .and_then(|data| data.code_lens.as_mut()) else { - return Task::ready(Vec::new()); + return Task::ready(None).shared(); }; - - let capable_servers = code_lens - .lens - .keys() - .filter_map(|server_id| { - let server = self.language_server_for_id(*server_id)?; - GetCodeLens::can_resolve_lens(&server.capabilities()) - .then_some((*server_id, server)) - }) - .collect::>(); - if capable_servers.is_empty() { - return Task::ready(Vec::new()); + let key = (server_id, lens_id); + if let Some(existing) = code_lens.resolving.get(&key) { + return existing.clone(); } - - let to_resolve = code_lens + let Some(cached) = code_lens .lens - .iter() - .flat_map(|(server_id, actions)| { - let start_idx = - actions.partition_point(|a| a.range.start.to_offset(&snapshot) < visible_start); - let end_idx = start_idx - + actions[start_idx..] - .partition_point(|a| a.range.start.to_offset(&snapshot) <= visible_end); - actions[start_idx..end_idx].iter().enumerate().filter_map( - move |(local_idx, action)| { - let LspAction::CodeLens(lens) = &action.lsp_action else { - return None; - }; - if lens.command.is_some() { - return None; - } - Some((*server_id, start_idx + local_idx, lens.clone())) - }, - ) - }) - .collect::>(); - if to_resolve.is_empty() { - return Task::ready(Vec::new()); + .get(&server_id) + .and_then(|cache| cache.get(&lens_id)) + else { + return Task::ready(None).shared(); + }; + if cached.resolved { + return Task::ready(Some((lens_id, cached.clone()))).shared(); } + let LspAction::CodeLens(lens) = &cached.lsp_action else { + return Task::ready(None).shared(); + }; + let lens = lens.clone(); + let Some(server) = self.language_server_for_id(server_id) else { + return Task::ready(None).shared(); + }; + if !GetCodeLens::can_resolve_lens(&server.capabilities()) { + return Task::ready(None).shared(); + } let request_timeout = ProjectSettings::get_global(cx) .global_lsp_settings .get_request_timeout(); - cx.spawn(async move |lsp_store, cx| { - let mut resolved = Vec::new(); - for (server_id, index, lens) in to_resolve { - let Some(server) = capable_servers.get(&server_id) else { - continue; - }; - match server - .request::(lens, request_timeout) - .await - .into_response() - { - Ok(resolved_lens) => resolved.push((server_id, index, resolved_lens)), - Err(e) => log::warn!("Failed to resolve code lens: {e:#}"), + let task = cx + .spawn({ + async move |lsp_store, cx| { + let response = server + .request::(lens, request_timeout) + .await + .into_response(); + lsp_store + .update(cx, |lsp_store, _| { + let code_lens = lsp_store + .lsp_data + .get_mut(&buffer_id) + .and_then(|data| data.code_lens.as_mut())?; + code_lens.resolving.remove(&key); + let resolved_lens = match response { + Ok(resolved_lens) => resolved_lens, + Err(e) => { + log::warn!("Failed to resolve code lens: {e:#}"); + return None; + } + }; + let action = code_lens + .lens + .get_mut(&server_id) + .and_then(|cache| cache.get_mut(&lens_id))?; + action.resolved = true; + action.lsp_action = LspAction::CodeLens(resolved_lens); + Some((lens_id, action.clone())) + }) + .ok() + .flatten() } - } - if resolved.is_empty() { - return Vec::new(); - } + }) + .shared(); - lsp_store - .update(cx, |lsp_store, _| { - let Some(code_lens) = lsp_store - .lsp_data - .get_mut(&buffer_id) - .and_then(|data| data.code_lens.as_mut()) - else { - return Vec::new(); - }; - let mut newly_resolved = Vec::new(); - for (server_id, index, resolved_lens) in resolved { - if let Some(actions) = code_lens.lens.get_mut(&server_id) { - if let Some(action) = actions.get_mut(index) { - action.resolved = true; - action.lsp_action = LspAction::CodeLens(resolved_lens); - newly_resolved.push(action.clone()); - } - } - } - newly_resolved - }) - .unwrap_or_default() - }) + if let Some(code_lens) = self + .lsp_data + .get_mut(&buffer_id) + .and_then(|data| data.code_lens.as_mut()) + { + code_lens.resolving.insert(key, task.clone()); + } + task } #[cfg(any(test, feature = "test-support"))] @@ -398,26 +431,32 @@ impl Project { lsp_store.update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); let buffer = buffer.clone(); cx.spawn(async move |_, cx| { - let mut actions = fetch_task.await?; - if let Some(actions) = &mut actions { - let resolve_task = lsp_store.update(cx, |lsp_store, cx| { - lsp_store.resolve_visible_code_lenses(&buffer, range.clone(), cx) - }); - let resolved = resolve_task.await; - for resolved_action in resolved { - if let Some(action) = actions.iter_mut().find(|a| { - a.server_id == resolved_action.server_id && a.range == resolved_action.range - }) { - *action = resolved_action; - } + let Some(mut tagged) = fetch_task.await? else { + return Ok(None); + }; + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + tagged.retain(|_, action| { + range.start.cmp(&action.range.start, &snapshot).is_ge() + && range.end.cmp(&action.range.end, &snapshot).is_le() + }); + let resolve_tasks = lsp_store.update(cx, |lsp_store, cx| { + tagged + .iter() + .filter(|(_, action)| !action.resolved) + .map(|(id, action)| { + lsp_store.resolve_code_lens(&buffer, action.server_id, *id, cx) + }) + .collect::>() + }); + for (resolved_id, resolved) in join_all(resolve_tasks).await.into_iter().flatten() { + if let Some(slot) = tagged.get_mut(&resolved_id) { + *slot = resolved; } - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - actions.retain(|action| { - range.start.cmp(&action.range.start, &snapshot).is_ge() - && range.end.cmp(&action.range.end, &snapshot).is_le() - }); } - Ok(actions) + // Sort by id to recover server-emit order at the menu boundary. + let mut entries: Vec<_> = tagged.into_iter().collect(); + entries.sort_by_key(|(id, _)| *id); + Ok(Some(entries.into_iter().map(|(_, a)| a).collect())) }) } } diff --git a/crates/project/src/lsp_store/log_store.rs b/crates/project/src/lsp_store/log_store.rs index ae6f9ec09d4..0cfe3c14cf2 100644 --- a/crates/project/src/lsp_store/log_store.rs +++ b/crates/project/src/lsp_store/log_store.rs @@ -2,7 +2,9 @@ use std::{collections::VecDeque, sync::Arc}; use collections::HashMap; use futures::{StreamExt, channel::mpsc}; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity}; +use gpui::{ + App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, TaskExt, WeakEntity, +}; use lsp::{ IoKind, LanguageServer, LanguageServerId, LanguageServerName, LanguageServerSelector, MessageType, TraceValue, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4e74c4cf1fc..dde7d4f1b39 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -86,7 +86,7 @@ use image_store::{ImageItemEvent, ImageStoreEvent}; use ::git::{blame::Blame, status::FileStatus}; use gpui::{ App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, SharedString, - Task, WeakEntity, Window, + Task, TaskExt, WeakEntity, Window, }; use language::{ Buffer, BufferEvent, Capability, CodeLabel, CursorShape, DiskState, Language, LanguageName, @@ -2264,14 +2264,7 @@ impl Project { #[inline] pub fn supports_terminal(&self, _cx: &App) -> bool { - if self.is_local() { - return true; - } - if self.is_via_remote_server() { - return true; - } - - false + self.is_local() || self.is_via_remote_server() } #[inline] diff --git a/crates/project/src/telemetry_snapshot.rs b/crates/project/src/telemetry_snapshot.rs index 6212b448835..1cd7bd75614 100644 --- a/crates/project/src/telemetry_snapshot.rs +++ b/crates/project/src/telemetry_snapshot.rs @@ -77,7 +77,7 @@ impl TelemetryWorktreeSnapshot { repo.update(cx, |repo, _| { let current_branch = repo.branch.as_ref().map(|branch| branch.name().to_owned()); - repo.send_job(None, |state, _| async move { + repo.send_job("telemetry_snapshot", None, |state, _| async move { let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else { diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index aa0f94ef707..b0fc16f3c83 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -9,6 +9,7 @@ use language::LanguageName; use remote::RemoteClient; use settings::{Settings, SettingsLocation}; use std::{ + borrow::Cow, path::{Path, PathBuf}, sync::Arc, }; @@ -156,23 +157,18 @@ impl Project { let builder = project .update(cx, move |_, cx| { - let format_to_run = || { - if let Some(command) = &spawn_task.command { - let command = shell_kind.prepend_command_prefix(command); - let command = shell_kind.try_quote_prefix_aware(&command); - let args = spawn_task - .args - .iter() - .filter_map(|arg| shell_kind.try_quote(&arg)); - - command.into_iter().chain(args).join(" ") - } else { - // todo: this breaks for remotes to windows - format!("exec {shell} -l") - } + let format_to_run = |spawn_task: &SpawnInTerminal| { + format_task_for_activation( + spawn_task, + shell_kind, + &shell, + path_style.is_windows(), + ) }; let (shell, env) = { + let to_run = + (!activation_script.is_empty()).then(|| format_to_run(&spawn_task)); env.extend(spawn_task.env); match remote_client { Some(remote_client) => match activation_script.clone() { @@ -180,7 +176,7 @@ impl Project { let separator = shell_kind.sequential_commands_separator(); let activation_script = activation_script.join(&format!("{separator} ")); - let to_run = format_to_run(); + let to_run = to_run.expect("activation command was formatted"); let arg = format!("{activation_script}{separator} {to_run}"); let args = shell_kind.args_for_shell(true, arg); @@ -213,7 +209,7 @@ impl Project { let separator = shell_kind.sequential_commands_separator(); let activation_script = activation_script.join(&format!("{separator} ")); - let to_run = format_to_run(); + let to_run = to_run.expect("activation command was formatted"); let arg = format!("{activation_script}{separator} {to_run}"); let args = shell_kind.args_for_shell(true, arg); @@ -644,3 +640,154 @@ fn create_remote_shell( command.env, )) } + +fn format_task_for_activation( + spawn_task: &SpawnInTerminal, + shell_kind: ShellKind, + shell: &str, + is_windows: bool, +) -> String { + if let Some(command) = &spawn_task.command { + let command = shell_kind.prepend_command_prefix(command); + let command = shell_kind.try_quote_prefix_aware(&command); + let args = spawn_task + .args + .iter() + .enumerate() + .filter_map(|(index, arg)| { + quote_prepared_task_arg_for_activation( + spawn_task, shell_kind, arg, index, is_windows, + ) + }); + + command.into_iter().chain(args).join(" ") + } else { + // todo: this breaks for remotes to windows + format!("exec {shell} -l") + } +} + +fn quote_prepared_task_arg_for_activation<'a>( + spawn_task: &SpawnInTerminal, + shell_kind: ShellKind, + arg: &'a str, + index: usize, + is_windows: bool, +) -> Option> { + if spawn_task.shell.shell_kind(is_windows) == ShellKind::Cmd + && index >= 2 + && spawn_task + .args + .get(index - 2) + .is_some_and(|arg| arg.eq_ignore_ascii_case("/S")) + && spawn_task + .args + .get(index - 1) + .is_some_and(|arg| arg.eq_ignore_ascii_case("/C")) + { + // The /C argument is already a cmd command string from prepare_task_for_spawn. + // Quoting it again for venv activation makes cmd see the quotes as literals. + return quote_cmd_command_arg_for_outer_shell(arg, shell_kind).map(Cow::Owned); + } + + shell_kind.try_quote(arg) +} + +fn quote_cmd_command_arg_for_outer_shell(arg: &str, shell_kind: ShellKind) -> Option { + match shell_kind { + ShellKind::PowerShell | ShellKind::Pwsh => Some(format!("'{}'", arg.replace('\'', "''"))), + ShellKind::Cmd => Some(arg.to_string()), + ShellKind::Posix + | ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Fish + | ShellKind::Nushell + | ShellKind::Rc + | ShellKind::Xonsh + | ShellKind::Elvish => shell_kind.try_quote(arg).map(Cow::into_owned), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn prepared_cmd_task(command_arg: &str) -> SpawnInTerminal { + SpawnInTerminal { + command: Some("cmd.exe".to_string()), + args: vec!["/S".to_string(), "/C".to_string(), command_arg.to_string()], + shell: Shell::Program("cmd.exe".to_string()), + ..SpawnInTerminal::default() + } + } + + #[test] + fn formats_prepared_cmd_task_for_powershell_activation() { + let task = prepared_cmd_task("\"echo Hi there\""); + + assert_eq!( + format_task_for_activation(&task, ShellKind::PowerShell, "powershell.exe", true), + "&cmd.exe /S /C '\"echo Hi there\"'" + ); + } + + #[test] + fn formats_prepared_cmd_task_for_cmd_activation() { + let task = prepared_cmd_task("\"echo Hi there\""); + + assert_eq!( + format_task_for_activation(&task, ShellKind::Cmd, "cmd.exe", true), + "cmd.exe /S /C \"echo Hi there\"" + ); + } + + #[test] + fn formats_prepared_cmd_task_with_shell_args_for_activation() { + let task = SpawnInTerminal { + command: Some("cmd.exe".to_string()), + args: vec![ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "\"echo Hi there\"".to_string(), + ], + shell: Shell::WithArguments { + program: "cmd.exe".to_string(), + args: vec!["/D".to_string()], + title_override: None, + }, + ..SpawnInTerminal::default() + }; + + assert_eq!( + format_task_for_activation(&task, ShellKind::PowerShell, "powershell.exe", true), + "&cmd.exe /D /S /C '\"echo Hi there\"'" + ); + } + + #[test] + fn formats_prepared_cmd_task_with_single_quote_for_powershell_activation() { + let task = prepared_cmd_task("\"echo It's fine\""); + + assert_eq!( + format_task_for_activation(&task, ShellKind::PowerShell, "powershell.exe", true), + "&cmd.exe /S /C '\"echo It''s fine\"'" + ); + } + + #[test] + fn formats_non_cmd_task_for_activation() { + let task = SpawnInTerminal { + command: Some("cargo".to_string()), + args: vec!["test".to_string(), "some test".to_string()], + shell: Shell::System, + ..SpawnInTerminal::default() + }; + + assert_eq!( + format_task_for_activation(&task, ShellKind::PowerShell, "powershell.exe", true), + "&cargo test 'some test'" + ); + } +} diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index c6abb6e1743..f544973a548 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -12,7 +12,7 @@ use collections::HashMap; use fs::{Fs, copy_recursive}; use futures::{FutureExt, future::Shared}; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EntityId, EventEmitter, Global, Task, + App, AppContext as _, AsyncApp, Context, Entity, EntityId, EventEmitter, Global, Task, TaskExt, WeakEntity, }; use itertools::Either; diff --git a/crates/project/tests/integration/git_store.rs b/crates/project/tests/integration/git_store.rs index 3f752a279f2..05d3843f018 100644 --- a/crates/project/tests/integration/git_store.rs +++ b/crates/project/tests/integration/git_store.rs @@ -1182,7 +1182,7 @@ mod git_worktrees { use serde_json::json; use settings::SettingsStore; use std::path::{Path, PathBuf}; - use util::path; + use util::{path, paths::PathStyle}; fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); @@ -1198,43 +1198,61 @@ mod git_worktrees { let work_dir = Path::new("/code/my-project"); // Valid: sibling - assert!(worktrees_directory_for_repo(work_dir, "../worktrees").is_ok()); + assert!(worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).is_ok()); // Valid: subdirectory - assert!(worktrees_directory_for_repo(work_dir, ".git/zed-worktrees").is_ok()); - assert!(worktrees_directory_for_repo(work_dir, "my-worktrees").is_ok()); + assert!( + worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Posix).is_ok() + ); + assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Posix).is_ok()); // Invalid: just ".." would resolve back to the working directory itself - let err = worktrees_directory_for_repo(work_dir, "..").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: ".." with trailing separators - let err = worktrees_directory_for_repo(work_dir, "..\\").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); - let err = worktrees_directory_for_repo(work_dir, "../").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: empty string would resolve to the working directory itself - let err = worktrees_directory_for_repo(work_dir, "").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be empty")); // Invalid: absolute path - let err = worktrees_directory_for_repo(work_dir, "/tmp/worktrees").unwrap_err(); + let err = + worktrees_directory_for_repo(work_dir, "/tmp/worktrees", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "/" is absolute on Unix - let err = worktrees_directory_for_repo(work_dir, "/").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "///" is absolute - let err = worktrees_directory_for_repo(work_dir, "///").unwrap_err(); + let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: escapes too far up - let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt").unwrap_err(); + let err = + worktrees_directory_for_repo(work_dir, "../../other-project/wt", PathStyle::Posix) + .unwrap_err(); assert!(err.to_string().contains("outside")); } + #[test] + fn test_worktree_directory_uses_remote_path_style() { + let work_dir = Path::new("/home/user/dev/lsp-tests"); + + let directory = + worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap(); + + assert_eq!( + directory, + PathBuf::from("/home/user/dev/worktrees/lsp-tests") + ); + } + #[gpui::test] async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/project_benchmarks/src/main.rs b/crates/project_benchmarks/src/main.rs index 054b5eb95a5..01dc141904e 100644 --- a/crates/project_benchmarks/src/main.rs +++ b/crates/project_benchmarks/src/main.rs @@ -6,6 +6,7 @@ use clap::Parser; use client::{Client, UserStore}; use futures::channel::oneshot; use gpui::AppContext as _; +use gpui::TaskExt; use http_client::FakeHttpClient; use language::LanguageRegistry; use node_runtime::NodeRuntime; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 1ae5f424845..780d8c9274e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -66,7 +66,9 @@ use ui::{ StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex, }; use util::{ - ResultExt, TakeUntilExt, TryFutureExt, maybe, + ResultExt, TakeUntilExt, TryFutureExt, + markdown::MarkdownInlineCode, + maybe, paths::{PathStyle, compare_paths}, rel_path::{RelPath, RelPathBuf}, }; @@ -2357,7 +2359,10 @@ impl ProjectPanel { "" }; - format!("{message_start} {path}?{unsaved_warning}") + format!( + "{message_start} {}?{unsaved_warning}", + MarkdownInlineCode(path) + ) } _ => { const CUTOFF_POINT: usize = 10; @@ -2365,7 +2370,7 @@ impl ProjectPanel { let truncated_path_counts = file_paths.len() - CUTOFF_POINT; let mut paths = file_paths .iter() - .map(|(_, _, path)| path.clone()) + .map(|(_, _, path)| MarkdownInlineCode(path).to_string()) .take(CUTOFF_POINT) .collect::>(); paths.truncate(CUTOFF_POINT); @@ -2376,7 +2381,10 @@ impl ProjectPanel { } paths } else { - file_paths.iter().map(|(_, _, path)| path.clone()).collect() + file_paths + .iter() + .map(|(_, _, path)| MarkdownInlineCode(path).to_string()) + .collect() }; let unsaved_warning = if dirty_buffers == 0 { String::new() @@ -7283,6 +7291,12 @@ impl Panel for ProjectPanel { fn activation_priority(&self) -> u32 { 1 } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.project_panel.get_or_insert_default().button = Some(false); + })) + } } impl ProjectPanel { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 4897b57937d..6722a300dd8 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -10339,3 +10339,39 @@ impl Render for TestProjectItemView { Empty } } + +#[gpui::test] +async fn test_delete_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "__somefile__": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + select_path(&panel, "root/__somefile__", cx); + panel.update_in(cx, |panel, window, cx| { + panel.delete(&Delete { skip_prompt: false }, window, cx) + }); + let (message, _detail) = cx + .pending_prompt() + .expect("delete should show a confirmation prompt"); + + assert_eq!( + message, + "Are you sure you want to permanently delete `__somefile__`?" + ); +} diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 8edcd9a80d1..2202ff35e18 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -1,8 +1,8 @@ use editor::{Bias, Editor, SelectionEffects, scroll::Autoscroll, styled_runs_for_code_label}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - App, Context, DismissEvent, Entity, HighlightStyle, ParentElement, StyledText, Task, TextStyle, - WeakEntity, Window, relative, rems, + App, Context, DismissEvent, Entity, HighlightStyle, ParentElement, StyledText, Task, TaskExt, + TextStyle, WeakEntity, Window, relative, rems, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index afea6cf34a3..8c7d09eb4b0 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -215,6 +215,7 @@ message GitDeleteBranch { uint64 repository_id = 2; string branch_name = 3; bool is_remote = 4; + bool force = 5; } message GitDiff { @@ -705,6 +706,30 @@ message GitLogSource { } } +message GetInitialGraphData { + uint64 project_id = 1; + uint64 repository_id = 2; + GitLogSource log_source = 3; + + enum LogOrder { + DATE_ORDER = 0; + TOPO_ORDER = 1; + AUTHOR_DATE_ORDER = 2; + REVERSE_CHRONOLOGICAL = 3; + } + LogOrder log_order = 4; +} + +message InitialGraphCommit { + string sha = 1; + repeated string parents = 2; + repeated string ref_names = 3; +} + +message GetInitialGraphDataResponse { + repeated InitialGraphCommit commits = 1; +} + message SearchCommits { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 0c149fb2976..a0fde40a84b 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -482,7 +482,9 @@ message Envelope { GetCommitData get_commit_data = 447; GetCommitDataResponse get_commit_data_response = 448; SearchCommits search_commits = 449; - SearchCommitsResponse search_commits_response = 450; // current max + SearchCommitsResponse search_commits_response = 450; + GetInitialGraphData get_initial_graph_data = 451; + GetInitialGraphDataResponse get_initial_graph_data_response = 452; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 651e11354a9..49b9db0d5c3 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -358,6 +358,8 @@ messages!( (GitRepairWorktrees, Background), (GetCommitData, Background), (GetCommitDataResponse, Background), + (GetInitialGraphData, Background), + (GetInitialGraphDataResponse, Background), (SearchCommits, Background), (SearchCommitsResponse, Background), (GitWorktreesResponse, Background), @@ -575,6 +577,7 @@ request_messages!( (GitEditRef, Ack), (GitRepairWorktrees, Ack), (GetCommitData, GetCommitDataResponse), + (GetInitialGraphData, GetInitialGraphDataResponse), (SearchCommits, SearchCommitsResponse), (GitCreateWorktree, Ack), (GitRemoveWorktree, Ack), @@ -770,6 +773,7 @@ entity_messages!( GitEditRef, GitRepairWorktrees, GetCommitData, + GetInitialGraphData, SearchCommits, GitCreateArchiveCheckpoint, GitRestoreArchiveCheckpoint, diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 6062aaa8a90..013e00e9648 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -64,6 +64,7 @@ fs.workspace = true gpui = { workspace = true, features = ["test-support"] } http_client.workspace = true language = { workspace = true, features = ["test-support"] } +picker = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 4b99ed37a38..a53d524885f 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -25,11 +25,11 @@ use disconnected_overlay::DisconnectedOverlay; use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, Window, actions, px, + Subscription, Task, TaskExt, WeakEntity, Window, actions, px, }; use picker::{ - Picker, PickerDelegate, + Picker, PickerDelegate, ScrollBehavior, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, }; use project::{Worktree, git_store::Repository}; @@ -83,6 +83,15 @@ enum ProjectPickerEntry { RecentProject(StringMatch), } +fn is_selectable_entry(entry: &ProjectPickerEntry) -> bool { + matches!( + entry, + ProjectPickerEntry::OpenFolder { .. } + | ProjectPickerEntry::ProjectGroup(_) + | ProjectPickerEntry::RecentProject(_) + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ProjectPickerStyle { Modal, @@ -814,8 +823,7 @@ pub struct RecentProjectsDelegate { selected_index: usize, render_paths: bool, create_new_window: bool, - // Flag to reset index when there is a new query vs not reset index when user delete an item - reset_selected_match_index: bool, + snap_selection_to_first_non_header_match: bool, has_any_non_local_projects: bool, project_connection_options: Option, focus_handle: FocusHandle, @@ -843,7 +851,7 @@ impl RecentProjectsDelegate { selected_index: 0, create_new_window, render_paths, - reset_selected_match_index: true, + snap_selection_to_first_non_header_match: true, has_any_non_local_projects: project_connection_options.is_some(), project_connection_options, focus_handle, @@ -1067,14 +1075,14 @@ impl PickerDelegate for RecentProjectsDelegate { self.filtered_entries = entries; - if self.reset_selected_match_index { + if self.snap_selection_to_first_non_header_match { self.selected_index = self .filtered_entries .iter() .position(|e| !matches!(e, ProjectPickerEntry::Header(_))) .unwrap_or(0); } - self.reset_selected_match_index = true; + self.snap_selection_to_first_non_header_match = true; Task::ready(()) } @@ -2106,6 +2114,69 @@ impl RecentProjectsDelegate { .detach(); } + /// Returns the new selection index after the entry at `deleted_index` + /// is removed. + /// + /// - Prefers the nearest entry matching `prefer_section` so the user + /// stays in the same section they were navigating. + /// - Falls back to any other selectable entry so the picker doesn't + /// land on a header. + fn replacement_index_after_deletion( + &self, + deleted_index: usize, + prefer_previous: bool, + prefer_section: fn(&ProjectPickerEntry) -> bool, + ) -> Option { + let replacement_index = |matches_entry: fn(&ProjectPickerEntry) -> bool| { + let next_index = self + .filtered_entries + .iter() + .enumerate() + .skip(deleted_index) + .find_map(|(index, entry)| matches_entry(entry).then_some(index)); + let previous_index = self + .filtered_entries + .iter() + .enumerate() + .take(deleted_index.min(self.filtered_entries.len())) + .rev() + .find_map(|(index, entry)| matches_entry(entry).then_some(index)); + + if prefer_previous { + previous_index.or(next_index) + } else { + next_index.or(previous_index) + } + }; + + replacement_index(prefer_section).or_else(|| replacement_index(is_selectable_entry)) + } + + fn update_picker_after_recent_project_deletion( + picker: &mut Picker, + deleted_index: usize, + workspaces: Vec, + window: &mut Window, + cx: &mut Context>, + ) { + let prefer_previous = picker.is_scrolled_to_end() == Some(true); + picker.delegate.set_workspaces(workspaces); + picker.delegate.snap_selection_to_first_non_header_match = false; + picker.update_matches_with_options( + picker.query(cx), + ScrollBehavior::PreserveOffset, + window, + cx, + ); + if let Some(replacement_index) = picker.delegate.replacement_index_after_deletion( + deleted_index, + prefer_previous, + |entry| matches!(entry, ProjectPickerEntry::RecentProject(_)), + ) { + picker.set_selected_index(replacement_index, None, false, window, cx); + } + } + fn delete_recent_project( &self, ix: usize, @@ -2115,7 +2186,10 @@ impl RecentProjectsDelegate { if let Some(ProjectPickerEntry::RecentProject(selected_match)) = self.filtered_entries.get(ix) { - let recent_workspace = self.workspaces[selected_match.candidate_id].clone(); + let Some(recent_workspace) = self.workspaces.get(selected_match.candidate_id).cloned() + else { + return; + }; let fs = self .workspace .upgrade() @@ -2133,12 +2207,9 @@ impl RecentProjectsDelegate { .await .unwrap_or_default(); this.update_in(cx, move |picker, window, cx| { - picker.delegate.set_workspaces(workspaces); - picker - .delegate - .set_selected_index(ix.saturating_sub(1), window, cx); - picker.delegate.reset_selected_match_index = false; - picker.update_matches(picker.query(cx), window, cx); + Self::update_picker_after_recent_project_deletion( + picker, ix, workspaces, window, cx, + ); // After deleting a project, we want to update the history manager to reflect the change. // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`. if let Some(history_manager) = HistoryManager::global(cx) { @@ -2234,7 +2305,7 @@ impl RecentProjectsDelegate { #[cfg(test)] mod tests { - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use serde_json::json; use settings::SettingsStore; @@ -2243,6 +2314,220 @@ mod tests { use super::*; + // Test picker for the empty query: + // + // [0] Header("Current Folders") + // [1] OpenFolder(0) + // [2] OpenFolder(1) + // [3] Header("This Window") + // [4] ProjectGroup(0) + // [5] ProjectGroup(1) + // [6] Header("Recent Projects") + // [7..=26] RecentProject(0..=19) + // + const RECENT_PROJECT_COUNT: usize = 20; + const FIRST_RECENT_PROJECT: usize = 7; + const LAST_RECENT_PROJECT: usize = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT - 1; + + fn open_folder(index: usize) -> OpenFolderEntry { + OpenFolderEntry { + worktree_id: WorktreeId::from_usize(index), + name: format!("project-folder-{index}").into(), + path: PathBuf::from(format!("/current/project-folder-{index}")), + branch: None, + is_active: false, + } + } + + fn project_group(index: usize) -> ProjectGroupKey { + ProjectGroupKey::new( + None, + PathList::new(&[PathBuf::from(format!("/this-window/project-{index}"))]), + ) + } + + fn recent_workspace(index: usize) -> RecentWorkspace { + let paths = PathList::new(&[PathBuf::from(format!("/recent/project-{index:02}"))]); + RecentWorkspace { + workspace_id: WorkspaceId::from_i64(index as i64), + location: SerializedWorkspaceLocation::Local, + paths: paths.clone(), + identity_paths: paths, + timestamp: Utc::now(), + } + } + + fn recent_workspaces() -> Vec { + (0..RECENT_PROJECT_COUNT).map(recent_workspace).collect() + } + + fn draw(cx: &mut VisualTestContext) { + cx.update(|window, cx| window.draw(cx).clear()); + } + + fn build_picker( + cx: &mut TestAppContext, + ) -> ( + Entity>, + &mut VisualTestContext, + ) { + init_test(cx); + let (picker, cx) = cx.add_window_view(|window, cx| { + let mut delegate = RecentProjectsDelegate::new( + WeakEntity::new_invalid(), + false, + cx.focus_handle(), + vec![open_folder(0), open_folder(1)], + vec![project_group(0), project_group(1)], + None, + ProjectPickerStyle::Modal, + ); + delegate.set_workspaces(recent_workspaces()); + Picker::list(delegate, window, cx) + .list_measure_all() + .show_scrollbar(true) + .max_height(Some(px(240.).into())) + }); + draw(cx); + (picker, cx) + } + + fn scroll_to_and_select( + picker: &Entity>, + cx: &mut VisualTestContext, + index: usize, + ) -> usize { + picker.update_in(cx, |picker, window, cx| { + picker.set_selected_index(index, None, true, window, cx); + }); + draw(cx); + picker.update(cx, |picker, _| picker.logical_scroll_top_index()) + } + + fn delete_recent_project_in_picker( + picker: &Entity>, + cx: &mut VisualTestContext, + index: usize, + ) { + picker.update_in(cx, |picker, window, cx| { + let Some(ProjectPickerEntry::RecentProject(hit)) = + picker.delegate.filtered_entries.get(index) + else { + panic!("expected entry at {index} to be a recent project"); + }; + let mut workspaces = picker.delegate.workspaces.clone(); + workspaces.remove(hit.candidate_id); + RecentProjectsDelegate::update_picker_after_recent_project_deletion( + picker, index, workspaces, window, cx, + ); + }); + } + + #[track_caller] + fn assert_scroll_top_is( + picker: &Entity>, + cx: &mut VisualTestContext, + expected: usize, + phase: &str, + ) { + picker.update(cx, |picker, _| { + assert_eq!( + picker.logical_scroll_top_index(), + expected, + "scroll top should remain at {expected} ({phase})" + ); + assert_selected_entry_is_recent_project(picker); + }); + } + + #[track_caller] + fn assert_pinned_to_bottom( + picker: &Entity>, + cx: &mut VisualTestContext, + phase: &str, + ) { + picker.update(cx, |picker, _| { + assert_eq!( + picker.is_scrolled_to_end(), + Some(true), + "picker should remain pinned to the bottom ({phase})" + ); + assert!( + picker.logical_scroll_top_index() > 0, + "picker should not jump to the top while pinned to the bottom ({phase})" + ); + assert_selected_entry_is_recent_project(picker); + }); + } + + #[track_caller] + fn assert_selected_entry_is_recent_project(picker: &Picker) { + assert!(matches!( + picker + .delegate + .filtered_entries + .get(picker.delegate.selected_index), + Some(ProjectPickerEntry::RecentProject(_)) + )); + } + + #[gpui::test] + fn deleting_top_recent_project_preserves_scroll_position(cx: &mut TestAppContext) { + let target = FIRST_RECENT_PROJECT; + let (picker, cx) = build_picker(cx); + let scroll_top = scroll_to_and_select(&picker, cx, target); + assert!( + scroll_top > 0, + "test should start scrolled away from the top" + ); + + delete_recent_project_in_picker(&picker, cx, target); + assert_scroll_top_is(&picker, cx, scroll_top, "after delete"); + + // The picker re-runs layout on the next frame; the scroll position + // must still be preserved after that redraw. + draw(cx); + assert_scroll_top_is(&picker, cx, scroll_top, "after redraw"); + } + + #[gpui::test] + fn deleting_middle_recent_project_preserves_scroll_position(cx: &mut TestAppContext) { + let target = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT / 2; + let (picker, cx) = build_picker(cx); + let scroll_top = scroll_to_and_select(&picker, cx, target); + assert!( + scroll_top > 0, + "test should start scrolled away from the top" + ); + + delete_recent_project_in_picker(&picker, cx, target); + assert_scroll_top_is(&picker, cx, scroll_top, "after delete"); + + draw(cx); + assert_scroll_top_is(&picker, cx, scroll_top, "after redraw"); + } + + #[gpui::test] + fn deleting_last_recent_project_preserves_scroll_position(cx: &mut TestAppContext) { + let target = LAST_RECENT_PROJECT; + let (picker, cx) = build_picker(cx); + scroll_to_and_select(&picker, cx, target); + + picker.update(cx, |picker, _| { + assert_eq!( + picker.is_scrolled_to_end(), + Some(true), + "selecting the last entry should leave the picker pinned to the bottom" + ); + }); + + delete_recent_project_in_picker(&picker, cx, target); + assert_pinned_to_bottom(&picker, cx, "after delete"); + + draw(cx); + assert_pinned_to_bottom(&picker, cx, "after redraw"); + } + #[gpui::test] async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) { let app_state = init_test(cx); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index f475baddd99..3c1ad319461 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -15,7 +15,7 @@ use extension_host::ExtensionStore; use futures::{FutureExt, StreamExt as _, channel::oneshot, future::Shared}; use gpui::{ Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, - EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, + EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, TaskExt, WeakEntity, Window, canvas, }; use log::{debug, info}; diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 495907d3934..0b4d3722a34 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, Window, + Subscription, Task, TaskExt, WeakEntity, Window, }; use picker::{ Picker, PickerDelegate, diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 138238c5fd4..85e07aee0b4 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -26,7 +26,7 @@ use futures::{ }; use gpui::{ App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity, - EventEmitter, FutureExt, Global, Task, WeakEntity, + EventEmitter, FutureExt, Global, Task, TaskExt, WeakEntity, }; use parking_lot::Mutex; diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 7b0fc0356a1..098993debad 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -8,7 +8,7 @@ use lsp::LanguageServerId; use extension::ExtensionHostProxy; use extension_host::headless_host::HeadlessExtensionStore; use fs::Fs; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, PromptLevel, TaskExt}; use http_client::HttpClient; use language::{Buffer, BufferEvent, LanguageRegistry, proto::serialize_operation}; use node_runtime::NodeRuntime; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index d31403275cb..840485e6750 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2624,6 +2624,115 @@ async fn test_remote_apply_code_action_skips_unadvertised_command( assert_eq!(transaction.0.len(), 0); } +#[gpui::test] +async fn test_remote_restore_unstaged_hunk_clears_diff( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + release_channel::init(semver::Version::new(0, 0, 0), cx); + editor::init(cx); + }); + + use editor::Editor; + use gpui::VisualContext; + + let base_text = " + fn one() -> usize { + 1 + } + " + .unindent(); + let modified_text = " + fn one() -> usize { + 100 + } + " + .unindent(); + + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": modified_text + }, + }, + }), + ) + .await; + fs.set_index_for_repo( + Path::new(path!("/code/project1/.git")), + &[("src/lib.rs", base_text.clone())], + ); + fs.set_head_for_repo( + Path::new(path!("/code/project1/.git")), + &[("src/lib.rs", base_text.clone())], + "deadbeef", + ); + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + let worktree_id = { + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.update(|cx| worktree.read(cx).id()) + }; + cx.executor().run_until_parked(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + let cx = cx.add_empty_window(); + let editor = cx.new_window_entity(|window, cx| { + Editor::for_buffer(buffer, Some(project.clone()), window, cx) + }); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let hunks: Vec<_> = editor + .diff_hunks_in_ranges( + &[editor::Anchor::Min..editor::Anchor::Max], + &snapshot.buffer_snapshot(), + ) + .collect(); + assert!(!hunks.is_empty(), "should have diff hunks before restore"); + }); + + cx.update_window_entity(&editor, |editor, window, cx| { + editor.select_all(&editor::actions::SelectAll, window, cx); + editor.git_restore(&git::Restore, window, cx); + }); + cx.executor().run_until_parked(); + + editor.update_in(cx, |editor, _window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + assert_eq!( + snapshot.text(), + base_text, + "buffer text should match base after restoring all hunks" + ); + + let hunks: Vec<_> = editor + .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot) + .collect(); + assert!(hunks.is_empty(), "should have no diff hunks after restore"); + }); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 78b7f1a4e51..d1c44667f94 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -11,7 +11,7 @@ use futures::FutureExt; use futures::future::Shared; use gpui::{ AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, ListScrollEvent, - ListState, Point, Task, actions, list, prelude::*, + ListState, Point, Task, TaskExt, actions, list, prelude::*, }; use jupyter_protocol::JupyterKernelspec; use language::{Language, LanguageRegistry}; diff --git a/crates/repl/src/repl_sessions_ui.rs b/crates/repl/src/repl_sessions_ui.rs index 9781382fc85..5fd1e922a50 100644 --- a/crates/repl/src/repl_sessions_ui.rs +++ b/crates/repl/src/repl_sessions_ui.rs @@ -1,6 +1,6 @@ use editor::Editor; use gpui::{ - AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, actions, + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, TaskExt, actions, prelude::*, }; use project::ProjectItem as _; diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index 4c5827b7c0c..b2bf90e99dc 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use command_palette_hooks::CommandPaletteFilter; -use gpui::{App, Context, Entity, EntityId, Global, SharedString, Subscription, Task, prelude::*}; +use gpui::{ + App, Context, Entity, EntityId, Global, SharedString, Subscription, Task, TaskExt, prelude::*, +}; use jupyter_websocket_client::RemoteServer; use language::{Language, LanguageName}; use project::{Fs, Project, ProjectPath, WorktreeId}; diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index e5105081ca7..9f87d403e72 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -4,7 +4,7 @@ use editor::SelectionEffects; use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; use gpui::{ App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, + Subscription, Task, TaskExt, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, actions, point, size, transparent_black, }; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 41dda49efa3..30805264522 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -22,7 +22,7 @@ use futures::channel::oneshot; use gpui::{ Action as _, App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, - Styled, Subscription, Task, WeakEntity, Window, div, + Styled, Subscription, Task, TaskExt, WeakEntity, Window, div, }; use language::{Language, LanguageRegistry}; use project::{ diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 00966436595..1ca53632dba 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -22,7 +22,8 @@ use futures::{StreamExt, stream::FuturesOrdered}; use gpui::{ Action, AnyElement, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point, Render, - SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions, div, + SharedString, Styled, Subscription, Task, TaskExt, UpdateGlobal, WeakEntity, Window, actions, + div, }; use itertools::Itertools; use language::{Buffer, Language}; diff --git a/crates/search/src/search_status_button.rs b/crates/search/src/search_status_button.rs index 5faab32d424..a31121e2f05 100644 --- a/crates/search/src/search_status_button.rs +++ b/crates/search/src/search_status_button.rs @@ -1,8 +1,8 @@ use editor::EditorSettings; -use gpui::FocusHandle; +use gpui::{App, FocusHandle}; use settings::Settings as _; use ui::{ButtonCommon, Clickable, Context, Render, Tooltip, Window, prelude::*}; -use workspace::{ItemHandle, StatusItemView}; +use workspace::{HideStatusItem, ItemHandle, StatusItemView}; pub const SEARCH_ICON: IconName = IconName::MagnifyingGlass; @@ -62,4 +62,10 @@ impl StatusItemView for SearchButton { ) { self.pane_item_focus_handle = active_pane_item.map(|item| item.item_focus_handle(cx)); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings.editor.search.get_or_insert_default().button = Some(false); + })) + } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 431937dce30..19bdcf85e41 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -429,6 +429,7 @@ impl JsonSchema for LanguageModelProviderSetting { "mistral", "ollama", "openai", + "opencode", "openrouter", "vercel_ai_gateway", "x_ai", diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index d3f0e6a4195..081406a6846 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -159,7 +159,7 @@ pub struct CustomEditPredictionProviderSettingsContent { /// The prompt format to use for completions. Set to `""` to have the format be derived from the model name. /// /// Default: "" - pub prompt_format: Option, + pub prompt_format: Option, /// The name of the model. /// /// Default: "" @@ -185,11 +185,12 @@ pub struct CustomEditPredictionProviderSettingsContent { strum::VariantNames, )] #[serde(rename_all = "snake_case")] -pub enum EditPredictionPromptFormat { +pub enum EditPredictionPromptFormatContent { #[default] Infer, Zeta, Zeta2, + Zeta2_1, CodeLlama, StarCoder, DeepseekCoder, @@ -280,7 +281,7 @@ pub struct OllamaEditPredictionSettingsContent { /// The prompt format to use for completions. Set to `""` to have the format be derived from the model name. /// /// Default: "" - pub prompt_format: Option, + pub prompt_format: Option, } /// Controls whether Zed collects training data when using Zed's Edit Predictions. diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 469be983f0f..61961bb77e7 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -65,8 +65,6 @@ pub struct AmazonBedrockSettingsContent { pub profile: Option, pub authentication_method: Option, pub allow_global: Option, - /// Enable the 1M token extended context window beta for supported Anthropic models. - pub allow_extended_context: Option, } #[with_fallible_options] diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 1124e2ac942..0bf14e2f4ff 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -864,6 +864,9 @@ pub struct VimSettingsContent { pub custom_digraphs: Option>>, pub highlight_on_yank_duration: Option, pub cursor_shape: Option, + /// When enabled, edit predictions are shown in Vim normal mode. + /// By default, edit predictions are only shown in insert and replace modes. + pub show_edit_predictions_in_normal_mode: Option, } #[derive( diff --git a/crates/settings_content/src/theme.rs b/crates/settings_content/src/theme.rs index 8597dbe0616..43cf3b36e98 100644 --- a/crates/settings_content/src/theme.rs +++ b/crates/settings_content/src/theme.rs @@ -854,6 +854,30 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.document_highlight.bracket_background")] pub editor_document_highlight_bracket_background: Option, + /// Filled background color for added diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.added.background")] + pub editor_diff_hunk_added_background: Option, + + /// Hollow background color for added diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.added.hollow_background")] + pub editor_diff_hunk_added_hollow_background: Option, + + /// Hollow border color for added diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.added.hollow_border")] + pub editor_diff_hunk_added_hollow_border: Option, + + /// Filled background color for deleted diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.deleted.background")] + pub editor_diff_hunk_deleted_background: Option, + + /// Hollow background color for deleted diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.deleted.hollow_background")] + pub editor_diff_hunk_deleted_hollow_background: Option, + + /// Hollow border color for deleted diff hunk row highlights in the editor. + #[serde(rename = "editor.diff_hunk.deleted.hollow_border")] + pub editor_diff_hunk_deleted_hollow_border: Option, + /// Terminal background color. #[serde(rename = "terminal.background")] pub terminal_background: Option, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index ce0c53b3822..acb9f53a675 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -2573,7 +2573,7 @@ fn editor_page() -> SettingsPage { ] } - fn vim_settings_section() -> [SettingsPageItem; 13] { + fn vim_settings_section() -> [SettingsPageItem; 14] { [ SettingsPageItem::SectionHeader("Vim"), SettingsPageItem::SettingItem(SettingItem { @@ -2700,6 +2700,28 @@ fn editor_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Edit Predictions in Normal Mode", + description: "Whether edit predictions are shown in normal mode. By default, edit predictions are only shown in insert and replace modes.", + field: Box::new(SettingField { + json_path: Some("vim.show_edit_predictions_in_normal_mode"), + pick: |settings_content| { + settings_content + .vim + .as_ref()? + .show_edit_predictions_in_normal_mode + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .vim + .get_or_insert_default() + .show_edit_predictions_in_normal_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape - Normal Mode", description: "Cursor shape for normal mode.", diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 401534b6605..4a69069148e 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -15,6 +15,6 @@ pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page; pub use tool_permissions_setup::{ render_copy_path_tool_config, render_create_directory_tool_config, render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config, - render_move_path_tool_config, render_restore_file_from_disk_tool_config, - render_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config, + render_move_path_tool_config, render_terminal_tool_config, render_web_search_tool_config, + render_write_file_tool_config, }; diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index fd6ea35c1e3..d101effe5bf 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -5,7 +5,7 @@ use edit_prediction::{ open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url}, }; use edit_prediction_ui::{get_available_providers, set_completion_provider}; -use gpui::{App, Entity, ScrollHandle, prelude::*}; +use gpui::{App, Entity, ScrollHandle, TaskExt, prelude::*}; use language::language_settings::AllLanguageSettings; use settings::Settings as _; diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 12693cb99d9..550b9a3adc6 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -32,6 +32,12 @@ const TOOLS: &[ToolInfo] = &[ description: "File editing operations", regex_explanation: "Patterns are matched against the file path being edited.", }, + ToolInfo { + id: "write_file", + name: "Write File", + description: "File creation and overwrite operations", + regex_explanation: "Patterns are matched against the file path being written.", + }, ToolInfo { id: "delete_path", name: "Delete Path", @@ -56,12 +62,6 @@ const TOOLS: &[ToolInfo] = &[ description: "Directory creation", regex_explanation: "Patterns are matched against the directory path being created.", }, - ToolInfo { - id: "save_file", - name: "Save File", - description: "File saving operations", - regex_explanation: "Patterns are matched against the file path being saved.", - }, ToolInfo { id: "fetch", name: "Fetch", @@ -74,12 +74,6 @@ const TOOLS: &[ToolInfo] = &[ description: "Web search queries", regex_explanation: "Patterns are matched against the search query.", }, - ToolInfo { - id: "restore_file_from_disk", - name: "Restore File from Disk", - description: "Discards unsaved changes by reloading from disk", - regex_explanation: "Patterns are matched against the file path being restored.", - }, ]; pub(crate) struct ToolInfo { @@ -303,14 +297,13 @@ fn get_tool_render_fn( match tool_id { "terminal" => render_terminal_tool_config, "edit_file" => render_edit_file_tool_config, + "write_file" => render_write_file_tool_config, "delete_path" => render_delete_path_tool_config, "copy_path" => render_copy_path_tool_config, "move_path" => render_move_path_tool_config, "create_directory" => render_create_directory_tool_config, - "save_file" => render_save_file_tool_config, "fetch" => render_fetch_tool_config, "search_web" => render_web_search_tool_config, - "restore_file_from_disk" => render_restore_file_from_disk_tool_config, _ => render_terminal_tool_config, // fallback } } @@ -1383,17 +1376,13 @@ macro_rules! tool_config_page_fn { tool_config_page_fn!(render_terminal_tool_config, "terminal"); tool_config_page_fn!(render_edit_file_tool_config, "edit_file"); +tool_config_page_fn!(render_write_file_tool_config, "write_file"); tool_config_page_fn!(render_delete_path_tool_config, "delete_path"); tool_config_page_fn!(render_copy_path_tool_config, "copy_path"); tool_config_page_fn!(render_move_path_tool_config, "move_path"); tool_config_page_fn!(render_create_directory_tool_config, "create_directory"); -tool_config_page_fn!(render_save_file_tool_config, "save_file"); tool_config_page_fn!(render_fetch_tool_config, "fetch"); tool_config_page_fn!(render_web_search_tool_config, "search_web"); -tool_config_page_fn!( - render_restore_file_from_disk_tool_config, - "restore_file_from_disk" -); #[cfg(test)] mod tests { diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f17caafce5e..a5c36671ea0 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -504,7 +504,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_editable_number_field) @@ -763,6 +763,7 @@ pub struct SettingsWindow { list_state: ListState, shown_errors: HashSet, pub(crate) regex_validation_error: Option, + last_copied_link_path: Option<&'static str>, } struct SearchDocument { @@ -1035,6 +1036,7 @@ impl SettingsPageItem { sub_page_link.title.clone(), sub_page_link.json_path, false, + settings_window, cx, )), ) @@ -1228,6 +1230,7 @@ fn render_settings_item( setting_item.description, setting_item.field.json_path(), sub_field, + settings_window, cx, )) }) @@ -1237,16 +1240,13 @@ fn render_settings_item_link( id: impl Into, json_path: Option<&'static str>, sub_field: bool, + settings_window: &SettingsWindow, cx: &mut Context<'_, SettingsWindow>, ) -> impl IntoElement { - let clipboard_has_link = cx - .read_from_clipboard() - .and_then(|entry| entry.text()) - .map_or(false, |maybe_url| { - json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path - }); + let copied_link_matches = + json_path.is_some() && json_path == settings_window.last_copied_link_path; - let (link_icon, link_icon_color) = if clipboard_has_link { + let (link_icon, link_icon_color) = if copied_link_matches { (IconName::Check, Color::Success) } else { (IconName::Link, Color::Muted) @@ -1271,9 +1271,10 @@ fn render_settings_item_link( .shape(IconButtonShape::Square) .tooltip(Tooltip::text("Copy Link")) .when_some(json_path, |this, path| { - this.on_click(cx.listener(move |_, _, _, cx| { + this.on_click(cx.listener(move |this, _, _, cx| { let link = format!("zed://settings/{}", path); cx.write_to_clipboard(ClipboardItem::new_string(link)); + this.last_copied_link_path = Some(path); cx.notify(); })) }), @@ -1685,6 +1686,7 @@ impl SettingsWindow { shown_errors: HashSet::default(), regex_validation_error: None, list_state, + last_copied_link_path: None, }; this.fetch_files(window, cx); @@ -4472,6 +4474,7 @@ pub mod test { list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), regex_validation_error: None, + last_copied_link_path: None, } } } @@ -4597,6 +4600,7 @@ pub mod test { list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), shown_errors: HashSet::default(), regex_validation_error: None, + last_copied_link_path: None, }; settings_window.build_filter_table(); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index be525a5c6e5..f9ae2ed5241 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -29,6 +29,7 @@ feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true +itertools.workspace = true log.workspace = true menu.workspace = true platform_title_bar.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 57b7c9b2cbb..597bb21811d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -10,22 +10,26 @@ use agent_ui::thread_metadata_store::{ use agent_ui::thread_worktree_archive; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, + fuzzy_match_positions, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread, - CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal, - channels_with_threads, import_threads_from_other_channels, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, + TerminalId, ThreadId, ThreadImportModal, channels_with_threads, + import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{ - AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _, + AgentPanelTerminalFeatureFlag, AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, + FeatureFlag, FeatureFlagAppExt as _, FeatureFlagViewExt as _, }; use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, - Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, WeakEntity, - Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, + Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt, + WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; +use itertools::Itertools; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; @@ -118,34 +122,57 @@ enum ArchiveWorktreeOutcome { } #[derive(Clone, Debug)] -struct ActiveEntry { - thread_id: agent_ui::ThreadId, - /// Stable remote identifier, used for matching when thread_id - /// differs (e.g. after cross-window activation creates a new - /// local ThreadId). - session_id: Option, - workspace: Entity, +enum ActiveEntry { + Thread { + thread_id: agent_ui::ThreadId, + /// Stable remote identifier, used for matching when thread_id + /// differs (e.g. after cross-window activation creates a new + /// local ThreadId). + session_id: Option, + workspace: Entity, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, } impl ActiveEntry { fn workspace(&self) -> &Entity { - &self.workspace + match self { + ActiveEntry::Thread { workspace, .. } | ActiveEntry::Terminal { workspace, .. } => { + workspace + } + } } fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool { - self.thread_id == *thread_id + matches!(self, ActiveEntry::Thread { thread_id: active_thread_id, .. } if active_thread_id == thread_id) + } + + fn is_active_terminal(&self, terminal_id: TerminalId) -> bool { + matches!(self, ActiveEntry::Terminal { terminal_id: active_terminal_id, .. } if *active_terminal_id == terminal_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { - match entry { - ListEntry::Thread(thread) => { - self.thread_id == thread.metadata.thread_id - || self - .session_id + match (self, entry) { + ( + ActiveEntry::Thread { + thread_id, + session_id, + .. + }, + ListEntry::Thread(thread), + ) => { + *thread_id == thread.metadata.thread_id + || session_id .as_ref() .zip(thread.metadata.session_id.as_ref()) .is_some_and(|(a, b)| a == b) } + (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { + *terminal_id == terminal.id + } _ => false, } } @@ -202,6 +229,16 @@ struct ThreadEntry { diff_stats: DiffStats, } +#[derive(Clone)] +struct TerminalEntry { + id: TerminalId, + title: SharedString, + workspace: Entity, + created_at: DateTime, + has_notification: bool, + highlight_positions: Vec, +} + impl ThreadEntry { /// Updates this thread entry with active thread information. /// @@ -232,6 +269,59 @@ enum ListEntry { has_threads: bool, }, Thread(ThreadEntry), + Terminal(TerminalEntry), +} + +#[derive(Clone)] +enum ActivatableEntry { + Thread { + metadata: ThreadMetadata, + workspace: ThreadEntryWorkspace, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, +} + +impl ActivatableEntry { + fn from_list_entry(entry: &ListEntry) -> Option { + match entry { + ListEntry::Thread(thread) => Some(Self::Thread { + metadata: thread.metadata.clone(), + workspace: thread.workspace.clone(), + }), + ListEntry::Terminal(terminal) => Some(Self::Terminal { + terminal_id: terminal.id, + workspace: terminal.workspace.clone(), + }), + ListEntry::ProjectHeader { .. } => None, + } + } + + fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) { + match self { + Self::Thread { + workspace: ThreadEntryWorkspace::Open(workspace), + .. + } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + Self::Thread { + workspace: + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }, + .. + } => (folder_paths.clone(), project_group_key.clone()), + Self::Terminal { workspace, .. } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + } + } } #[cfg(test)] @@ -239,7 +329,7 @@ impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(), - _ => None, + ListEntry::Terminal(_) | ListEntry::ProjectHeader { .. } => None, } } @@ -253,6 +343,7 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, + ListEntry::Terminal(terminal) => vec![terminal.workspace.clone()], ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), @@ -266,10 +357,17 @@ impl From for ListEntry { } } +impl From for ListEntry { + fn from(terminal: TerminalEntry) -> Self { + ListEntry::Terminal(terminal) + } +} + #[derive(Default)] struct SidebarContents { entries: Vec, notified_threads: HashSet, + notified_terminals: HashSet, project_header_indices: Vec, has_open_projects: bool, } @@ -280,28 +378,6 @@ impl SidebarContents { } } -fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { - let mut positions = Vec::new(); - let mut query_chars = query.chars().peekable(); - - for (byte_idx, candidate_char) in candidate.char_indices() { - if let Some(&query_char) = query_chars.peek() { - if candidate_char.eq_ignore_ascii_case(&query_char) { - positions.push(byte_idx); - query_chars.next(); - } - } else { - break; - } - } - - if query_chars.peek().is_none() { - Some(positions) - } else { - None - } -} - // TODO: The mapping from workspace root paths to git repositories needs a // unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, // thread persistence (which PathList is saved to the database), and thread @@ -328,6 +404,25 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +fn workspace_has_agent_panel_terminals(workspace: &Entity, cx: &App) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty()) +} + +fn workspace_contains_worktree_path( + workspace: &Entity, + worktree_path: &Path, + cx: &App, +) -> bool { + let project = workspace.read(cx).project().clone(); + project + .read(cx) + .visible_worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) +} + #[derive(Clone)] struct WorkspaceMenuWorktreeLabel { icon: Option, @@ -505,6 +600,17 @@ impl Sidebar { .detach(); AgentThreadWorktreeLabelFlag::watch(cx); + cx.observe_flag::( + window, + |enabled, this, _window, cx| { + if !*enabled && matches!(this.active_entry, Some(ActiveEntry::Terminal { .. })) { + this.active_entry = None; + } + this.sync_active_entry_from_active_workspace(cx); + this.update_entries(cx); + }, + ) + .detach(); let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -749,13 +855,11 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ActiveViewFocused + | AgentPanelEvent::EntryChanged => { + this.sync_active_entry_from_panel(agent_panel, cx); this.update_entries(cx); } AgentPanelEvent::ThreadInteracted { thread_id } => { @@ -830,7 +934,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: pending_thread_id, session_id, workspace: active_workspace, @@ -842,7 +946,14 @@ impl Sidebar { return false; } - if let Some(thread_id) = panel.active_thread_id(cx) { + if cx.has_flag::() + && let Some(terminal_id) = panel.active_terminal_id() + { + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: active_workspace, + }); + } else if let Some(thread_id) = panel.active_thread_id(cx) { let is_archived = ThreadMetadataStore::global(cx) .read(cx) .entry(thread_id) @@ -851,7 +962,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id, session_id, workspace: active_workspace, @@ -925,7 +1036,7 @@ impl Sidebar { .detach_and_log_err(cx); } - fn open_workspace_and_create_draft( + fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, window: &mut Window, @@ -957,7 +1068,7 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; this.update_in(cx, |this, window, cx| { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); })?; anyhow::Ok(()) }) @@ -1009,6 +1120,7 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; + let mut notified_terminals: HashSet = HashSet::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); @@ -1072,6 +1184,15 @@ impl Sidebar { for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; + let terminals: Vec = group_workspaces + .iter() + .flat_map(|workspace| terminal_entries_for_workspace(workspace, cx)) + .collect(); + notified_terminals.extend( + terminals + .iter() + .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + ); if group_key.path_list().paths().is_empty() { continue; } @@ -1297,7 +1418,7 @@ impl Sidebar { } } - let has_threads = if !threads.is_empty() { + let has_threads = if !threads.is_empty() || !terminals.is_empty() { true } else { let store = ThreadMetadataStore::global(cx).read(cx); @@ -1344,7 +1465,20 @@ impl Sidebar { } } - if matched_threads.is_empty() && !workspace_matched { + let mut matched_terminals: Vec = Vec::new(); + for mut terminal in terminals { + let mut terminal_matched = false; + if let Some(positions) = fuzzy_match_positions(&query, &terminal.title) { + terminal.highlight_positions = positions; + terminal_matched = true; + } + if workspace_matched || terminal_matched { + matched_terminals.push(terminal); + } + } + + if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched + { continue; } @@ -1359,13 +1493,13 @@ impl Sidebar { has_threads, }); - for thread in matched_threads { - if let Some(sid) = thread.metadata.session_id.clone() { - current_session_ids.insert(sid); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + matched_terminals, + matched_threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } else { project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { @@ -1382,13 +1516,13 @@ impl Sidebar { continue; } - for thread in threads { - if let Some(sid) = &thread.metadata.session_id { - current_session_ids.insert(sid.clone()); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + terminals, + threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } } @@ -1400,6 +1534,7 @@ impl Sidebar { self.contents = SidebarContents { entries, notified_threads, + notified_terminals, project_header_indices, has_open_projects, }; @@ -1436,7 +1571,7 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) + .position(|entry| matches!(entry, ListEntry::Thread(_) | ListEntry::Terminal(_))) .or_else(|| { if self.contents.entries.is_empty() { None @@ -1483,8 +1618,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) - || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); self.project_header_menu_handles.entry(ix).or_default(); self.render_project_header( @@ -1503,6 +1640,9 @@ impl Sidebar { ) } ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), + ListEntry::Terminal(terminal) => { + self.render_terminal(ix, terminal, is_active, is_selected, cx) + } }; if is_group_header_after_first { @@ -1705,9 +1845,9 @@ impl Sidebar { this.set_group_expanded(&key, true, cx); this.selection = None; if let Some(workspace) = this.workspace_for_group(&key, cx) { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); } else { - this.open_workspace_and_create_draft(&key, window, cx); + this.open_workspace_and_create_entry(&key, window, cx); } }, )) @@ -2127,7 +2267,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); let header_element = self.render_project_header( header_idx, @@ -2391,6 +2534,10 @@ impl Sidebar { } } } + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, false, window, cx); + } } } @@ -2514,7 +2661,7 @@ impl Sidebar { // Set active_entry eagerly so the sidebar highlight updates // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -2583,7 +2730,7 @@ impl Sidebar { { target_sidebar.update(cx, |sidebar, cx| { sidebar.pending_thread_activation = Some(metadata_thread_id); - sidebar.active_entry = Some(ActiveEntry { + sidebar.active_entry = Some(ActiveEntry::Thread { thread_id: metadata_thread_id, session_id: target_session_id.clone(), workspace: workspace_for_entry.clone(), @@ -2957,7 +3104,7 @@ impl Sidebar { self.update_entries(cx); } } - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { @@ -2984,7 +3131,7 @@ impl Sidebar { // Find the group header for the current selection. let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), - Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), Some(ListEntry::ProjectHeader { .. }) @@ -3053,6 +3200,148 @@ impl Sidebar { } } + /// Find the neighbor thread in the sidebar (by display position). + /// Look below first, then above, for the nearest thread that isn't + /// the one being archived. We capture both the neighbor's metadata + /// (for activation) and its workspace paths (for the workspace + /// removal fallback). + fn neighboring_activatable_entry(&self, current_position: usize) -> Option { + let after = self + .contents + .entries + .get(current_position.checked_add(1)?..)?; + let before = self.contents.entries.get(..current_position)?; + after + .iter() + .chain(before.iter().rev()) + .find_map(ActivatableEntry::from_list_entry) + } + + fn activate_entry( + &mut self, + entry: &ActivatableEntry, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match entry { + ActivatableEntry::Thread { metadata, .. } => { + let Some(workspace) = self.multi_workspace.upgrade().and_then(|multi_workspace| { + multi_workspace + .read(cx) + .workspace_for_paths(metadata.folder_paths(), None, cx) + }) else { + return false; + }; + + self.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: workspace.clone(), + }); + self.activate_workspace(&workspace, window, cx); + Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); + true + } + ActivatableEntry::Terminal { + terminal_id, + workspace, + } => { + if !cx.has_flag::() { + return false; + } + let Some(workspace) = self + .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) + else { + return false; + }; + self.activate_terminal(&workspace, *terminal_id, false, window, cx); + true + } + } + } + + fn activate_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + retain: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + if retain { + multi_workspace.retain_active_workspace(cx); + } + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, true, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + + self.update_entries(cx); + } + + fn close_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self + .active_entry + .as_ref() + .is_some_and(|entry| entry.is_active_terminal(terminal_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .and_then(|position| { + self.neighboring_activatable_entry(position) + }); + + // Closing from the sidebar must not steal focus, since the row's + // workspace may not be the active workspace. + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + } + }); + + if is_active { + self.active_entry = None; + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } + self.update_entries(cx); + } + fn archive_thread( &mut self, session_id: &acp::SessionId, @@ -3064,7 +3353,7 @@ impl Sidebar { let active_workspace = metadata.as_ref().and_then(|metadata| { self.active_entry.as_ref().and_then(|entry| { if entry.is_active_thread(&metadata.thread_id) { - Some(entry.workspace.clone()) + Some(entry.workspace().clone()) } else { None } @@ -3126,15 +3415,20 @@ impl Sidebar { ) }) }) + .filter(|root| { + !workspaces.iter().any(|workspace| { + workspace_has_agent_panel_terminals(workspace, cx) + && workspace_contains_worktree_path( + workspace, + root.root_path.as_path(), + cx, + ) + }) + }) .collect::>() }) .unwrap_or_default(); - // Find the neighbor thread in the sidebar (by display position). - // Look below first, then above, for the nearest thread that isn't - // the one being archived. We capture both the neighbor's metadata - // (for activation) and its workspace paths (for the workspace - // removal fallback). let current_pos = self.contents.entries.iter().position(|entry| match entry { ListEntry::Thread(thread) => thread_id.map_or_else( || thread.metadata.session_id.as_ref() == Some(session_id), @@ -3142,27 +3436,8 @@ impl Sidebar { ), _ => false, }); - let neighbor = current_pos.and_then(|pos| { - self.contents.entries[pos + 1..] - .iter() - .chain(self.contents.entries[..pos].iter().rev()) - .find_map(|entry| match entry { - ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => { - let (workspace_paths, project_group_key) = match &t.workspace { - ThreadEntryWorkspace::Open(ws) => ( - PathList::new(&ws.read(cx).root_paths(cx)), - ws.read(cx).project_group_key(cx), - ), - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => (folder_paths.clone(), project_group_key.clone()), - }; - Some((t.metadata.clone(), workspace_paths, project_group_key)) - } - _ => None, - }) - }); + let neighbor = + current_pos.and_then(|position| self.neighboring_activatable_entry(position)); // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. @@ -3188,6 +3463,10 @@ impl Sidebar { .read(cx) .workspace_for_paths(folder_paths, None, cx)?; + if workspace_has_agent_panel_terminals(&workspace, cx) { + return None; + } + let group_key = workspace.read(cx).project_group_key(cx); let is_linked_worktree = group_key.path_list() != folder_paths; @@ -3278,12 +3557,12 @@ impl Sidebar { let (fallback_paths, project_group_key) = neighbor .as_ref() - .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone())) + .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() - .map(|ws| { - let key = ws.read(cx).project_group_key(cx); + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() @@ -3314,7 +3593,6 @@ impl Sidebar { ) }); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { @@ -3333,7 +3611,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3345,7 +3623,6 @@ impl Sidebar { .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { @@ -3360,7 +3637,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3371,13 +3648,12 @@ impl Sidebar { }) .detach_and_log_err(cx); } else { - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let in_flight = thread_id .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx)); self.archive_and_activate( session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3406,7 +3682,7 @@ impl Sidebar { &mut self, _session_id: &acp::SessionId, thread_id: Option, - neighbor: Option<&ThreadMetadata>, + neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, @@ -3456,25 +3732,8 @@ impl Sidebar { return; } - // Try to activate the neighbor thread. If its workspace is open, - // tell the panel to load it and activate that workspace. - // `rebuild_contents` will reconcile `active_entry` once the thread - // finishes loading. - - if let Some(metadata) = neighbor { - if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(metadata.folder_paths(), None, cx) - }) { - self.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - self.activate_workspace(&workspace, window, cx); - Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); - return; - } + if neighbor.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { + return; } // No neighbor or its workspace isn't open — just clear the @@ -3633,6 +3892,38 @@ impl Sidebar { metadata.interacted_at.unwrap_or(metadata.updated_at) } + fn push_entries_by_display_time( + entries: &mut Vec, + terminals: Vec, + threads: Vec, + current_session_ids: &mut HashSet, + current_thread_ids: &mut HashSet, + ) { + fn display_time(entry: &ListEntry) -> DateTime { + match entry { + ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), + ListEntry::Terminal(terminal) => terminal.created_at, + ListEntry::ProjectHeader { .. } => unreachable!(), + } + } + + let row_entries = terminals + .into_iter() + .map(ListEntry::Terminal) + .chain(threads.into_iter().map(ListEntry::Thread)) + .sorted_by_key(|right| std::cmp::Reverse(display_time(right))); + + for entry in row_entries { + if let ListEntry::Thread(thread) = &entry { + if let Some(session_id) = &thread.metadata.session_id { + current_session_ids.insert(session_id.clone()); + } + current_thread_ids.insert(thread.metadata.thread_id); + } + entries.push(entry); + } + } + /// The sort order used by the ctrl-tab switcher fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering { let sort_time = |x: &ThreadMetadata| { @@ -3704,6 +3995,7 @@ impl Sidebar { timestamp, }) } + ListEntry::Terminal(_) => None, }) .collect(); @@ -3755,8 +4047,11 @@ impl Sidebar { let weak_multi_workspace = self.multi_workspace.clone(); - let original_metadata = match &self.active_entry { - Some(ActiveEntry { thread_id, .. }) => entries + // Capture the full active entry so dismissal can restore terminal + // entries too, not just threads. + let original_active_entry = self.active_entry.clone(); + let original_metadata = match &original_active_entry { + Some(ActiveEntry::Thread { thread_id, .. }) => entries .iter() .find(|e| *thread_id == e.metadata.thread_id) .map(|e| e.metadata.clone()), @@ -3783,7 +4078,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3804,7 +4099,7 @@ impl Sidebar { }); } this.record_thread_access(&metadata.thread_id); - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3821,24 +4116,46 @@ impl Sidebar { }); } } - if let Some(metadata) = &original_metadata { - if let Some(original_ws) = &original_workspace { - this.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: original_ws.clone(), + match &original_active_entry { + Some(ActiveEntry::Thread { .. }) => { + if let (Some(metadata), Some(original_ws)) = + (&original_metadata, &original_workspace) + { + this.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: original_ws.clone(), + }); + this.update_entries(cx); + Self::load_agent_thread_in_workspace( + original_ws, + metadata, + false, + window, + cx, + ); + } + } + Some(ActiveEntry::Terminal { + terminal_id, + workspace, + }) => { + let terminal_id = *terminal_id; + let workspace = workspace.clone(); + this.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + this.update_entries(cx); + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, false, window, cx); + }); + } }); } - this.update_entries(cx); - if let Some(original_ws) = &original_workspace { - Self::load_agent_thread_in_workspace( - original_ws, - metadata, - false, - window, - cx, - ); - } + None => {} } this.dismiss_thread_switcher(cx); } @@ -3877,7 +4194,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -4025,6 +4342,61 @@ impl Sidebar { .into_any_element() } + fn render_terminal( + &self, + ix: usize, + terminal: &TerminalEntry, + is_active: bool, + is_focused: bool, + cx: &mut Context, + ) -> AnyElement { + let id = ElementId::from(format!("terminal-{}", terminal.id)); + let timestamp = format_history_entry_timestamp(terminal.created_at); + let is_hovered = self.hovered_thread_index == Some(ix); + let color = cx.theme().colors(); + let sidebar_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + let terminal_id = terminal.id; + let workspace = terminal.workspace.clone(); + + ThreadItem::new(id, terminal.title.clone()) + .base_bg(sidebar_bg) + .icon(IconName::Terminal) + .timestamp(timestamp) + .notified(terminal.has_notification) + .highlight_positions(terminal.highlight_positions.clone()) + .selected(is_active) + .focused(is_focused) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) + .when(is_hovered, |this| { + this.action_slot( + IconButton::new("close-terminal", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Close Terminal")) + .on_click(cx.listener(move |this, _, window, cx| { + this.close_terminal(&workspace, terminal_id, window, cx); + })), + ) + }) + .on_click(cx.listener({ + let workspace = terminal.workspace.clone(); + move |this, _, window, cx| { + this.activate_terminal(&workspace, terminal_id, false, window, cx); + } + })) + .into_any_element() + } + fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { div() .min_w_0() @@ -4101,15 +4473,39 @@ impl Sidebar { self.set_group_expanded(&key, true, cx); self.selection = None; if let Some(workspace) = self.workspace_for_group(&key, cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); } else { - self.open_workspace_and_create_draft(&key, window, cx); + self.open_workspace_and_create_entry(&key, window, cx); } } else if let Some(workspace) = self.active_workspace(cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); } } + fn create_new_entry( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_workspace(workspace, cx) { + self.create_new_terminal(workspace, window, cx); + } else { + self.create_new_thread(workspace, window, cx); + } + } + + fn should_create_terminal_for_workspace( + &self, + workspace: &Entity, + cx: &App, + ) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| panel.read(cx).should_create_terminal_for_new_entry(cx)) + } + fn create_new_thread( &mut self, workspace: &Entity, @@ -4127,7 +4523,7 @@ impl Sidebar { let draft_id = workspace.update(cx, |workspace, cx| { let panel = workspace.panel::(cx)?; let draft_id = panel.update(cx, |panel, cx| { - panel.activate_draft(true, "sidebar", window, cx); + panel.activate_new_thread(true, "sidebar", window, cx); panel.active_thread_id(cx) }); workspace.focus_panel::(window, cx); @@ -4135,7 +4531,7 @@ impl Sidebar { }); if let Some(draft_id) = draft_id { - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: draft_id, session_id: None, workspace: workspace.clone(), @@ -4143,11 +4539,35 @@ impl Sidebar { } } + fn create_new_terminal( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_terminal(Some(workspace), window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + } + fn selected_group_key(&self) -> Option { let ix = self.selection?; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { (0..ix) .rev() .find_map(|i| match self.contents.entries.get(i) { @@ -4279,7 +4699,7 @@ impl Sidebar { .iter() .enumerate() .filter_map(|(ix, entry)| match entry { - ListEntry::Thread(_) => Some(ix), + ListEntry::Thread(_) | ListEntry::Terminal(_) => Some(ix), _ => None, }) .collect(); @@ -4307,30 +4727,35 @@ impl Sidebar { }; let entry_ix = thread_indices[next_pos]; - let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else { - return; - }; - - let metadata = thread.metadata.clone(); - match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => { - let workspace = workspace.clone(); - self.activate_thread(metadata, &workspace, true, window, cx); + match &self.contents.entries[entry_ix] { + ListEntry::Thread(thread) => { + let metadata = thread.metadata.clone(); + match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => { + let workspace = workspace.clone(); + self.activate_thread(metadata, &workspace, true, window, cx); + } + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + let folder_paths = folder_paths.clone(); + let project_group_key = project_group_key.clone(); + self.open_workspace_and_activate_thread( + metadata, + folder_paths, + &project_group_key, + window, + cx, + ); + } + } } - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => { - let folder_paths = folder_paths.clone(); - let project_group_key = project_group_key.clone(); - self.open_workspace_and_activate_thread( - metadata, - folder_paths, - &project_group_key, - window, - cx, - ); + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, true, window, cx); } + ListEntry::ProjectHeader { .. } => {} } } @@ -4868,7 +5293,7 @@ impl WorkspaceSidebar for Sidebar { } fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() + !self.contents.notified_threads.is_empty() || !self.contents.notified_terminals.is_empty() } fn is_threads_list_view_active(&self) -> bool { @@ -5044,6 +5469,33 @@ impl Render for Sidebar { } } +fn terminal_entries_for_workspace( + workspace: &Entity, + cx: &App, +) -> impl Iterator { + if !cx.has_flag::() { + return None.into_iter().flatten(); + } + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return None.into_iter().flatten(); + }; + let terminals = + agent_panel + .read(cx) + .terminals(cx) + .into_iter() + .map(|terminal: AgentPanelTerminalInfo| TerminalEntry { + id: terminal.id, + title: terminal.title, + workspace: workspace.clone(), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + highlight_positions: Vec::new(), + }); + + Some(terminals).into_iter().flatten() +} + fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 3747a7a4d39..1d553d7d5ad 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -33,13 +33,17 @@ fn init_test(cx: &mut TestAppContext) { }); } +fn enable_agent_panel_terminal(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); +} + #[track_caller] fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) { let active = sidebar.active_entry.as_ref(); let matches = active.is_some_and(|entry| { - // Match by session_id directly on active_entry. - entry.session_id.as_ref() == Some(session_id) - // Or match by finding the thread in sidebar entries. + matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id) || sidebar.contents.entries.iter().any(|list_entry| { matches!(list_entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) @@ -67,7 +71,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { }); match thread_id { Some(tid) => { - matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid) + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid) } // Thread not in sidebar entries — can't confirm it's active. None => false, @@ -77,7 +81,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { #[track_caller] fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity, msg: &str) { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace), "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}", workspace.entity_id(), sidebar.active_entry, @@ -147,6 +151,12 @@ fn assert_remote_project_integration_sidebar_state( title ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`", + terminal.title + ); + } } } @@ -434,6 +444,7 @@ fn request_test_tool_authorization( "Allow", acp::PermissionOptionKind::AllowOnce, )]), + acp_thread::AuthorizationKind::PermissionGrant, cx, ) .unwrap() @@ -517,6 +528,10 @@ fn visible_entries_as_strings( format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } } + ListEntry::Terminal(terminal) => { + let title = &terminal.title; + format!(" {title}{selected}") + } } }) .collect() @@ -1408,6 +1423,150 @@ fn setup_sidebar_with_agent_panel( (sidebar, panel) } +#[gpui::test] +async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id), + "expected active terminal entry, got {:?}", + sidebar.active_entry, + ); + assert!( + sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server") + }), + "expected the inserted terminal to appear in sidebar contents", + ); + }); + + type_in_search(&sidebar, "server", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server <== selected"] + ); + + type_in_search(&sidebar, "missing", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let build_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("build test terminal should be inserted"); + let server_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("server test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(server_terminal_id)); + }); + + panel.update(cx, |panel, cx| { + panel.emit_test_terminal_bell(build_terminal_id, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(sidebar.has_notifications(cx)); + assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id)); + assert!(sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification) + })); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_terminal(build_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(!sidebar.has_notifications(cx)); + assert!( + !sidebar + .contents + .notified_terminals + .contains(&build_terminal_id) + ); + }); +} + +#[gpui::test] +async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + let build_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("build test terminal should be inserted"); + let server_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("server test terminal should be inserted"); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&workspace, server_terminal_id, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(server_terminal_id)); + assert_eq!(panel.active_terminal_id(), Some(build_terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id), + "expected remaining terminal to become active, got {:?}", + sidebar.active_entry, + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Build"] + ); +} + #[gpui::test] async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -2740,7 +2899,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // because the panel has a thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "Panel has a thread with messages, so active_entry should be Thread, got {:?}", sidebar.active_entry, ); @@ -2776,7 +2935,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // false — the panel still has the old thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "After adding a folder the panel still has a thread with messages, \ so active_entry should be Thread, got {:?}", sidebar.active_entry, @@ -3873,6 +4032,12 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje title, worktree_name ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while opening linked worktree thread: title=`{}`", + terminal.title + ); + } } } @@ -6241,7 +6406,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // active_entry should still be a draft on workspace_b (the active one). sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected Draft(workspace_b) after archiving non-active thread, got: {:?}", sidebar.active_entry, ); @@ -6278,7 +6443,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // sidebar row but active_entry tracks it. sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected draft on workspace_b after archiving active thread, got: {:?}", sidebar.active_entry, ); @@ -9773,7 +9938,7 @@ mod property_test { // 3. The entry must match the agent panel's current state. if panel.read(cx).active_thread_id(cx).is_some() { anyhow::ensure!( - matches!(entry, ActiveEntry { .. }), + matches!(entry, ActiveEntry::Thread { .. }), "panel shows a tracked draft but active_entry is {:?}", entry, ); @@ -9783,7 +9948,7 @@ mod property_test { .map(|cv| cv.read(cx).parent_id()) { anyhow::ensure!( - matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id), + matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id), "panel has thread {:?} but active_entry is {:?}", thread_id, entry, @@ -9795,8 +9960,11 @@ mod property_test { // a draft, which is represented by the + button's active state // rather than a sidebar row. // TODO: Make this check more complete - let is_draft = panel.read(cx).active_thread_is_draft(cx) - || panel.read(cx).active_conversation_view().is_none(); + // Active terminals must still match a row, so don't treat the absence + // of a conversation view as "draft" when a terminal is active. + let is_draft = panel.read(cx).active_terminal_id().is_none() + && (panel.read(cx).active_thread_is_draft(cx) + || panel.read(cx).active_conversation_view().is_none()); if is_draft { return Ok(()); } diff --git a/crates/snippets_ui/src/snippets_ui.rs b/crates/snippets_ui/src/snippets_ui.rs index c881d5276e6..ffb136f6252 100644 --- a/crates/snippets_ui/src/snippets_ui.rs +++ b/crates/snippets_ui/src/snippets_ui.rs @@ -2,7 +2,7 @@ use file_icons::FileIcons; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ App, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement, Render, Styled, - WeakEntity, Window, actions, + TaskExt, WeakEntity, Window, actions, }; use language::{LanguageMatcher, LanguageName, LanguageRegistry}; use open_path_prompt::file_finder_settings::FileFinderSettings; diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index ac4087bb96b..67adf2583d8 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -9,7 +9,7 @@ use fuzzy_nucleo::StringMatchCandidate; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point, - Render, Styled, Task, WeakEntity, Window, actions, rems, + Render, Styled, Task, TaskExt, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate}; use project::Project; diff --git a/crates/task/src/static_source.rs b/crates/task/src/static_source.rs index 9e4051ef972..a98d38a8eb8 100644 --- a/crates/task/src/static_source.rs +++ b/crates/task/src/static_source.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use futures::{StreamExt, channel::mpsc::UnboundedSender}; -use gpui::{App, AppContext}; +use gpui::{App, AppContext, TaskExt}; use parking_lot::RwLock; use serde::Deserialize; use util::ResultExt; diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index ca8ebb5248e..072ad29c1b7 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -2,7 +2,7 @@ use std::{path::Path, sync::Arc}; use collections::HashMap; use editor::Editor; -use gpui::{App, AppContext as _, Context, Entity, Task, Window}; +use gpui::{App, AppContext as _, Context, Entity, Task, TaskExt, Window}; use project::{Location, TaskContexts, TaskSourceKind, Worktree}; use task::{RevealTarget, TaskContext, TaskId, TaskTemplate, TaskVariables, VariableName}; use workspace::Workspace; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 99b3b9d6ce4..7069110edd3 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -3341,6 +3341,27 @@ mod tests { }); } + /// Polls the terminal content until `expected` appears, or panics after ~1s. + /// The PTY IO thread writes into the terminal grid independently of the + /// GPUI executor, so we need a real-time polling loop to synchronize. + async fn assert_content_eventually( + terminal: &Entity, + expected: &str, + cx: &mut TestAppContext, + ) { + let mut content = String::new(); + for _ in 0..100 { + content = terminal.update(cx, |term, _| term.get_content()); + if content.contains(expected) { + return; + } + cx.background_executor + .timer(Duration::from_millis(10)) + .await; + } + panic!("Expected terminal content to contain {expected:?}, got: {content}"); + } + /// Test that kill_active_task properly terminates both the foreground process /// and the shell, allowing wait_for_completed_task to complete and output to be captured. #[cfg(unix)] @@ -3353,10 +3374,7 @@ mod tests { let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["test_output_before_kill; sleep 60"]).await; - // Wait a bit for the echo to execute and produce output - cx.background_executor - .timer(Duration::from_millis(200)) - .await; + assert_content_eventually(&terminal, "test_output_before_kill", cx).await; // Kill the active task terminal.update(cx, |term, _cx| { @@ -3400,6 +3418,8 @@ mod tests { .expect("Should receive exit status"); assert_eq!(exit_status, Some(ExitStatus::default())); + assert_content_eventually(&terminal, "done", cx).await; + // Now try to kill - should be a no-op since task already completed terminal.update(cx, |term, _cx| { term.kill_active_task(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 642243ae147..25e4ad9d999 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -12,8 +12,8 @@ use db::kvp::KeyValueStore; use futures::{channel::oneshot, future::join_all}; use gpui::{ Action, Anchor, AnyView, App, AsyncApp, AsyncWindowContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, WeakEntity, - Window, actions, + FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Task, TaskExt, + WeakEntity, Window, actions, }; use itertools::Itertools; use project::{Fs, Project}; @@ -1539,11 +1539,7 @@ impl Focusable for TerminalPanel { impl Panel for TerminalPanel { fn position(&self, _window: &Window, cx: &App) -> DockPosition { - match TerminalSettings::get_global(cx).dock { - TerminalDockPosition::Left => DockPosition::Left, - TerminalDockPosition::Bottom => DockPosition::Bottom, - TerminalDockPosition::Right => DockPosition::Right, - } + TerminalSettings::get_global(cx).dock.into() } fn position_is_valid(&self, _: DockPosition) -> bool { @@ -1670,6 +1666,12 @@ impl Panel for TerminalPanel { fn activation_priority(&self) -> u32 { 2 } + + fn hide_button_setting(&self, _: &App) -> Option { + Some(workspace::HideStatusItem::new(|settings| { + settings.terminal.get_or_insert_default().button = Some(false); + })) + } } struct TerminalProvider(Entity); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index f0f13d8fc2c..fb3abf41db7 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -1,7 +1,7 @@ use super::{HoverTarget, HoveredWord, TerminalView}; use anyhow::{Context as _, Result}; use editor::Editor; -use gpui::{App, AppContext, Context, Task, WeakEntity, Window}; +use gpui::{App, AppContext, Context, Task, TaskExt, WeakEntity, Window}; use itertools::Itertools; use project::{Entry, Metadata}; use std::path::PathBuf; diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index a6e28a95f50..37c4c165836 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -11,8 +11,8 @@ use editor::{ use gpui::{ Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, - Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, - anchored, deferred, div, + Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, TaskExt, WeakEntity, + actions, anchored, deferred, div, }; use itertools::Itertools; use menu; @@ -2005,7 +2005,7 @@ impl SearchableItem for TerminalView { /// For remote projects, local-only resolution (home dir fallback, shell expansion, /// local `is_dir` checks) is skipped -- returning `None` lets the remote shell /// open in the remote user's home directory by default. -pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { +pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { let is_remote = workspace.project().read(cx).is_remote(); let directory = match &TerminalSettings::get_global(cx).working_directory { WorkingDirectory::CurrentFileDirectory => workspace diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 026f1272790..4b947234054 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -38,7 +38,7 @@ use std::{ }; pub use subscription::*; pub use sum_tree::Bias; -use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; +use sum_tree::{Dimensions, FilterCursor, SumTree, Summary, TreeMap, TreeSet}; use undo_map::UndoMap; use util::debug_panic; @@ -912,7 +912,8 @@ impl Buffer { let mut new_ropes = RopeBuilder::new(self.visible_text.cursor(0), self.deleted_text.cursor(0)); let mut old_fragments = self.fragments.cursor::(&None); - let mut new_fragments = old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right); + let mut new_fragments = + FragmentBuilder::new(old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right)); new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().visible; @@ -1044,7 +1045,7 @@ impl Buffer { let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); - self.snapshot.fragments = new_fragments; + self.snapshot.fragments = new_fragments.to_sum_tree(&None); self.snapshot.insertions.edit(new_insertions, ()); self.snapshot.visible_text = visible_text; self.snapshot.deleted_text = deleted_text; @@ -1127,8 +1128,9 @@ impl Buffer { let mut old_fragments = self .fragments .cursor::>(&cx); - let mut new_fragments = - old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left); + let mut new_fragments = FragmentBuilder::new( + old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left), + ); new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().0.full_offset(); @@ -1291,7 +1293,7 @@ impl Buffer { let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); - self.snapshot.fragments = new_fragments; + self.snapshot.fragments = new_fragments.to_sum_tree(&None); self.snapshot.visible_text = visible_text; self.snapshot.deleted_text = deleted_text; self.snapshot.insertions.edit(new_insertions, ()); @@ -1303,7 +1305,7 @@ impl Buffer { new_text: &str, timestamp: clock::Lamport, insertion_offset: &mut u32, - new_fragments: &mut SumTree, + new_fragments: &mut FragmentBuilder, new_insertions: &mut Vec>, insertion_slices: &mut Vec, new_ropes: &mut RopeBuilder, @@ -2836,6 +2838,39 @@ impl BufferSnapshot { } } +struct FragmentBuilder { + fragments: Vec, + summary: FragmentSummary, +} + +impl FragmentBuilder { + fn new(init: SumTree) -> Self { + Self { + summary: init.summary().clone(), + fragments: init.iter().cloned().collect(), + } + } + fn append(&mut self, items: SumTree, cx: &Option) { + if !items.is_empty() { + self.summary.add_summary(items.summary(), cx); + self.fragments.extend(items.iter().cloned()); + } + } + fn push(&mut self, fragment: Fragment, cx: &Option) { + self.append(SumTree::from_item(fragment, cx), cx); + } + fn to_sum_tree(self, cx: &Option) -> SumTree { + if self.fragments.len() > 1024 { + SumTree::from_par_iter(self.fragments, cx) + } else { + SumTree::from_iter(self.fragments, cx) + } + } + fn summary(&self) -> &FragmentSummary { + &self.summary + } +} + struct RopeBuilder<'a> { old_visible_cursor: rope::Cursor<'a>, old_deleted_cursor: rope::Cursor<'a>, diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 14e1df38841..b52e855f2b4 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -129,6 +129,12 @@ impl ThemeColors { editor_document_highlight_read_background: neutral().light_alpha().step_3(), editor_document_highlight_write_background: neutral().light_alpha().step_4(), editor_document_highlight_bracket_background: green().light_alpha().step_5(), + editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.16), + editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.08), + editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.48), + editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.16), + editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.08), + editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.48), terminal_background: neutral().light().step_1(), terminal_foreground: black().light().step_12(), terminal_bright_foreground: black().light().step_11(), @@ -276,6 +282,12 @@ impl ThemeColors { editor_document_highlight_read_background: neutral().dark_alpha().step_4(), editor_document_highlight_write_background: neutral().dark_alpha().step_4(), editor_document_highlight_bracket_background: green().dark_alpha().step_6(), + editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12), + editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06), + editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36), + editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12), + editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06), + editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36), terminal_background: neutral().dark().step_1(), terminal_ansi_background: neutral().dark().step_1(), terminal_foreground: white().dark().step_12(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 22a2c737048..2716eae0b23 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -185,6 +185,12 @@ pub(crate) fn zed_default_dark() -> Theme { ), editor_document_highlight_write_background: gpui::red(), editor_document_highlight_bracket_background: gpui::green(), + editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12), + editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06), + editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36), + editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12), + editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06), + editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36), terminal_background: bg, // todo("Use one colors for terminal") diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 63ccdacca7a..f9ebd441aaf 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -241,6 +241,18 @@ pub struct ThemeColors { /// /// Matching brackets in the cursor scope are highlighted with this background color. pub editor_document_highlight_bracket_background: Hsla, + /// Filled background color for added diff hunk row highlights in the editor. + pub editor_diff_hunk_added_background: Hsla, + /// Hollow background color for added diff hunk row highlights in the editor. + pub editor_diff_hunk_added_hollow_background: Hsla, + /// Hollow border color for added diff hunk row highlights in the editor. + pub editor_diff_hunk_added_hollow_border: Hsla, + /// Filled background color for deleted diff hunk row highlights in the editor. + pub editor_diff_hunk_deleted_background: Hsla, + /// Hollow background color for deleted diff hunk row highlights in the editor. + pub editor_diff_hunk_deleted_hollow_background: Hsla, + /// Hollow border color for deleted diff hunk row highlights in the editor. + pub editor_diff_hunk_deleted_hollow_border: Hsla, // === // Terminal diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs index 76c2e2a8b24..3f4a20adbfb 100644 --- a/crates/theme_settings/src/schema.rs +++ b/crates/theme_settings/src/schema.rs @@ -13,6 +13,13 @@ pub use settings::{FontWeightContent, WindowBackgroundContent}; use theme::{StatusColorsRefinement, ThemeColorsRefinement}; +const LIGHT_DIFF_HUNK_FILLED_OPACITY: f32 = 0.16; +const LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.08; +const LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.48; +const DARK_DIFF_HUNK_FILLED_OPACITY: f32 = 0.12; +const DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.06; +const DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.36; + /// The content of a serialized theme family. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ThemeFamilyContent { @@ -230,6 +237,7 @@ pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> Statu pub fn theme_colors_refinement( this: &settings::ThemeColorsContent, status_colors: &StatusColorsRefinement, + is_light: bool, ) -> ThemeColorsRefinement { let border = this .border @@ -278,6 +286,29 @@ pub fn theme_colors_refinement( .as_ref() .and_then(|color| try_parse_color(color).ok()) .or(search_match_background); + let version_control_added = this + .version_control_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.created); + let version_control_deleted = this + .version_control_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.deleted); + let (hunk_fill, hunk_hollow_bg, hunk_hollow_border) = if is_light { + ( + LIGHT_DIFF_HUNK_FILLED_OPACITY, + LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY, + LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY, + ) + } else { + ( + DARK_DIFF_HUNK_FILLED_OPACITY, + DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY, + DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY, + ) + }; ThemeColorsRefinement { border, border_variant: this @@ -576,6 +607,36 @@ pub fn theme_colors_refinement( .as_ref() .and_then(|color| try_parse_color(color).ok()) .or(editor_document_highlight_read_background), + editor_diff_hunk_added_background: this + .editor_diff_hunk_added_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_added.map(|c| c.opacity(hunk_fill))), + editor_diff_hunk_added_hollow_background: this + .editor_diff_hunk_added_hollow_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_bg))), + editor_diff_hunk_added_hollow_border: this + .editor_diff_hunk_added_hollow_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_border))), + editor_diff_hunk_deleted_background: this + .editor_diff_hunk_deleted_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_deleted.map(|c| c.opacity(hunk_fill))), + editor_diff_hunk_deleted_hollow_background: this + .editor_diff_hunk_deleted_hollow_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_bg))), + editor_diff_hunk_deleted_hollow_border: this + .editor_diff_hunk_deleted_hollow_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_border))), terminal_background: this .terminal_background .as_ref() @@ -696,16 +757,8 @@ pub fn theme_colors_refinement( .link_text_hover .as_ref() .and_then(|color| try_parse_color(color).ok()), - version_control_added: this - .version_control_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(status_colors.created), - version_control_deleted: this - .version_control_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(status_colors.deleted), + version_control_added, + version_control_deleted, version_control_modified: this .version_control_modified .as_ref() @@ -856,7 +909,165 @@ fn try_parse_color(color: &str) -> anyhow::Result { #[cfg(test)] mod tests { - use super::*; + use theme::StatusColorsRefinement; + + use super::{ + StatusColorsContent, ThemeColorsContent, status_colors_refinement, theme_colors_refinement, + try_parse_color, + }; + + #[test] + fn explicit_diff_hunk_colors_take_precedence_over_fallbacks() { + let mut colors = ThemeColorsContent::default(); + colors.editor_diff_hunk_added_background = Some("#112233".to_string()); + colors.editor_diff_hunk_added_hollow_background = Some("#223344".to_string()); + colors.editor_diff_hunk_added_hollow_border = Some("#334455".to_string()); + colors.editor_diff_hunk_deleted_background = Some("#445566".to_string()); + colors.editor_diff_hunk_deleted_hollow_background = Some("#556677".to_string()); + colors.editor_diff_hunk_deleted_hollow_border = Some("#667788".to_string()); + colors.version_control_added = Some("#00ff00".to_string()); + colors.version_control_deleted = Some("#ff0000".to_string()); + + let refinement = theme_colors_refinement( + &colors, + &status_colors_refinement(&StatusColorsContent::default()), + true, + ); + + assert_eq!( + refinement.editor_diff_hunk_added_background, + Some(parse_color("#112233")) + ); + assert_eq!( + refinement.editor_diff_hunk_added_hollow_background, + Some(parse_color("#223344")) + ); + assert_eq!( + refinement.editor_diff_hunk_added_hollow_border, + Some(parse_color("#334455")) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_background, + Some(parse_color("#445566")) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_hollow_background, + Some(parse_color("#556677")) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_hollow_border, + Some(parse_color("#667788")) + ); + } + + #[test] + fn diff_hunk_colors_fallback_to_version_control_colors() { + let mut colors = ThemeColorsContent::default(); + colors.version_control_added = Some("#00ff00".to_string()); + colors.version_control_deleted = Some("#ff0000".to_string()); + + let refinement = theme_colors_refinement( + &colors, + &status_colors_refinement(&StatusColorsContent::default()), + true, + ); + + let added = parse_color("#00ff00"); + let deleted = parse_color("#ff0000"); + + assert_eq!( + refinement.editor_diff_hunk_added_background, + Some(added.opacity(0.16)) + ); + assert_eq!( + refinement.editor_diff_hunk_added_hollow_background, + Some(added.opacity(0.08)) + ); + assert_eq!( + refinement.editor_diff_hunk_added_hollow_border, + Some(added.opacity(0.48)) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_background, + Some(deleted.opacity(0.16)) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_hollow_background, + Some(deleted.opacity(0.08)) + ); + assert_eq!( + refinement.editor_diff_hunk_deleted_hollow_border, + Some(deleted.opacity(0.48)) + ); + } + + #[test] + fn diff_hunk_opacity_fallbacks_use_correct_values_for_light_and_dark_themes() { + let mut colors = ThemeColorsContent::default(); + colors.version_control_added = Some("#00ff00".to_string()); + + let light_refinement = theme_colors_refinement( + &colors, + &status_colors_refinement(&StatusColorsContent::default()), + true, + ); + let dark_refinement = theme_colors_refinement( + &colors, + &status_colors_refinement(&StatusColorsContent::default()), + false, + ); + + let added = parse_color("#00ff00"); + + assert_eq!( + light_refinement.editor_diff_hunk_added_background, + Some(added.opacity(0.16)) + ); + assert_eq!( + light_refinement.editor_diff_hunk_added_hollow_background, + Some(added.opacity(0.08)) + ); + assert_eq!( + light_refinement.editor_diff_hunk_added_hollow_border, + Some(added.opacity(0.48)) + ); + + assert_eq!( + dark_refinement.editor_diff_hunk_added_background, + Some(added.opacity(0.12)) + ); + assert_eq!( + dark_refinement.editor_diff_hunk_added_hollow_background, + Some(added.opacity(0.06)) + ); + assert_eq!( + dark_refinement.editor_diff_hunk_added_hollow_border, + Some(added.opacity(0.36)) + ); + } + + #[test] + fn diff_hunk_fallbacks_are_absent_when_status_and_version_control_colors_are_missing() { + let refinement = theme_colors_refinement( + &ThemeColorsContent::default(), + &status_colors_refinement(&StatusColorsContent::default()), + true, + ); + + assert_eq!(refinement.editor_diff_hunk_added_background, None); + assert_eq!(refinement.editor_diff_hunk_added_hollow_background, None); + assert_eq!(refinement.editor_diff_hunk_added_hollow_border, None); + assert_eq!(refinement.editor_diff_hunk_deleted_background, None); + assert_eq!(refinement.editor_diff_hunk_deleted_hollow_background, None); + assert_eq!(refinement.editor_diff_hunk_deleted_hollow_border, None); + } + + fn parse_color(color: &str) -> gpui::Hsla { + match try_parse_color(color) { + Ok(color) => color, + Err(error) => panic!("failed to parse color {color}: {error}"), + } + } #[test] fn helix_jump_label_color_uses_theme_color_or_status_error() { @@ -867,8 +1078,11 @@ mod tests { ..Default::default() }; - let fallback_refinement = - theme_colors_refinement(&ThemeColorsContent::default(), &status_colors); + let fallback_refinement = theme_colors_refinement( + &ThemeColorsContent::default(), + &status_colors, + Default::default(), + ); assert_eq!( fallback_refinement.vim_helix_jump_label_foreground, @@ -881,6 +1095,7 @@ mod tests { ..Default::default() }, &status_colors, + Default::default(), ); assert_eq!( diff --git a/crates/theme_settings/src/settings.rs b/crates/theme_settings/src/settings.rs index 727f9425ca6..86432cf7b5e 100644 --- a/crates/theme_settings/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -476,10 +476,12 @@ impl ThemeSettings { } let status_color_refinement = status_colors_refinement(&theme_overrides.status); - base_theme.styles.colors.refine(&theme_colors_refinement( + let theme_color_refinement = theme_colors_refinement( &theme_overrides.colors, &status_color_refinement, - )); + base_theme.appearance.is_light(), + ); + base_theme.styles.colors.refine(&theme_color_refinement); base_theme.styles.status.refine(&status_color_refinement); merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players); merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents); diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs index 9be00af4755..b5bf1a60283 100644 --- a/crates/theme_settings/src/theme_settings.rs +++ b/crates/theme_settings/src/theme_settings.rs @@ -296,8 +296,11 @@ pub fn refine_theme(theme: &ThemeContent) -> Theme { AppearanceContent::Light => ThemeColors::light(), AppearanceContent::Dark => ThemeColors::dark(), }; - let mut theme_colors_refinement = - theme_colors_refinement(&theme.style.colors, &status_colors_refinement); + let mut theme_colors_refinement = theme_colors_refinement( + &theme.style.colors, + &status_colors_refinement, + theme.appearance == AppearanceContent::Light, + ); theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); refined_theme_colors.refine(&theme_colors_refinement); diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 474d0d287e4..72569b84fd4 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -5,8 +5,8 @@ use call::{ActiveCall, Room}; use channel::ChannelStore; use client::{User, proto::PeerId}; use gpui::{ - AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, WeakEntity, - canvas, point, + AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt, + WeakEntity, canvas, point, }; use gpui::{App, Task, Window}; use icons::IconName; diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index 96400a91a0a..24dccdc35b9 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -2,7 +2,7 @@ // It's currently not in use but is kept for future feature announcements. #![allow(dead_code)] -use gpui::{Action, Entity, Global, Render, SharedString}; +use gpui::{Action, Entity, Global, Render, SharedString, TaskExt}; use ui::{ButtonLike, Tooltip, prelude::*}; use util::ResultExt; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index c6f82adcdf0..8e194218990 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -29,7 +29,7 @@ use cloud_api_types::Plan; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, Context, Element, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, - StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, + StatefulInteractiveElement, Styled, Subscription, TaskExt, WeakEntity, Window, actions, div, pulsating_between, }; use onboarding_banner::OnboardingBanner; diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index a9218564b55..72c24df92e0 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use editor::Editor; use gpui::{ - AsyncWindowContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, - Task, WeakEntity, Window, div, + App, AsyncWindowContext, Context, Entity, IntoElement, ParentElement, Render, Styled, + Subscription, Task, WeakEntity, Window, div, }; use language::{Buffer, BufferEvent, LanguageName, Toolchain, ToolchainScope}; use project::{Project, ProjectPath, Toolchains, WorktreeId, toolchain_store::ToolchainStoreEvent}; use ui::{Button, ButtonCommon, Clickable, LabelSize, SharedString, Tooltip}; use util::{maybe, rel_path::RelPath}; -use workspace::{StatusItemView, Workspace, item::ItemHandle}; +use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use crate::ToolchainSelector; @@ -264,4 +264,10 @@ impl StatusItemView for ActiveToolchain { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + // The toolchain selector only appears when the active buffer has a + // language with toolchain support. + None + } } diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index c8f330526df..ef21abaf3ec 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -5,7 +5,7 @@ use crate::{ use gpui::{ Action, Anchor, AnyElement, App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size, - Subscription, anchored, canvas, prelude::*, px, + Subscription, TaskExt, anchored, canvas, prelude::*, px, }; use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious}; use std::{ diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 94877af090f..d0baca0f476 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -416,6 +416,68 @@ impl PathStyle { } } + pub fn join_path( + self, + left: impl AsRef, + right: impl AsRef, + ) -> anyhow::Result { + let left = left + .as_ref() + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let right = right.as_ref(); + let right_string = right + .to_str() + .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?; + let joined = self + .join(left, right_string) + .ok_or_else(|| anyhow::anyhow!("Path must be relative: {right:?}"))?; + Ok(PathBuf::from(self.normalize(&joined))) + } + + pub fn normalize(self, path_like: &str) -> String { + match self { + PathStyle::Windows => crate::normalize_path(Path::new(path_like)) + .to_string_lossy() + .into_owned(), + PathStyle::Posix => { + let is_absolute = path_like.starts_with('/'); + let remainder = if is_absolute { + path_like.trim_start_matches('/') + } else { + path_like + }; + + let mut components = Vec::new(); + for component in remainder.split(self.separators_ch()) { + match component { + "" | "." => {} + ".." => { + if components + .last() + .is_some_and(|component| *component != "..") + { + components.pop(); + } else if !is_absolute { + components.push(component); + } + } + component => components.push(component), + } + } + + let normalized = components.join(self.primary_separator()); + if is_absolute && normalized.is_empty() { + "/".to_string() + } else if is_absolute { + format!("/{normalized}") + } else { + normalized + } + } + } + } + pub fn split(self, path_like: &str) -> (Option<&str>, &str) { let Some(pos) = path_like.rfind(self.primary_separator()) else { return (None, path_like); @@ -1566,6 +1628,34 @@ mod tests { use super::*; use util_macros::perf; + #[test] + fn test_join_path_uses_path_style_separator() { + let posix_path = PathStyle::Posix + .join_path(Path::new("/home/user/dev"), "worktrees") + .unwrap(); + let windows_path = PathStyle::Windows + .join_path(Path::new("C:\\Users\\user\\dev"), "worktrees") + .unwrap(); + + assert_eq!(posix_path, PathBuf::from("/home/user/dev/worktrees")); + assert_eq!( + windows_path.to_string_lossy(), + "C:\\Users\\user\\dev\\worktrees" + ); + } + + #[test] + fn test_normalize_uses_path_style_separator() { + assert_eq!( + PathStyle::Posix.normalize("/home/user/dev/../worktrees/./zed"), + "/home/user/worktrees/zed" + ); + assert_eq!( + PathStyle::Windows.normalize("C:\\Users\\user\\dev\\worktrees"), + "C:\\Users\\user\\dev\\worktrees" + ); + } + fn rel_path_entry(path: &'static str, is_file: bool) -> (&'static RelPath, bool) { (RelPath::unix(path).unwrap(), is_file) } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 6c10c321233..da7092db699 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -8,7 +8,8 @@ use editor::{ }; use futures::AsyncWriteExt as _; use gpui::{ - Action, App, AppContext as _, Context, Global, Keystroke, Task, WeakEntity, Window, actions, + Action, App, AppContext as _, Context, Global, Keystroke, Task, TaskExt, WeakEntity, Window, + actions, }; use itertools::Itertools; use language::Point; diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index d61b0547aef..796d69b2822 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -11,7 +11,7 @@ use editor::{ NavigationTargetOverlay, SelectionEffects, ToOffset, ToPoint, movement, }; use gpui::actions; -use gpui::{App, Context, Font, Hsla, Pixels, Window, WindowTextSystem}; +use gpui::{App, Context, Font, Hsla, Pixels, TaskExt, Window, WindowTextSystem}; use language::{CharClassifier, CharKind, Point, Selection}; use multi_buffer::MultiBufferSnapshot; use search::{BufferSearchBar, SearchOptions}; @@ -2960,6 +2960,61 @@ mod test { cx.assert_state("«ˇone» two three", Mode::HelixSelect); } + // Regression test for ZED-758: helix motions called + // `Editor::text_layout_details` on an editor whose `style` had never + // been set, panicking on `unwrap()`. + #[gpui::test] + async fn test_helix_motion_on_unrendered_editor(cx: &mut gpui::TestAppContext) { + use editor::{Editor, EditorMode, SelectionEffects}; + use multi_buffer::{MultiBuffer, MultiBufferOffset}; + + VimTestContext::init(cx); + cx.update(|cx| { + VimTestContext::init_keybindings(true, cx); + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |s| { + s.vim_mode = Some(true); + s.helix_mode = Some(true); + }); + }); + }); + + let cx = cx.add_empty_window(); + + let editor = cx.update(|window, cx| { + use gpui::AppContext as _; + let buffer = MultiBuffer::build_simple("one two three", cx); + cx.new(|cx| { + let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(4)..MultiBufferOffset(4)]) + }); + editor + }) + }); + + let vim = editor + .read_with(cx, |editor, _| editor.addon::().cloned()) + .expect("VimAddon should be auto-attached to new editors when vim mode is enabled"); + + cx.update(|window, cx| { + vim.entity.update(cx, |vim, cx| { + vim.switch_mode(Mode::HelixNormal, true, window, cx); + vim.helix_move_and_collapse(crate::motion::Motion::Left, None, window, cx); + }); + }); + + let cursor_offset = cx.update(|_, cx| { + editor.update(cx, |editor, cx| { + editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head() + }) + }); + assert_eq!(cursor_offset, MultiBufferOffset(3)); + } + #[gpui::test] async fn test_helix_select_regex(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 4660bbfb829..daab005183d 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -1,6 +1,8 @@ -use gpui::{Context, Element, Entity, FontWeight, Render, Subscription, WeakEntity, Window, div}; +use gpui::{ + App, Context, Element, Entity, FontWeight, Render, Subscription, WeakEntity, Window, div, +}; use ui::text_for_keystrokes; -use workspace::{StatusItemView, item::ItemHandle, ui::prelude::*}; +use workspace::{HideStatusItem, StatusItemView, item::ItemHandle, ui::prelude::*}; use crate::{Vim, VimEvent, VimGlobals}; @@ -186,4 +188,9 @@ impl StatusItemView for ModeIndicator { _cx: &mut Context, ) { } + + fn hide_setting(&self, _: &App) -> Option { + // The Vim mode indicator is only visible while Vim mode is on. + None + } } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 6e992704f54..28669d4890a 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2452,7 +2452,7 @@ fn find_matching_bracket_text_based( .find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset))); if bracket_info.is_none() { - return find_matching_c_preprocessor_directive(map, line_range); + return find_matching_c_preprocessor_directive(map, line_range, offset); } let (open, close, is_opening) = bracket_info?.0; @@ -2489,18 +2489,20 @@ fn find_matching_bracket_text_based( fn find_matching_c_preprocessor_directive( map: &DisplaySnapshot, line_range: Range, + offset: MultiBufferOffset, ) -> Option { let line_start = map .buffer_chars_at(line_range.start) .skip_while(|(c, _)| *c == ' ' || *c == '\t') + .take_while(|(c, char_offset)| *char_offset < line_range.end && !c.is_whitespace()) .map(|(c, _)| c) - .take(6) .collect::(); - if line_start.starts_with("#if") - || line_start.starts_with("#else") - || line_start.starts_with("#elif") - { + if line_range.start + line_start.len() < offset { + return None; + } + + if line_start.starts_with("#if") || line_start.starts_with("#el") { let mut depth = 0i32; for (ch, char_offset) in map.buffer_chars_at(line_range.end) { if ch != '\n' { @@ -2618,8 +2620,30 @@ fn matching( // Ensure the range is contained by the current line. let mut line_end = map.next_line_boundary(point).0; - if line_end == point { - line_end = map.max_point().to_point(map); + let max_point = map.max_point().to_point(map); + + // Only widen to EOF when the cursor is actually at EOF. + // This avoids expanding a blank current line into start..EOF. + if line_end == point && point == max_point { + line_end = max_point; + } + + let line_range = map.prev_line_boundary(point).0..line_end; + let line_range = line_range.start.to_offset(&map.buffer_snapshot()) + ..line_range.end.to_offset(&map.buffer_snapshot()); + + if let Some(preproc_range) = find_matching_c_preprocessor_directive(map, line_range, offset) { + return preproc_range.to_display_point(map); + } + + if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) { + if open_range.contains(&offset) { + return close_range.start.to_display_point(map); + } + + if close_range.contains(&offset) { + return open_range.start.to_display_point(map); + } } let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`'); @@ -2729,32 +2753,6 @@ fn matching( continue; } - if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) { - if open_range.contains(&offset) { - return close_range.start.to_display_point(map); - } - - if close_range.contains(&offset) { - return open_range.start.to_display_point(map); - } - - let open_candidate = (open_range.start >= offset - && line_range.contains(&open_range.start)) - .then_some((open_range.start.saturating_sub(offset), close_range.start)); - - let close_candidate = (close_range.start >= offset - && line_range.contains(&close_range.start)) - .then_some((close_range.start.saturating_sub(offset), open_range.start)); - - if let Some((_, destination)) = [open_candidate, close_candidate] - .into_iter() - .flatten() - .min_by_key(|(distance, _)| *distance) - { - return destination.to_display_point(map); - } - } - closest_pair_destination .map(|destination| destination.to_display_point(map)) .unwrap_or_else(|| { @@ -3663,6 +3661,10 @@ mod test { cx.shared_state().await.assert_eq(indoc! {r"/* this is a comment ˇ*/"}); + cx.simulate_shared_keystrokes("k %").await; + cx.shared_state().await.assert_eq(indoc! {r"/* + ˇ this is a comment + */"}); cx.set_shared_state("ˇ// comment").await; cx.simulate_shared_keystrokes("%").await; @@ -3673,48 +3675,53 @@ mod test { async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; - cx.set_shared_state(indoc! {r"#ˇif + cx.set_shared_state(indoc! {r" + #ˇif - #else + #else - #endif - "}) + #endif + "}) .await; cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"#if + cx.shared_state().await.assert_eq(indoc! {r" + #if ˇ#else #endif - "}); + "}); cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"#if + cx.shared_state().await.assert_eq(indoc! {r" + #if #else ˇ#endif - "}); + "}); cx.simulate_shared_keystrokes("%").await; - cx.shared_state().await.assert_eq(indoc! {r"ˇ#if + cx.shared_state().await.assert_eq(indoc! {r" + ˇ#if #else #endif - "}); + "}); cx.set_shared_state(indoc! {r" - #ˇif - #if - - #else - - #endif + #ˇif + #if #else + #endif - "}) + + #else + + #endif + "}) .await; cx.simulate_shared_keystrokes("%").await; @@ -3727,8 +3734,9 @@ mod test { #endif ˇ#else + #endif - "}); + "}); cx.simulate_shared_keystrokes("% %").await; cx.shared_state().await.assert_eq(indoc! {r" @@ -3740,8 +3748,9 @@ mod test { #endif #else + #endif - "}); + "}); cx.simulate_shared_keystrokes("j % % %").await; cx.shared_state().await.assert_eq(indoc! {r" #if @@ -3752,8 +3761,28 @@ mod test { #endif #else + #endif - "}); + "}); + + cx.set_shared_state(indoc! {r" + #if definedˇ(something) + + #endif + "}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r" + #if defined(somethingˇ) + + #endif + "}); + cx.simulate_shared_keystrokes("0 %").await; + cx.shared_state().await.assert_eq(indoc! {r" + #if defined(something) + + ˇ#endif + "}); } #[gpui::test] diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1d0d0812e82..e2ce1fb1284 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -28,7 +28,7 @@ use editor::Editor; use editor::{Anchor, SelectionEffects}; use editor::{Bias, ToPoint}; use editor::{display_map::ToDisplayPoint, movement}; -use gpui::{Context, Window, actions}; +use gpui::{Context, TaskExt, Window, actions}; use language::{AutoIndentMode, Point, SelectionGoal}; use log::error; use multi_buffer::MultiBufferRow; diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index 48cf8739b72..7f205a0fb8f 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -5,7 +5,7 @@ use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, movement, }; -use gpui::{Context, Entity, EntityId, UpdateGlobal, Window}; +use gpui::{Context, Entity, EntityId, TaskExt, UpdateGlobal, Window}; use language::SelectionGoal; use text::Point; use ui::App; diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index e7d17af1e3e..4fde2f786ce 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -1,5 +1,5 @@ use editor::{Editor, EditorSettings}; -use gpui::{Action, Context, Window, actions}; +use gpui::{Action, Context, TaskExt, Window, actions}; use language::Point; use schemars::JsonSchema; use search::{BufferSearchBar, SearchOptions, buffer_search}; diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index 208bbfc7e6b..2f130355c17 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -22,7 +22,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { vim.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { let mut positions = vim.save_selection_starts(editor, cx); - editor.rewrap_impl( + editor.rewrap( RewrapOptions { override_language_settings: true, line_length: action.line_length, @@ -74,7 +74,7 @@ impl Vim { ); }); }); - editor.rewrap_impl( + editor.rewrap( RewrapOptions { override_language_settings: true, ..Default::default() @@ -112,7 +112,7 @@ impl Vim { object.expand_selection(map, selection, around, times); }); }); - editor.rewrap_impl( + editor.rewrap( RewrapOptions { override_language_settings: true, ..Default::default() diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 3ca4d704c7c..85bf84d8878 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -15,7 +15,8 @@ use editor::display_map::{is_invisible, replacement}; use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint}; use gpui::{ Action, App, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, DismissEvent, Entity, - EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity, + EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TaskExt, TextStyle, + WeakEntity, }; use language::{Buffer, BufferEvent, BufferId, Chunk, LanguageAwareStyling, Point}; @@ -78,6 +79,10 @@ impl Mode { pub fn is_helix(&self) -> bool { matches!(self, Self::HelixNormal | Self::HelixSelect) } + + pub fn is_normal(&self) -> bool { + matches!(self, Self::Normal | Self::HelixNormal) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 6c0c3d0201b..d247e240310 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -2209,7 +2209,9 @@ impl Vim { autoindent: self.should_autoindent(), cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(), line_mode: matches!(self.mode, Mode::VisualLine), - hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace), + hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace) + && !(self.mode.is_normal() + && VimSettings::get_global(cx).show_edit_predictions_in_normal_mode), } } @@ -2259,6 +2261,7 @@ struct VimSettings { pub custom_digraphs: HashMap>, pub highlight_on_yank_duration: u64, pub cursor_shape: CursorShapeSettings, + pub show_edit_predictions_in_normal_mode: bool, } /// Cursor shape configuration for insert mode. @@ -2346,6 +2349,7 @@ impl Settings for VimSettings { custom_digraphs: vim.custom_digraphs.unwrap(), highlight_on_yank_duration: vim.highlight_on_yank_duration.unwrap(), cursor_shape: vim.cursor_shape.unwrap().into(), + show_edit_predictions_in_normal_mode: vim.show_edit_predictions_in_normal_mode.unwrap(), } } } diff --git a/crates/vim/test_data/test_matching_comments.json b/crates/vim/test_data/test_matching_comments.json index 7fcf5e46e1e..8d130621913 100644 --- a/crates/vim/test_data/test_matching_comments.json +++ b/crates/vim/test_data/test_matching_comments.json @@ -5,6 +5,9 @@ {"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}} {"Key":"%"} {"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}} +{"Key":"k"} +{"Key":"%"} +{"Get":{"state":"/*\nˇ this is a comment\n*/","mode":"Normal"}} {"Put":{"state":"ˇ// comment"}} {"Key":"%"} {"Get":{"state":"ˇ// comment","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_preprocessor_directives.json b/crates/vim/test_data/test_matching_preprocessor_directives.json index 9f0bd9792ee..7a55ac7995f 100644 --- a/crates/vim/test_data/test_matching_preprocessor_directives.json +++ b/crates/vim/test_data/test_matching_preprocessor_directives.json @@ -5,14 +5,20 @@ {"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}} {"Key":"%"} {"Get":{"state":"ˇ#if\n\n#else\n\n#endif\n","mode":"Normal"}} -{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n"}} +{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n"}} {"Key":"%"} -{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n\n#endif\n","mode":"Normal"}} {"Key":"%"} {"Key":"%"} -{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}} {"Key":"j"} {"Key":"%"} {"Key":"%"} {"Key":"%"} -{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} +{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}} +{"Put":{"state":"#if definedˇ(something)\n\n#endif\n"}} +{"Key":"%"} +{"Get":{"state":"#if defined(somethingˇ)\n\n#endif\n","mode":"Normal"}} +{"Key":"0"} +{"Key":"%"} +{"Get":{"state":"#if defined(something)\n\nˇ#endif\n","mode":"Normal"}} diff --git a/crates/workspace/src/active_file_name.rs b/crates/workspace/src/active_file_name.rs index f35312d5294..68d57c2983b 100644 --- a/crates/workspace/src/active_file_name.rs +++ b/crates/workspace/src/active_file_name.rs @@ -1,11 +1,13 @@ use gpui::{ - Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, + App, Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, }; use settings::Settings; use ui::{Button, Tooltip, prelude::*}; use util::paths::PathStyle; -use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings}; +use crate::{ + HideStatusItem, StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings, +}; pub struct ActiveFileName { project_path: Option, @@ -66,4 +68,10 @@ impl StatusItemView for ActiveFileName { } cx.notify(); } + + fn hide_setting(&self, _: &App) -> Option { + Some(HideStatusItem::new(|settings| { + settings.status_bar.get_or_insert_default().show_active_file = Some(false); + })) + } } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 1983b2921ff..62655a90639 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1,5 +1,6 @@ use crate::focus_follows_mouse::FocusFollowsMouse as _; use crate::persistence::model::DockData; +use crate::status_bar::HideStatusItem; use crate::{DraggedDock, Event, FocusFollowsMouse, ModalLayer, Pane, WorkspaceSettings}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; @@ -13,7 +14,7 @@ use gpui::{ px, }; use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsStore}; +use settings::{Settings, SettingsStore, TerminalDockPosition}; use std::sync::Arc; use ui::{ ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*, @@ -86,6 +87,12 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { fn is_agent_panel(&self) -> bool { false } + /// Returns metadata describing how to hide this panel's button from the + /// status bar by writing to user settings. Implementors should return + /// `None` if the panel button cannot be hidden through settings. + fn hide_button_setting(&self, _: &App) -> Option { + None + } } pub trait PanelHandle: Send + Sync { @@ -116,6 +123,7 @@ pub trait PanelHandle: Send + Sync { fn activation_priority(&self, cx: &App) -> u32; fn enabled(&self, cx: &App) -> bool; fn is_agent_panel(&self, cx: &App) -> bool; + fn hide_button_setting(&self, cx: &App) -> Option; fn move_to_next_position(&self, window: &mut Window, cx: &mut App) { let current_position = self.position(window, cx); let next_position = [ @@ -244,6 +252,10 @@ where fn is_agent_panel(&self, cx: &App) -> bool { self.read(cx).is_agent_panel() } + + fn hide_button_setting(&self, cx: &App) -> Option { + self.read(cx).hide_button_setting(cx) + } } impl From<&dyn PanelHandle> for AnyView { @@ -301,6 +313,16 @@ impl Into for DockPosition { } } +impl From for DockPosition { + fn from(value: TerminalDockPosition) -> Self { + match value { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + } + } +} + impl DockPosition { fn label(&self) -> &'static str { match self { @@ -1241,6 +1263,7 @@ impl Render for PanelButtons { DockPosition::Bottom, ]; + let panel_hide = panel.hide_button_setting(cx); ContextMenu::build(window, cx, |mut menu, _, cx| { let mut has_position_entries = false; for position in POSITIONS { @@ -1312,6 +1335,12 @@ impl Render for PanelButtons { }, ); } + if let Some(hide) = panel_hide { + menu = crate::status_bar::add_hide_button_entry( + menu.separator(), + hide, + ); + } menu }) }) @@ -1378,6 +1407,12 @@ impl StatusItemView for PanelButtons { ) { // Nothing to do, panel buttons don't depend on the active center item } + + fn hide_setting(&self, _: &App) -> Option { + // Panel buttons are hidden on a per-panel basis through each panel + // button's own context menu. + None + } } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 5cd669473c7..573a6d9ac0a 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -12,7 +12,7 @@ use client::{Client, proto}; use futures::channel::mpsc; use gpui::{ Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task, + EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task, TaskExt, WeakEntity, Window, }; use language::Capability; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 7916646311d..999b4d30413 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -3,8 +3,8 @@ use fs::Fs; use gpui::{ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, WeakEntity, Window, - WindowId, actions, deferred, px, + ManagedView, MouseButton, Pixels, Render, Subscription, Task, TaskExt, Tiling, WeakEntity, + Window, WindowId, actions, deferred, px, }; pub use project::ProjectGroupKey; use project::{DisableAiSettings, Project}; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index aa6e53ef666..4a2204d4c5f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -21,8 +21,8 @@ use gpui::{ Action, Anchor, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, Focusable, KeyContext, MouseButton, NavigationDirection, Pixels, Point, PromptLevel, Render, - ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored, - deferred, prelude::*, + ScrollHandle, Subscription, Task, TaskExt, WeakEntity, WeakFocusHandle, Window, actions, + anchored, deferred, prelude::*, }; use itertools::Itertools; use language::{Capability, DiagnosticSeverity}; diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index b1328aa3614..66af132b47c 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -2759,6 +2759,7 @@ mod tests { read_multi_workspace_state, }, }; + use gpui::TaskExt; use gpui::AppContext as _; use pretty_assertions::assert_eq; diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index fbaa1b50a0a..ae34936047d 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -3,12 +3,41 @@ use crate::{ sidebar_side_context_menu, }; use gpui::{ - Anchor, AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, Styled, - Subscription, WeakEntity, Window, + Anchor, AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, + SharedString, Styled, Subscription, WeakEntity, Window, }; -use std::any::TypeId; +use settings::{SettingsContent, update_settings_file}; +use std::{any::TypeId, sync::Arc}; use theme::CLIENT_SIDE_DECORATION_ROUNDING; -use ui::{Divider, Indicator, Tooltip, prelude::*}; +use ui::{ContextMenu, Divider, IconPosition, Indicator, Tooltip, prelude::*, right_click_menu}; + +/// Describes how a status-bar item can be hidden by the user. +/// +/// Every [`StatusItemView`] must either provide this (so that the user gets a +/// "Hide Button" entry in the right-click menu) or explicitly return `None` +/// to opt out. Returning `None` should be reserved for items that are +/// already conditional on some other setting exposed elsewhere (e.g., the +/// activity indicator, which disappears on its own once there's no work to +/// display). +#[derive(Clone)] +pub struct HideStatusItem { + hide: Arc, +} + +impl HideStatusItem { + pub fn new(hide: impl Fn(&mut SettingsContent) + Send + Sync + 'static) -> Self { + Self { + hide: Arc::new(hide), + } + } + + /// Persists the hide by updating the user settings file. + pub fn apply(&self, cx: &App) { + let hide = self.hide.clone(); + let fs = ::global(cx); + update_settings_file(fs, cx, move |settings, _cx| (hide)(settings)); + } +} pub trait StatusItemView: Render { /// Event callback that is triggered when the active pane item changes. @@ -18,6 +47,15 @@ pub trait StatusItemView: Render { window: &mut Window, cx: &mut Context, ); + + /// Returns metadata describing how this item can be hidden from the + /// status bar by writing to the user settings file. + /// + /// Implementors that return `None` must be inherently conditional on + /// another user-exposed setting; otherwise, they should return `Some` so + /// that the status bar can show a "Hide Button" entry in its + /// right-click menu. + fn hide_setting(&self, cx: &App) -> Option; } trait StatusItemViewHandle: Send { @@ -29,6 +67,7 @@ trait StatusItemViewHandle: Send { cx: &mut App, ); fn item_type(&self) -> TypeId; + fn hide_setting(&self, cx: &App) -> Option; } #[derive(Default)] @@ -124,7 +163,9 @@ impl StatusBar { sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left, |this| this.child(self.render_sidebar_toggle(sidebar, cx)), ) - .children(self.left_items.iter().map(|item| item.to_any())) + .children(self.left_items.iter().enumerate().map(|(index, item)| { + render_hideable_item("status-bar-left", index, item.as_ref(), cx) + })) } fn render_right_tools( @@ -136,7 +177,15 @@ impl StatusBar { .flex_shrink_0() .gap_1() .overflow_x_hidden() - .children(self.right_items.iter().rev().map(|item| item.to_any())) + .children( + self.right_items + .iter() + .enumerate() + .rev() + .map(|(index, item)| { + render_hideable_item("status-bar-right", index, item.as_ref(), cx) + }), + ) .when( sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right, |this| this.child(self.render_sidebar_toggle(sidebar, cx)), @@ -201,6 +250,40 @@ impl StatusBar { } } +fn render_hideable_item( + side: &'static str, + index: usize, + item: &dyn StatusItemViewHandle, + cx: &App, +) -> impl IntoElement { + let view = item.to_any(); + let Some(hide) = item.hide_setting(cx) else { + return view.into_any_element(); + }; + + let menu_id: SharedString = format!("{side}-item-menu-{index}").into(); + right_click_menu(menu_id) + .trigger(move |_is_active, _window, _cx| view) + .menu(move |window, cx| { + let hide = hide.clone(); + ContextMenu::build(window, cx, move |menu, _window, _cx| { + add_hide_button_entry(menu, hide) + }) + }) + .into_any_element() +} + +/// Appends a "Hide Button" entry aligned with surrounding toggleable entries. +pub fn add_hide_button_entry(menu: ContextMenu, hide: HideStatusItem) -> ContextMenu { + menu.toggleable_entry( + "Hide Button", + false, + IconPosition::Start, + None, + move |_window, cx| hide.apply(cx), + ) +} + impl StatusBar { pub fn new( active_pane: &Entity, @@ -350,6 +433,10 @@ impl StatusItemViewHandle for Entity { fn item_type(&self) -> TypeId { TypeId::of::() } + + fn hide_setting(&self, cx: &App) -> Option { + self.read(cx).hide_setting(cx) + } } impl From<&dyn StatusItemViewHandle> for AnyView { diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 3ea35678865..501fc583f2d 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -2,7 +2,7 @@ use std::process::ExitStatus; use anyhow::Result; use collections::HashSet; -use gpui::{AppContext, AsyncWindowContext, Context, Entity, Task, WeakEntity}; +use gpui::{AppContext, AsyncWindowContext, Context, Entity, Task, TaskExt, WeakEntity}; use language::Buffer; use project::{TaskSourceKind, WorktreeId}; use remote::ConnectionState; @@ -121,7 +121,7 @@ impl Workspace { let save_action = match save_strategy { SaveStrategy::All => { let save_all = workspace.update_in(cx, |workspace, window, cx| { - let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx); + let task = workspace.save_all_internal(SaveIntent::SaveAll, true, window, cx); cx.background_spawn(async { task.await.map(|_| ()) }) }); save_all.ok() diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 4110cffc46d..122cc468a45 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -8,7 +8,7 @@ use agent_settings::AgentSettings; use git::Clone as GitClone; use gpui::{ Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - ParentElement, Render, Styled, Task, Window, actions, + ParentElement, Render, Styled, Task, TaskExt, Window, actions, }; use gpui::{WeakEntity, linear_color_stop, linear_gradient}; use menu::{SelectNext, SelectPrevious}; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 03b01cc79d8..267420cc15f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -63,8 +63,8 @@ use gpui::{ Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, - SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, - WindowOptions, actions, canvas, point, relative, size, transparent_black, + SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, WindowBounds, WindowHandle, + WindowId, WindowOptions, actions, canvas, point, relative, size, transparent_black, }; pub use history_manager::*; pub use item::{ @@ -118,7 +118,7 @@ use sqlez::{ statement::Statement, }; use status_bar::StatusBar; -pub use status_bar::StatusItemView; +pub use status_bar::{HideStatusItem, StatusItemView, add_hide_button_entry}; use std::{ any::TypeId, borrow::Cow, @@ -152,8 +152,8 @@ use util::{ }; use uuid::Uuid; pub use workspace_settings::{ - AutosaveSetting, BottomDockLayout, FocusFollowsMouse, RestoreOnStartupBehavior, - StatusBarSettings, TabBarSettings, WorkspaceSettings, + AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, FocusFollowsMouse, + RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, WorkspaceSettings, }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; @@ -3305,9 +3305,30 @@ impl Workspace { } } + // Hot-exit silently writes dirty buffers to the DB; only allow it + // if the workspace will be reachable again, either via session + // restore or by reopening its folder paths. Otherwise prompt, so + // we don't orphan the buffers. + let allow_hot_exit_serialization = close_intent == CloseIntent::Quit + || save_last_workspace + || this + .read_with(cx, |workspace, cx| { + workspace + .project + .read(cx) + .visible_worktrees(cx) + .next() + .is_some() + }) + .unwrap_or(false); let save_result = this .update_in(cx, |this, window, cx| { - this.save_all_internal(SaveIntent::Close, window, cx) + this.save_all_internal( + SaveIntent::Close, + allow_hot_exit_serialization, + window, + cx, + ) })? .await; @@ -3328,6 +3349,7 @@ impl Workspace { fn save_all(&mut self, action: &SaveAll, window: &mut Window, cx: &mut Context) { self.save_all_internal( action.save_intent.unwrap_or(SaveIntent::SaveAll), + true, window, cx, ) @@ -3425,12 +3447,13 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Task> { - self.save_all_internal(SaveIntent::Close, window, cx) + self.save_all_internal(SaveIntent::Close, true, window, cx) } fn save_all_internal( &mut self, mut save_intent: SaveIntent, + allow_hot_exit_serialization: bool, window: &mut Window, cx: &mut Context, ) -> Task> { @@ -3457,23 +3480,27 @@ impl Workspace { let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() { let mut serialize_tasks = Vec::new(); let mut remaining_dirty_items = Vec::new(); - workspace.update_in(cx, |workspace, window, cx| { - for (pane, item) in dirty_items { - if let Some(task) = item - .to_serializable_item_handle(cx) - .and_then(|handle| handle.serialize(workspace, true, window, cx)) - { - serialize_tasks.push((pane, item, task)); - } else { + if allow_hot_exit_serialization { + workspace.update_in(cx, |workspace, window, cx| { + for (pane, item) in dirty_items { + if let Some(task) = item + .to_serializable_item_handle(cx) + .and_then(|handle| handle.serialize(workspace, true, window, cx)) + { + serialize_tasks.push((pane, item, task)); + } else { + remaining_dirty_items.push((pane, item)); + } + } + })?; + + for (pane, item, task) in serialize_tasks { + if task.await.log_err().is_none() { remaining_dirty_items.push((pane, item)); } } - })?; - - for (pane, item, task) in serialize_tasks { - if task.await.log_err().is_none() { - remaining_dirty_items.push((pane, item)); - } + } else { + remaining_dirty_items = dirty_items; } if !remaining_dirty_items.is_empty() { @@ -5715,6 +5742,7 @@ impl Workspace { .insert(pane.downgrade(), leader_id); self.unfollow(leader_id, window, cx); self.unfollow_in_pane(&pane, window, cx); + self.auto_watch = AutoWatch::Off; self.follower_states.insert( leader_id, FollowerState { @@ -11473,7 +11501,7 @@ mod tests { } #[gpui::test] - async fn test_close_window_with_serializable_items(cx: &mut TestAppContext) { + async fn test_close_window_with_worktrees_hot_exits(cx: &mut TestAppContext) { init_test(cx); // Register TestItem as a serializable item @@ -11510,8 +11538,163 @@ mod tests { assert!(task.await.unwrap()); } + // See https://github.com/zed-industries/zed/issues/55726. + // + // macOS only: on Linux/Windows, closing the last window sets + // `save_last_workspace`, which preserves the session (same as `Quit`), + // so hot-exit is safe there. + #[cfg(target_os = "macos")] #[gpui::test] - async fn test_close_window_with_failing_serialization(cx: &mut TestAppContext) { + async fn test_close_window_without_worktrees_prompts(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::CloseWindow, window, cx) + }); + cx.executor().run_until_parked(); + + assert!( + cx.has_pending_prompt(), + "closing a no-folder workspace with a dirty serializable item should prompt, \ + since the workspace will not be reachable after close" + ); + cx.simulate_prompt_answer("Don't Save"); + cx.executor().run_until_parked(); + + assert!(task.await.unwrap()); + } + + #[gpui::test] + async fn test_quit_without_worktrees_hot_exits(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::Quit, window, cx) + }); + cx.executor().run_until_parked(); + + assert!( + !cx.has_pending_prompt(), + "quitting should hot-exit silently; the session restore on next \ + launch will bring the dirty buffer back" + ); + assert!(task.await.unwrap()); + } + + // See https://github.com/zed-industries/zed/issues/55726. + #[gpui::test] + async fn test_replace_window_without_worktrees_prompts(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) + }); + cx.executor().run_until_parked(); + + assert!( + cx.has_pending_prompt(), + "replacing a workspace with a dirty serializable item should prompt, \ + since the workspace will be detached afterwards" + ); + cx.simulate_prompt_answer("Don't Save"); + cx.executor().run_until_parked(); + + assert!(task.await.unwrap()); + } + + #[gpui::test] + async fn test_replace_window_with_worktrees_hot_exits(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + register_serializable_item::(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "one": "" })).await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_serialize(|| Some(Task::ready(Ok(())))) + }); + workspace.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + }); + + let task = workspace.update_in(cx, |w, window, cx| { + w.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) + }); + cx.executor().run_until_parked(); + + assert!( + !cx.has_pending_prompt(), + "replacing a workspace with folder paths should hot-exit silently; \ + the buffer is recoverable by reopening the project" + ); + assert!(task.await.unwrap()); + } + + #[gpui::test] + async fn test_close_window_with_failing_serialize_prompts(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 974219bf9bc..2b612928098 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -414,9 +414,13 @@ impl Worktree { None }; - let root_repo_common_dir = discover_root_repo_common_dir(&abs_path, fs.as_ref()) - .await - .map(SanitizedPath::from_arc); + let root_repo_common_dir = if visible { + discover_root_repo_common_dir(&abs_path, fs.as_ref()) + .await + .map(SanitizedPath::from_arc) + } else { + None + }; Ok(cx.new(move |cx: &mut Context| { let mut snapshot = LocalSnapshot { @@ -1147,6 +1151,7 @@ impl LocalWorktree { let next_entry_id = self.next_entry_id.clone(); let fs = self.fs.clone(); let scanning_enabled = self.scanning_enabled; + let track_git_repositories = self.visible; let settings = self.settings.clone(); let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_spawn({ @@ -1185,6 +1190,7 @@ impl LocalWorktree { share_private_files, settings, watcher, + track_git_repositories, is_single_file, }; @@ -3936,6 +3942,7 @@ struct BackgroundScanner { watcher: Arc, settings: WorktreeSettings, share_private_files: bool, + track_git_repositories: bool, /// Whether this is a single-file worktree (root is a file, not a directory). /// Used to determine if we should give up after repeated canonicalization failures. is_single_file: bool, @@ -3961,7 +3968,7 @@ impl BackgroundScanner { // If the worktree root does not contain a git repository, then find // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. - let repo = if scanning_enabled { + let repo = if scanning_enabled && self.track_git_repositories { let (ignores, exclude, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; let mut state = self.state.lock().await; @@ -3989,6 +3996,7 @@ impl BackgroundScanner { let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo && scanning_enabled + && self.track_git_repositories { maybe!(async { self.state @@ -4015,6 +4023,7 @@ impl BackgroundScanner { let mut global_gitignore_events = if let Some(global_gitignore_path) = &global_gitignore_file && scanning_enabled + && self.track_git_repositories { let is_file = self.fs.is_file(&global_gitignore_path).await; self.state.lock().await.snapshot.global_gitignore = if is_file { @@ -4352,14 +4361,16 @@ impl BackgroundScanner { let mut dot_git_paths = None; - for ancestor in abs_path.as_path().ancestors() { - if is_dot_git(ancestor, self.fs.as_ref()).await { - let path_in_git_dir = abs_path - .as_path() - .strip_prefix(ancestor) - .expect("stripping off the ancestor"); - dot_git_paths = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); - break; + if self.track_git_repositories { + for ancestor in abs_path.as_path().ancestors() { + if is_dot_git(ancestor, self.fs.as_ref()).await { + let path_in_git_dir = abs_path + .as_path() + .strip_prefix(ancestor) + .expect("stripping off the ancestor"); + dot_git_paths = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); + break; + } } } @@ -4384,9 +4395,10 @@ impl BackgroundScanner { } } - if abs_path - .as_path() - .ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) + if self.track_git_repositories + && abs_path + .as_path() + .ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) { if let Some(repository) = snapshot.git_repositories.values().find(|repo| { repo.common_dir_abs_path.join(REPO_EXCLUDE) == abs_path.as_path() @@ -4437,7 +4449,9 @@ impl BackgroundScanner { continue; }; - if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) { + if self.track_git_repositories + && abs_path.file_name() == Some(OsStr::new(GITIGNORE)) + { for (_, repo) in snapshot .git_repositories .iter() @@ -4774,29 +4788,33 @@ impl BackgroundScanner { continue; }; - if child_name == DOT_GIT { - let mut state = self.state.lock().await; - state - .insert_git_repository( - child_path.clone(), - self.fs.as_ref(), - self.watcher.as_ref(), - ) - .await; - } else if child_name == GITIGNORE { - match build_gitignore(&child_abs_path, self.fs.as_ref()).await { - Ok(ignore) => { - let ignore = Arc::new(ignore); - ignore_stack = ignore_stack - .append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone()); - new_ignore = Some(ignore); - } - Err(error) => { - log::error!( - "error loading .gitignore file {:?} - {:?}", - child_name, - error - ); + if self.track_git_repositories { + if child_name == DOT_GIT { + let mut state = self.state.lock().await; + state + .insert_git_repository( + child_path.clone(), + self.fs.as_ref(), + self.watcher.as_ref(), + ) + .await; + } else if child_name == GITIGNORE { + match build_gitignore(&child_abs_path, self.fs.as_ref()).await { + Ok(ignore) => { + let ignore = Arc::new(ignore); + ignore_stack = ignore_stack.append( + IgnoreKind::Gitignore(job.abs_path.clone()), + ignore.clone(), + ); + new_ignore = Some(ignore); + } + Err(error) => { + log::error!( + "error loading .gitignore file {:?} - {:?}", + child_name, + error + ); + } } } } @@ -5004,11 +5022,12 @@ impl BackgroundScanner { ) .await; - let mut new_ancestor_repo = if relative_paths.iter().any(|path| path.is_empty()) { - Some(discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await) - } else { - None - }; + let mut new_ancestor_repo = + if self.track_git_repositories && relative_paths.iter().any(|path| path.is_empty()) { + Some(discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await) + } else { + None + }; let mut state = self.state.lock().await; let doing_recursive_update = scan_queue_tx.is_some(); @@ -5054,7 +5073,8 @@ impl BackgroundScanner { if let (Some(scan_queue_tx), true) = (&scan_queue_tx, is_dir) { if state.should_scan_directory(&fs_entry) - || (fs_entry.path.is_empty() + || (self.track_git_repositories + && fs_entry.path.is_empty() && abs_path.file_name() == Some(OsStr::new(DOT_GIT))) { state diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index 87eb0fe3081..4fa1fa9a1e4 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -3283,6 +3283,50 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp ); } +#[gpui::test] +async fn test_invisible_worktree_does_not_track_ancestor_git_repository( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor); + fs.insert_tree( + path!("/repo"), + json!({ + ".git": {}, + "project": { + "file.txt": "content", + }, + }), + ) + .await; + + let worktree = Worktree::local( + path!("/repo/project").as_ref(), + false, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().scan_complete() + }) + .await; + cx.run_until_parked(); + + worktree.read_with(cx, |worktree, _| { + let local_worktree = worktree.as_local().unwrap(); + assert!(local_worktree.repositories().is_empty()); + assert_eq!(local_worktree.root_repo_common_dir(), None); + }); +} + #[gpui::test] async fn test_linked_worktree_git_file_event_does_not_panic( executor: BackgroundExecutor, diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index afa7d62aa3c..7ba13d83529 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -7,42 +7,13 @@ pub const XAI_API_URL: &str = "https://api.x.ai/v1"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] pub enum Model { - #[serde(rename = "grok-2-vision-latest")] - Grok2Vision, #[default] - #[serde(rename = "grok-3-latest")] - Grok3, - #[serde(rename = "grok-3-mini-latest")] - Grok3Mini, - #[serde(rename = "grok-3-fast-latest")] - Grok3Fast, - #[serde(rename = "grok-3-mini-fast-latest")] - Grok3MiniFast, - #[serde(rename = "grok-4", alias = "grok-4-latest")] - Grok4, - #[serde( - rename = "grok-4-fast-reasoning", - alias = "grok-4-fast-reasoning-latest" - )] - Grok4FastReasoning, - #[serde( - rename = "grok-4-fast-non-reasoning", - alias = "grok-4-fast-non-reasoning-latest" - )] - Grok4FastNonReasoning, - #[serde( - rename = "grok-4-1-fast-non-reasoning", - alias = "grok-4-1-fast-non-reasoning-latest" - )] - Grok41FastNonReasoning, - #[serde( - rename = "grok-4-1-fast-reasoning", - alias = "grok-4-1-fast-reasoning-latest", - alias = "grok-4-1-fast" - )] - Grok41FastReasoning, - #[serde(rename = "grok-code-fast-1", alias = "grok-code-fast-1-0825")] - GrokCodeFast1, + #[serde(rename = "grok-4.3", alias = "grok-4.3-latest")] + Grok43, + #[serde(rename = "grok-4.20-0309-reasoning")] + Grok420Reasoning, + #[serde(rename = "grok-4.20-0309-non-reasoning")] + Grok420NonReasoning, #[serde(rename = "custom")] Custom { name: String, @@ -59,57 +30,32 @@ pub enum Model { impl Model { pub fn default_fast() -> Self { - Self::Grok3Fast + Self::Grok43 } pub fn from_id(id: &str) -> Result { match id { - "grok-4" => Ok(Self::Grok4), - "grok-4-fast-reasoning" => Ok(Self::Grok4FastReasoning), - "grok-4-fast-non-reasoning" => Ok(Self::Grok4FastNonReasoning), - "grok-4-1-fast-non-reasoning" => Ok(Self::Grok41FastNonReasoning), - "grok-4-1-fast-reasoning" => Ok(Self::Grok41FastReasoning), - "grok-4-1-fast" => Ok(Self::Grok41FastReasoning), - "grok-2-vision" => Ok(Self::Grok2Vision), - "grok-3" => Ok(Self::Grok3), - "grok-3-mini" => Ok(Self::Grok3Mini), - "grok-3-fast" => Ok(Self::Grok3Fast), - "grok-3-mini-fast" => Ok(Self::Grok3MiniFast), - "grok-code-fast-1" => Ok(Self::GrokCodeFast1), + "grok-4.3" => Ok(Self::Grok43), + "grok-4.20-0309-reasoning" => Ok(Self::Grok420Reasoning), + "grok-4.20-0309-non-reasoning" => Ok(Self::Grok420NonReasoning), _ => anyhow::bail!("invalid model id '{id}'"), } } pub fn id(&self) -> &str { match self { - Self::Grok2Vision => "grok-2-vision", - Self::Grok3 => "grok-3", - Self::Grok3Mini => "grok-3-mini", - Self::Grok3Fast => "grok-3-fast", - Self::Grok3MiniFast => "grok-3-mini-fast", - Self::Grok4 => "grok-4", - Self::Grok4FastReasoning => "grok-4-fast-reasoning", - Self::Grok4FastNonReasoning => "grok-4-fast-non-reasoning", - Self::Grok41FastNonReasoning => "grok-4-1-fast-non-reasoning", - Self::Grok41FastReasoning => "grok-4-1-fast-reasoning", - Self::GrokCodeFast1 => "grok-code-fast-1", + Self::Grok43 => "grok-4.3", + Self::Grok420Reasoning => "grok-4.20-0309-reasoning", + Self::Grok420NonReasoning => "grok-4.20-0309-non-reasoning", Self::Custom { name, .. } => name, } } pub fn display_name(&self) -> &str { match self { - Self::Grok2Vision => "Grok 2 Vision", - Self::Grok3 => "Grok 3", - Self::Grok3Mini => "Grok 3 Mini", - Self::Grok3Fast => "Grok 3 Fast", - Self::Grok3MiniFast => "Grok 3 Mini Fast", - Self::Grok4 => "Grok 4", - Self::Grok4FastReasoning => "Grok 4 Fast", - Self::Grok4FastNonReasoning => "Grok 4 Fast (Non-Reasoning)", - Self::Grok41FastNonReasoning => "Grok 4.1 Fast (Non-Reasoning)", - Self::Grok41FastReasoning => "Grok 4.1 Fast", - Self::GrokCodeFast1 => "Grok Code Fast 1", + Self::Grok43 => "Grok 4.3", + Self::Grok420Reasoning => "Grok 4.20 Reasoning", + Self::Grok420NonReasoning => "Grok 4.20 (Non-Reasoning)", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -118,27 +64,15 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, - Self::Grok4 | Self::GrokCodeFast1 => 256_000, - Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => 2_000_000, - Self::Grok2Vision => 8_192, + Self::Grok43 => 1_000_000, + Self::Grok420Reasoning | Self::Grok420NonReasoning => 2_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => Some(8_192), - Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => Some(64_000), - Self::Grok2Vision => Some(4_096), + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => Some(64_000), Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -147,33 +81,19 @@ impl Model { pub fn supports_parallel_tool_calls(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok3 - | Self::Grok3Mini - | Self::Grok3Fast - | Self::Grok3MiniFast - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { parallel_tool_calls: Some(support), .. } => *support, - Self::GrokCodeFast1 | Model::Custom { .. } => false, + Model::Custom { .. } => false, } } pub fn requires_json_schema_subset(&self) -> bool { match self { - Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => true, - _ => false, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, + Self::Custom { .. } => false, } } @@ -183,17 +103,7 @@ impl Model { pub fn supports_tool(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok3 - | Self::Grok3Mini - | Self::Grok3Fast - | Self::Grok3MiniFast - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning - | Self::GrokCodeFast1 => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { supports_tools: Some(support), .. @@ -204,17 +114,12 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::Grok2Vision - | Self::Grok4 - | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning - | Self::Grok41FastNonReasoning - | Self::Grok41FastReasoning => true, + Self::Grok43 | Self::Grok420Reasoning | Self::Grok420NonReasoning => true, Self::Custom { supports_images: Some(support), .. } => *support, - _ => false, + Self::Custom { .. } => false, } } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d8ac8be3369..d354e7d78d3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.2.0" +version = "1.3.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 8f417ee08ab..6a706a56321 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -4,6 +4,16 @@ mod reliability; mod zed; +// Ensure the binary name stays in sync with APP_NAME so that the paths used +// at runtime (data dir, config dir, etc.) match what the binary is called. +const _: () = assert!( + paths::APP_NAME_LOWERCASE + .as_bytes() + .eq_ignore_ascii_case(env!("CARGO_BIN_NAME").as_bytes()), + "paths::APP_NAME_LOWERCASE must match the binary name. \ + Forks: update APP_NAME in crates/paths/src/paths.rs when renaming the binary.", +); + use agent::{SharedThread, ThreadStore}; use agent_client_protocol::schema as acp; use agent_ui::AgentPanel; @@ -22,8 +32,8 @@ use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; use git_ui::clone::clone_and_open; use gpui::{ - App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, Task, UpdateGlobal as _, - block_on, + App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, Task, TaskExt, + UpdateGlobal as _, block_on, }; use gpui_platform; diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index b74cdebbacd..fd602d4ab50 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use client::{Client, telemetry::MINIDUMP_ENDPOINT}; use feature_flags::FeatureFlagAppExt; use futures::{AsyncReadExt, TryStreamExt}; -use gpui::{App, AppContext as _, SerializedThreadTaskTimings}; +use gpui::{App, AppContext as _, SerializedThreadTaskTimings, TaskExt}; use http_client::{self, AsyncBody, HttpClient, Request}; use log::info; use project::Project; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3afd117a015..180d85440df 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -36,7 +36,7 @@ use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar}; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement, - PathPromptOptions, PromptLevel, ReadGlobal, SharedString, Size, Task, TitlebarOptions, + PathPromptOptions, PromptLevel, ReadGlobal, SharedString, Size, Task, TaskExt, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, actions, image_cache, img, point, px, retain_all, }; diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 5e41024589d..f0968bf9efe 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -5,9 +5,14 @@ use copilot::CopilotEditPredictionDelegate; use edit_prediction::{EditPredictionModel, ZedEditPredictionDelegate}; use editor::Editor; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; -use language::language_settings::{EditPredictionProvider, all_language_settings}; +use language::{ + ZetaVersion, + language_settings::{ + EditPredictionPromptFormat, EditPredictionProvider, all_language_settings, + }, +}; -use settings::{EditPredictionPromptFormat, SettingsStore}; +use settings::SettingsStore; use std::{cell::RefCell, rc::Rc, sync::Arc}; use ui::Window; @@ -132,10 +137,7 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option Option { let model_base = model.split(':').next().unwrap_or(model); Some(match model_base { + "zeta2" => EditPredictionPromptFormat::Zeta(ZetaVersion::Zeta2), + "zeta2.1" => EditPredictionPromptFormat::Zeta(ZetaVersion::Zeta2_1), "codellama" | "code-llama" => EditPredictionPromptFormat::CodeLlama, "starcoder" | "starcoder2" | "starcoderbase" => EditPredictionPromptFormat::StarCoder, "deepseek-coder" | "deepseek-coder-v2" => EditPredictionPromptFormat::DeepseekCoder, diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 6faf0d3fe68..18ea7c08697 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -14,7 +14,7 @@ use futures::future; use futures::{FutureExt, StreamExt}; use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView}; -use gpui::{App, AsyncApp, Global, WindowHandle}; +use gpui::{App, AsyncApp, Global, TaskExt, WindowHandle}; use onboarding::FIRST_OPEN; use onboarding::show_onboarding_view; use recent_projects::{RemoteSettings, navigate_to_positions, open_remote_project}; diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 7502481b5b5..7b694281b99 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -1,4 +1,5 @@ use gpui::ElementId; +use gpui::TaskExt; use gpui::{AnyElement, Entity}; use picker::Picker; use repl::{ diff --git a/crates/zed/src/zed/remote_debug.rs b/crates/zed/src/zed/remote_debug.rs index df91953c64c..e658248cdf8 100644 --- a/crates/zed/src/zed/remote_debug.rs +++ b/crates/zed/src/zed/remote_debug.rs @@ -1,52 +1,53 @@ -use workspace::Workspace; -use zed_actions::remote_debug::{SimulateDisconnect, SimulateTimeout, SimulateTimeoutExhausted}; - -pub fn init(cx: &mut gpui::App) { - cx.observe_new(|workspace: &mut Workspace, _, cx| { - let project = workspace.project().read(cx); - let Some(remote_client) = project.remote_client() else { - return; - }; - - workspace.register_action({ - let remote_client = remote_client.downgrade(); - move |_, _: &SimulateDisconnect, _window, cx| { - let Some(remote_client) = remote_client.upgrade() else { - return; - }; - - log::info!("SimulateDisconnect: forcing disconnect from remote server"); - remote_client.update(cx, |client, cx| { - client.force_disconnect(cx).detach_and_log_err(cx); - }); - } - }); - - workspace.register_action({ - let remote_client = remote_client.downgrade(); - move |_, _: &SimulateTimeout, _window, cx| { - let Some(remote_client) = remote_client.upgrade() else { - return; - }; - - log::info!("SimulateTimeout: forcing heartbeat timeout on remote connection"); - remote_client.update(cx, |client, cx| { - client.force_heartbeat_timeout(0, cx); - }); - } - }); - - let remote_client = remote_client.downgrade(); - workspace.register_action(move |_, _: &SimulateTimeoutExhausted, _window, cx| { - let Some(remote_client) = remote_client.upgrade() else { - return; - }; - - log::info!("SimulateTimeout: forcing heartbeat timeout on remote connection"); - remote_client.update(cx, |client, cx| { - client.force_heartbeat_timeout(remote::remote_client::MAX_RECONNECT_ATTEMPTS, cx); - }); - }); - }) - .detach(); -} +use gpui::TaskExt; +use workspace::Workspace; +use zed_actions::remote_debug::{SimulateDisconnect, SimulateTimeout, SimulateTimeoutExhausted}; + +pub fn init(cx: &mut gpui::App) { + cx.observe_new(|workspace: &mut Workspace, _, cx| { + let project = workspace.project().read(cx); + let Some(remote_client) = project.remote_client() else { + return; + }; + + workspace.register_action({ + let remote_client = remote_client.downgrade(); + move |_, _: &SimulateDisconnect, _window, cx| { + let Some(remote_client) = remote_client.upgrade() else { + return; + }; + + log::info!("SimulateDisconnect: forcing disconnect from remote server"); + remote_client.update(cx, |client, cx| { + client.force_disconnect(cx).detach_and_log_err(cx); + }); + } + }); + + workspace.register_action({ + let remote_client = remote_client.downgrade(); + move |_, _: &SimulateTimeout, _window, cx| { + let Some(remote_client) = remote_client.upgrade() else { + return; + }; + + log::info!("SimulateTimeout: forcing heartbeat timeout on remote connection"); + remote_client.update(cx, |client, cx| { + client.force_heartbeat_timeout(0, cx); + }); + } + }); + + let remote_client = remote_client.downgrade(); + workspace.register_action(move |_, _: &SimulateTimeoutExhausted, _window, cx| { + let Some(remote_client) = remote_client.upgrade() else { + return; + }; + + log::info!("SimulateTimeout: forcing heartbeat timeout on remote connection"); + remote_client.update(cx, |client, cx| { + client.force_heartbeat_timeout(remote::remote_client::MAX_RECONNECT_ATTEMPTS, cx); + }); + }); + }) + .detach(); +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 7bc37ff698c..7bee9fc9b09 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -99,6 +99,7 @@ pub enum ZetaFormat { #[default] V0131GitMergeMarkersPrefix, V0211Prefill, + #[serde(alias = "Zeta2")] V0211SeedCoder, V0331SeedCoderModelPy, v0226Hashline, @@ -111,6 +112,7 @@ pub enum ZetaFormat { /// V0316, but marker numbers are relative to the cursor block (e.g. -1, -0, +1). V0317SeedMultiRegions, /// V0316 with larger block sizes. + #[serde(alias = "Zeta2.1")] V0318SeedMultiRegions, /// V0318-style markers over the full available current file excerpt with no related files. V0327SingleFile, diff --git a/docs/.conventions/CONVENTIONS.md b/docs/.conventions/CONVENTIONS.md index 585971f8fb4..b2d49420aec 100644 --- a/docs/.conventions/CONVENTIONS.md +++ b/docs/.conventions/CONVENTIONS.md @@ -144,8 +144,8 @@ Use inline `code` for: Use Zed's special syntax for dynamic rendering: -- `{#action git::Commit}` — Renders the action name -- `{#kb git::Commit}` — Renders the keybinding for that action +- {#action git::Commit} — Renders the action name +- {#kb git::Commit} — Renders the keybinding for that action This ensures keybindings stay accurate if defaults change. diff --git a/docs/.doc-examples/configuration.md b/docs/.doc-examples/configuration.md index 4598e19d0a5..45fa7e38730 100644 --- a/docs/.doc-examples/configuration.md +++ b/docs/.doc-examples/configuration.md @@ -32,7 +32,7 @@ The **Settings Editor** ({#kb zed::OpenSettings}) is the primary way to configur To open it: - Press {#kb zed::OpenSettings} -- Or run `zed: open settings` from the command palette +- Or run {#action zed::OpenSettings} from the command palette As you type in the search box, matching settings appear with descriptions and controls to modify them. Changes save automatically to your settings file. @@ -42,7 +42,7 @@ As you type in the search box, matching settings appear with descriptions and co ### User Settings {#user-settings} -Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run `zed: open settings file` from the command palette. +Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run {#action zed::OpenSettingsFile} from the command palette. The file is located at: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 54f477472b1..ad35212a6d6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -38,10 +38,10 @@ Example: The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands: -| Syntax | Purpose | Example | -| ----------------------------- | ------------------------------------- | ------------------------------- | -| `{#kb action::ActionName}` | Keybinding for action | `{#kb agent::ToggleFocus}` | -| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` | +| Syntax | Purpose | Example | +| --------------------------- | ------------------------------------- | ----------------------------- | +| {#kb action::ActionName} | Keybinding for action | {#kb agent::ToggleFocus} | +| {#action agent::ActionName} | Action reference (renders as command) | {#action agent::OpenSettings} | **Rules:** diff --git a/docs/README.md b/docs/README.md index 38be153de34..65c4699cb62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,7 +50,7 @@ When referencing keybindings or actions, use the following formats: ### Keybindings -`{#kb scope::Action}` - e.g., `{#kb zed::OpenSettings}`. +{#kb scope::Action} - e.g., {#kb zed::OpenSettings}. This will output a code element like: `Cmd + , | Ctrl + ,`. We then use a client-side plugin to show the actual keybinding based on the user's platform. @@ -66,7 +66,7 @@ Supported overlays: `jetbrains`. ### Actions -`{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. +{#action scope::Action} - e.g., {#action zed::OpenSettings}. This will render a human-readable version of the action name, e.g., "zed: open settings", and will allow us to implement things like additional context on hover, etc. diff --git a/docs/book.toml b/docs/book.toml index 93540934b4c..27a7bd7e5d3 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -55,6 +55,7 @@ enable = false "/language-model-integration.html" = "/docs/assistant/assistant.html" "/model-improvement.html" = "/docs/ai/ai-improvement.html" "/ai/temperature.html" = "/docs/ai/agent-settings.html#model-temperature" +"/ai/subscription.html" = "/docs/ai/plans-and-usage.html" # Core "/configuring-zed.html" = "/docs/reference/all-settings.html" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a3b8ce32ea5..83536020057 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -21,12 +21,9 @@ - [Rules](./ai/rules.md) - [Model Context Protocol](./ai/mcp.md) - [Configuration](./ai/configuration.md) - - [LLM Providers](./ai/llm-providers.md) - [Agent Settings](./ai/agent-settings.md) -- [Subscription](./ai/subscription.md) - - [Models](./ai/models.md) - - [Plans and Usage](./ai/plans-and-usage.md) - - [Billing](./ai/billing.md) +- [Models](./ai/models.md) +- [Providers](./ai/llm-providers.md) # Working with Code @@ -60,6 +57,29 @@ - [Environment Variables](./environment.md) - [Dev Containers](./dev-containers.md) +# Account & Billing + +- [Authenticate](./authentication.md) +- [Plans & Pricing](./ai/plans-and-usage.md) +- [Billing](./ai/billing.md) + +# Zed Business + +- [Overview](./business/overview.md) +- [Organizations](./business/organizations.md) +- [Roles & Permissions](./roles.md) +- [Admin Controls](./business/admin-controls.md) +- [Business Support](./business/business-support.md) + +# Privacy & Security + +- [Overview](./ai/privacy-and-security.md) + - [Worktree Trust](./worktree-trust.md) + - [AI Improvement](./ai/ai-improvement.md) +- [Privacy for Business](./business/privacy.md) +- [Telemetry](./telemetry.md) +- [SOC2](./soc2.md) + # Platform Support - [macOS](./macos.md) @@ -182,15 +202,6 @@ - [All Actions](./all-actions.md) - [CLI Reference](./reference/cli.md) -# Account & Privacy - -- [Authenticate](./authentication.md) -- [Roles](./roles.md) -- [Privacy and Security](./ai/privacy-and-security.md) - - [Worktree Trust](./worktree-trust.md) - - [AI Improvement](./ai/ai-improvement.md) -- [Telemetry](./telemetry.md) - # Developing Zed - [Developing Zed](./development.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 5f7fe17baec..5d75fcf653e 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -8,7 +8,7 @@ description: Use Zed's AI coding agent to generate, refactor, and debug code wit The Agent Panel is where you interact with AI agents that can read, write, and run code in your project. It's the core of Zed's AI code editing experience — use it for code generation, refactoring, debugging, documentation, and general questions. -Open it with `agent: new thread` from [the Command Palette](../getting-started.md#command-palette) or click the ✨ icon in the status bar. +Open it with {#action agent::NewThread} from [the Command Palette](../getting-started.md#command-palette) or click the ✨ icon in the status bar. ## Getting Started {#getting-started} @@ -240,7 +240,7 @@ Zed's UI will inform you about this via a warning icon that appears close to the ## Errors and Debugging {#errors-and-debugging} -If you hit an error or unusual LLM behavior, open the thread as Markdown with `agent: open thread as markdown` and attach it to your GitHub issue. +If you hit an error or unusual LLM behavior, open the thread as Markdown with {#action agent::OpenActiveThreadAsMarkdown} and attach it to your GitHub issue. You can also open threads as Markdown by clicking on the file icon button, to the right of the thumbs down button, when focused on the panel's editor. diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 28ee927e4ab..488cf141846 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -138,7 +138,7 @@ Specify a custom temperature for a provider and/or model: ## Agent Panel Settings {#agent-panel-settings} -Note that some of these settings are also surfaced in the Agent Panel's settings UI, which you can access either via the `agent: open settings` action or by the dropdown menu on the top-right corner of the panel. +Note that some of these settings are also surfaced in the Agent Panel's settings UI, which you can access either via the {#action agent::OpenSettings} action or by the dropdown menu on the top-right corner of the panel. ### Font Size diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 1b95df5eda7..219f2fae1da 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -1,57 +1,77 @@ --- -title: Billing - Zed AI -description: Manage Zed AI billing, payment methods, invoices, threshold billing, and sales tax information. +title: Billing +description: Manage billing for your Zed subscription, including payment methods, invoices, and sales tax information for individual and organization accounts. --- # Billing -This page covers billing for Zed's [subscription plans](./subscription.md). For details on what's included in each plan and how token usage works, see [Plans and Usage](./plans-and-usage.md). +Zed uses Stripe for payment processing. All plans that require payment do so via credit card or other supported payment methods. Individual Pro subscriptions also use Orb for invoicing and metering. -We use Stripe as our payments provider, and Orb for invoicing and metering. All Pro plans require payment via credit card or other supported payment method. -For invoice-based billing, a Business plan is required. Contact [sales@zed.dev](mailto:sales@zed.dev) for more information. +For details on what's included in each plan and how token usage works, see [Plans & Pricing](./plans-and-usage.md). -## Billing Information {#settings} +## Individual billing {#individual} -Access billing information and settings at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). +### Billing information {#settings} + +Access billing information and settings from your [Zed dashboard](https://dashboard.zed.dev). This page embeds data from Orb, our invoicing and metering partner. -## Billing Cycles {#billing-cycles} +### Billing cycles {#billing-cycles} Zed is billed on a monthly basis based on the date you initially subscribe. You'll receive _at least_ one invoice from Zed each month you're subscribed to Zed Pro, and more than one if you use more than $10 in incremental token spend within the month. -## Threshold Billing {#threshold-billing} +### Threshold billing {#threshold-billing} -Zed utilizes threshold billing to ensure timely collection of owed monies and prevent abuse. Every time your usage of Zed's hosted models crosses a $10 spend threshold, a new invoice is generated, and the threshold resets to $0. +Zed utilizes threshold billing to ensure timely payment collection. Every time your usage of Zed's hosted models crosses a $10 spend threshold, a new invoice is generated, and the threshold resets to $0. For example, - You subscribe on February 1. Your first invoice is $10. -- You use $12 of incremental tokens in the month of February, with the first $10 spent on February 15. You'll receive an invoice for $10 on February 15 +- You use $12 of incremental tokens in the month of February, with the first $10 spent on February 15. You'll receive an invoice for $10 on February 15. - On March 1, you receive an invoice for $12: $10 (March Pro subscription) and $2 in leftover token spend, since your usage didn't cross the $10 threshold. -## Payment Failures {#payment-failures} +### Payment failures {#payment-failures} If payment of an invoice fails, Zed will block usage of our hosted models until the payment is complete. Email [billing-support@zed.dev](mailto:billing-support@zed.dev) for assistance. -## Invoice History {#invoice-history} +### Invoice history {#invoice-history} -You can access your invoice history by navigating to [dashboard.zed.dev/account](https://dashboard.zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. +You can access your invoice history from the Billing page at [dashboard.zed.dev](https://dashboard.zed.dev) by clicking `Invoice history` within the embedded Orb portal. -If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev) +If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev). -## Updating Billing Information {#updating-billing-info} +## Organization billing {#organization} -Email [billing-support@zed.dev](mailto:billing-support@zed.dev) for help updating payment methods, names, addresses, and tax information. +Zed Business consolidates your team's costs. Seat licenses and AI usage for all members appear on one bill, with no separate invoices per member. For a full feature overview, see [Zed Business](../business/overview.md). -> Self-service billing updates will be available in a future release. +### Billing dashboard {#dashboard} -Please note that changes to billing information will **only** affect future invoices — **we cannot modify historical invoices**. +Owners and admins can access billing information at [dashboard.zed.dev](https://dashboard.zed.dev). The dashboard shows the plan you're currently on and offers jumping off points to update billing details, such as the billing name and address, as well as payment information. You can also access your invoices history, accessible through the Orb billing portal. -## Sales Tax {#sales-tax} +### AI usage {#ai-usage} -Zed partners with [Sphere](https://www.getsphere.com/) to calculate indirect tax rate for invoices, based on customer location and the product being sold. Tax is listed as a separate line item on invoices, based preferentially on your billing address, followed by the card issue country known to Stripe. +AI usage across the organization is metered on a token basis at the same rates as individual Pro subscriptions. See [Plans & Pricing](./plans-and-usage.md#usage) for rate details. -If you have a VAT/GST ID, you can add it at during checkout. Check the box that denotes you as a business. +Administrators can set an org-wide AI spend limit from the Data & Privacy page in the organization dashboard. The limit starts at $0, so it must be increased before members can use any hosted models. Once the limit is reached, members will see an error when attempting to use hosted models. -Please note that changes to VAT/GST IDs and address will **only** affect future invoices — **we cannot modify historical invoices**. -Questions or issues can be directed to [billing-support@zed.dev](mailto:billing-support@zed.dev). +### Invoice history {#org-invoice-history} + +Owners and Admins can access an organization's invoice history from the Billing page at [dashboard.zed.dev](https://dashboard.zed.dev) by clicking `Invoice history` within the embedded Orb portal. + +If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev). + +## Updating billing information {#updating-billing-info} + +From the _Billing_ page, owners can update their billing name, address, and payment method. Tax IDs are collected during checkout and cannot be changed self-serve; email [billing-support@zed.dev](mailto:billing-support@zed.dev) to update your tax ID. + +Changes to billing information will **only** affect future invoices. We cannot modify historical invoices. Email [billing-support@zed.dev](mailto:billing-support@zed.dev) with any questions. + +## Sales tax {#sales-tax} + +Zed partners with [Sphere](https://www.getsphere.com/) to calculate indirect tax rates for invoices, based on customer location and the product being sold. Tax is listed as a separate line item on invoices, based preferentially on your billing address, followed by the card issue country known to Stripe. + +If you have a VAT/GST ID, you can add it during checkout. Check the box that denotes you as a business. + +Changes to VAT/GST IDs and address will **only** affect future invoices. We cannot modify historical invoices. + +Email [billing-support@zed.dev](mailto:billing-support@zed.dev) with any tax questions. diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 865693036c2..1f5b3e8adce 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -286,11 +286,29 @@ After adding your API key, Codestral will appear in the provider dropdown in the } ``` -### Self-Hosted OpenAI-compatible servers +### Local and self-hosted models -You can use any self-hosted server that implements the OpenAI completion API format. This works with vLLM, llama.cpp server, LocalAI, and other compatible servers. +You can use local or self-hosted edit prediction models through Ollama or any server that implements the OpenAI completion API format. This works with Ollama, vLLM, llama.cpp server, LocalAI, and other compatible servers. -#### Configuration +#### Ollama + +Set `ollama` as your provider and configure the local model: + +```json [settings] +{ + "edit_predictions": { + "provider": "ollama", + "ollama": { + "api_url": "http://localhost:11434", + "model": "qwen2.5-coder:7b-base", + "prompt_format": "infer", + "max_output_tokens": 512 + } + } +} +``` + +#### OpenAI-compatible servers Set `open_ai_compatible_api` as your provider and configure the API endpoint: @@ -302,7 +320,7 @@ Set `open_ai_compatible_api` as your provider and configure the API endpoint: "api_url": "http://localhost:8080/v1/completions", "model": "deepseek-coder-6.7b-base", "prompt_format": "deepseek_coder", - "max_output_tokens": 64 + "max_output_tokens": 512 } } } @@ -310,15 +328,55 @@ Set `open_ai_compatible_api` as your provider and configure the API endpoint: The `prompt_format` setting controls how code context is formatted for the model. Use `"infer"` to detect the format from the model name, or specify one explicitly: +- `zeta` - Zeta 1 format +- `zeta2` - Zeta 2 format +- `zeta2_1` - Zeta 2.1 format - `code_llama` - CodeLlama format: `

 prefix  suffix `
 - `star_coder` - StarCoder format: `prefixsuffix`
 - `deepseek_coder` - DeepSeek format with special unicode markers
 - `qwen` - Qwen/CodeGemma format: `<|fim_prefix|>prefix<|fim_suffix|>suffix<|fim_middle|>`
+- `code_gemma` - CodeGemma format: `<|fim_prefix|>prefix<|fim_suffix|>suffix<|fim_middle|>`
 - `codestral` - Codestral format: `[SUFFIX]suffix[PREFIX]prefix`
 - `glm` - GLM-4 format with code markers
 - `infer` - Auto-detect from model name (default)
 
-Your server must implement the OpenAI `/v1/completions` endpoint. Edit predictions will send POST requests with this format:
+With `"prompt_format": "infer"`, Zed automatically uses Zeta 2 format for models named `zeta2` and Zeta 2.1 format for models named `zeta2.1`.
+
+For example, to use Zeta 2 with Ollama:
+
+```json [settings]
+{
+  "edit_predictions": {
+    "provider": "ollama",
+    "ollama": {
+      "api_url": "http://localhost:11434",
+      "model": "zeta2",
+      "prompt_format": "infer",
+      "max_output_tokens": 512
+    }
+  }
+}
+```
+
+To use Zeta 2.1 with an OpenAI-compatible server:
+
+```json [settings]
+{
+  "edit_predictions": {
+    "provider": "open_ai_compatible_api",
+    "open_ai_compatible_api": {
+      "api_url": "http://localhost:8080/v1/completions",
+      "model": "zeta2.1",
+      "prompt_format": "infer",
+      "max_output_tokens": 512
+    }
+  }
+}
+```
+
+You can also set `"prompt_format": "zeta2"` or `"prompt_format": "zeta2_1"` explicitly when the model name does not match.
+
+Your OpenAI-compatible server must implement the OpenAI `/v1/completions` endpoint. Edit predictions will send POST requests with this format:
 
 ```json
 {
diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md
index 454079c2d26..50d1a1ce197 100644
--- a/docs/src/ai/external-agents.md
+++ b/docs/src/ai/external-agents.md
@@ -23,7 +23,7 @@ Under the hood we run Gemini CLI in the background, and talk to it over ACP.
 
 First open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Gemini CLI thread.
 
-If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include:
+If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include:
 
 ```json [keymap]
 [
@@ -69,7 +69,7 @@ Under the hood, Zed runs the Claude Agent SDK, which runs Claude Code under the
 
 Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Claude Agent thread.
 
-If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include:
+If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include:
 
 ```json [keymap]
 [
@@ -144,7 +144,7 @@ Under the hood, Zed runs Codex CLI and communicates to it over ACP, through [a d
 As of version `0.208`, you should be able to use Codex directly from Zed.
 Open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a new Codex thread.
 
-If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the `zed: open keymap file` command to include:
+If you'd like to bind this to a keyboard shortcut, you can do so by editing your `keymap.json` file via the {#action zed::OpenKeymapFile} command to include:
 
 ```json
 [
@@ -202,7 +202,7 @@ At some point in the near future, Agent Server extensions will be deprecated.
 
 Add more external agents to Zed by installing [Agent Server extensions](../extensions/agent-servers.md).
 
-See what agents are available by filtering for "Agent Servers" in the extensions page, which you can access via the command palette with `zed: extensions`, or the [Zed website](https://zed.dev/extensions?filter=agent-servers).
+See what agents are available by filtering for "Agent Servers" in the extensions page, which you can access via the command palette with {#action zed::Extensions}, or the [Zed website](https://zed.dev/extensions?filter=agent-servers).
 
 ### Via The ACP Registry
 
@@ -216,7 +216,7 @@ At the moment, the registry is a curated set of agents, including only the ones
 
 #### Using it in Zed
 
-Use the `zed: acp registry` command to quickly go to the ACP Registry page.
+Use the {#action zed::AcpRegistry} command to quickly go to the ACP Registry page.
 There's also a button ("Add Agent") that takes you there in the agent panel's configuration view.
 
 From there, you can click to install your preferred agent and it will become available right away in the `+` icon button in the agent panel.
@@ -246,7 +246,7 @@ It's also possible to customize environment variables for registry-installed age
 
 ## Debugging Agents
 
-When using external agents in Zed, you can access the debug view via with `dev: open acp logs` from the Command Palette.
+When using external agents in Zed, you can access the debug view via with {#action dev::OpenAcpLogs} from the Command Palette.
 This lets you see the messages being sent and received between Zed and the agent.
 
 ![The debug view for ACP logs.](https://zed.dev/img/acp/acp-logs.webp)
@@ -339,7 +339,7 @@ For more on configuring MCP servers, see [Model Context Protocol](./mcp.md).
 
 1. Verify the MCP server is enabled in `context_servers` settings
 2. For remote MCP servers with OAuth, this is a [known issue](https://github.com/zed-industries/zed/issues/54410) — try local stdio-based servers instead
-3. Open `dev: open acp logs` from the Command Palette to debug
+3. Open {#action dev::OpenAcpLogs} from the Command Palette to debug
 
 **"My existing Claude Code / Codex setup isn't working in Zed"**
 
diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md
index b32c433803f..1f8104bde24 100644
--- a/docs/src/ai/llm-providers.md
+++ b/docs/src/ai/llm-providers.md
@@ -13,7 +13,7 @@ You can do that by either subscribing to [one of Zed's plans](./plans-and-usage.
 
 If you already have an API key for a provider like Anthropic or OpenAI, you can add it to Zed. No Zed subscription required.
 
-To add an existing API key to a given provider, go to the Agent Panel settings (`agent: open settings`), look for the desired provider, paste the key into the input, and hit enter.
+To add an existing API key to a given provider, go to the Agent Panel settings ({#action agent::OpenSettings}), look for the desired provider, paste the key into the input, and hit enter.
 
 > Note: API keys are _not_ stored as plain text in your settings file, but rather in your OS's secure credential storage.
 
@@ -70,7 +70,7 @@ With that done, choose one of the three authentication methods:
 #### Authentication via Named Profile (Recommended)
 
 1. Ensure you have the AWS CLI installed and configured with a named profile
-2. Open your settings file (`zed: open settings file`) and include the `bedrock` key under `language_models` with the following settings:
+2. Open your settings file ({#action zed::OpenSettingsFile}) and include the `bedrock` key under `language_models` with the following settings:
    ```json [settings]
    {
      "language_models": {
@@ -90,7 +90,7 @@ To do this:
 
 1. Create an IAM User in the [IAM Console](https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users).
 2. Create security credentials for that User, save them and keep them secure.
-3. Open the Agent Configuration with (`agent: open settings`) and go to the Amazon Bedrock section
+3. Open the Agent Configuration with ({#action agent::OpenSettings}) and go to the Amazon Bedrock section
 4. Copy the credentials from Step 2 into the respective **Access Key ID**, **Secret Access Key**, and **Region** fields.
 
 #### Authentication via Bedrock API Key
@@ -98,7 +98,7 @@ To do this:
 Amazon Bedrock also supports [API Keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html), which authenticate directly without requiring IAM users or named profiles.
 
 1. Create an API Key in the [Amazon Bedrock Console](https://console.aws.amazon.com/bedrock/)
-2. Open the Agent Configuration with (`agent: open settings`) and go to the Amazon Bedrock section
+2. Open the Agent Configuration with ({#action agent::OpenSettings}) and go to the Amazon Bedrock section
 3. Enter your Bedrock API key in the **API Key** field and select your **Region**
 
 ```json [settings]
@@ -150,25 +150,6 @@ We will support Cross-Region inference for each of the models on a best-effort b
 
 For the most up-to-date supported regions and models, refer to the [Supported Models and Regions for Cross Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html).
 
-#### Extended Context Window {#bedrock-extended-context}
-
-Anthropic models on Bedrock support a 1M token extended context window through the `anthropic_beta` API parameter. To enable this feature, set `"allow_extended_context": true` in your Bedrock configuration:
-
-```json [settings]
-{
-  "language_models": {
-    "bedrock": {
-      "authentication_method": "named_profile",
-      "region": "your-aws-region",
-      "profile": "your-profile-name",
-      "allow_extended_context": true
-    }
-  }
-}
-```
-
-Zed enables extended context for supported models (Claude Sonnet 4.5, Claude Opus 4.6, and Claude Opus 4.7). Extended context usage may increase API costs—refer to AWS Bedrock pricing for details.
-
 #### Image Support {#bedrock-image-support}
 
 Bedrock models that support vision (Claude 3 and later, Amazon Nova Pro and Lite, Meta Llama 3.2 Vision models, Mistral Pixtral) can receive images in conversations and tool results.
@@ -179,7 +160,7 @@ You can use Anthropic models by choosing them via the model dropdown in the Agen
 
 1. Sign up for Anthropic and [create an API key](https://console.anthropic.com/settings/keys)
 2. Make sure that your Anthropic account has credits
-3. Open the settings view (`agent: open settings`) and go to the Anthropic section
+3. Open the settings view ({#action agent::OpenSettings}) and go to the Anthropic section
 4. Enter your Anthropic API key
 
 Even if you pay for Claude Pro, you will still have to [pay for additional credits](https://console.anthropic.com/settings/plans) to use it via the API.
@@ -232,7 +213,7 @@ You can configure a model to use [extended thinking](https://docs.anthropic.com/
 ### DeepSeek {#deepseek}
 
 1. Visit the DeepSeek platform and [create an API key](https://platform.deepseek.com/api_keys)
-2. Open the settings view (`agent: open settings`) and go to the DeepSeek section
+2. Open the settings view ({#action agent::OpenSettings}) and go to the DeepSeek section
 3. Enter your DeepSeek API key
 
 The DeepSeek API key will be saved in your keychain.
@@ -275,7 +256,7 @@ You can also modify the `api_url` to use a custom endpoint if needed.
 
 You can use GitHub Copilot Chat with the Zed agent by choosing it via the model dropdown in the Agent Panel.
 
-1. Open the settings view (`agent: open settings`) and go to the GitHub Copilot Chat section
+1. Open the settings view ({#action agent::OpenSettings}) and go to the GitHub Copilot Chat section
 2. Click on `Sign in to use GitHub Copilot`, follow the steps shown in the modal.
 
 Alternatively, you can provide an OAuth token via the `GH_COPILOT_TOKEN` environment variable.
@@ -289,7 +270,7 @@ To use Copilot Enterprise with Zed (for both agent and completions), you must co
 You can use Gemini models with the Zed agent by choosing it via the model dropdown in the Agent Panel.
 
 1. Go to the Google AI Studio site and [create an API key](https://aistudio.google.com/app/apikey).
-2. Open the settings view (`agent: open settings`) and go to the Google AI section
+2. Open the settings view ({#action agent::OpenSettings}) and go to the Google AI section
 3. Enter your Google AI API key and press enter.
 
 The Google AI API key will be saved in your keychain.
@@ -353,7 +334,7 @@ Tip: Set [LM Studio as a login item](https://lmstudio.ai/docs/advanced/headless#
 ### Mistral {#mistral}
 
 1. Visit the Mistral platform and [create an API key](https://console.mistral.ai/api-keys/)
-2. Open the configuration view (`agent: open settings`) and navigate to the Mistral section
+2. Open the configuration view ({#action agent::OpenSettings}) and navigate to the Mistral section
 3. Enter your Mistral API key
 
 The Mistral API key will be saved in your keychain.
@@ -362,7 +343,7 @@ Zed will also use the `MISTRAL_API_KEY` environment variable if it's defined.
 
 #### Custom Models {#mistral-custom-models}
 
-The Zed agent comes pre-configured with several Mistral models (codestral-latest, mistral-large-latest, mistral-medium-latest, mistral-small-latest, open-mistral-nemo, and open-codestral-mamba).
+The Zed agent comes pre-configured to use the latest version for common Mistral models (Large, Medium, Small, Codestral, Devstral, and others).
 All the default models support tool use.
 If you wish to use alternate models or customize their parameters, you can do so by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)):
 
@@ -502,7 +483,7 @@ One such service is [Ollama Turbo](https://ollama.com/turbo). To configure Zed t
 
 1. Sign in to your Ollama account and subscribe to Ollama Turbo
 2. Visit [ollama.com/settings/keys](https://ollama.com/settings/keys) and create an API key
-3. Open the settings view (`agent: open settings`) and go to the Ollama section
+3. Open the settings view ({#action agent::OpenSettings}) and go to the Ollama section
 4. Paste your API key and press enter.
 5. For the API URL enter `https://ollama.com`
 
@@ -512,7 +493,7 @@ Zed will also use the `OLLAMA_API_KEY` environment variables if defined.
 
 1. Visit the OpenAI platform and [create an API key](https://platform.openai.com/account/api-keys)
 2. Make sure that your OpenAI account has credits
-3. Open the settings view (`agent: open settings`) and go to the OpenAI section
+3. Open the settings view ({#action agent::OpenSettings}) and go to the OpenAI section
 4. Enter your OpenAI API key
 
 The OpenAI API key will be saved in your keychain.
@@ -570,7 +551,7 @@ This is useful for connecting to other hosted services (like Together AI, Anysca
 
 You can add a custom, OpenAI-compatible model either via the UI or by editing your settings file.
 
-To do it via the UI, go to the Agent Panel settings (`agent: open settings`) and look for the "Add Provider" button to the right of the "LLM Providers" section title.
+To do it via the UI, go to the Agent Panel settings ({#action agent::OpenSettings}) and look for the "Add Provider" button to the right of the "LLM Providers" section title.
 Then, fill up the input fields available in the modal.
 
 To do it via your settings file ([how to edit](../configuring-zed.md#settings-files)), add the following snippet under `language_models`:
@@ -626,7 +607,7 @@ OpenCode offers multiple ways to access AI models:
 1. Visit [OpenCode Console](https://opencode.ai/auth) and create an account
 2. Free models are available without payment. To use Zen or Go models, make sure you have enough credits or an active subscription
 3. Generate an API key from the "API Keys" section in the OpenCode Console
-4. Open the settings view (`agent: open settings`) and go to the OpenCode section
+4. Open the settings view ({#action agent::OpenSettings}) and go to the OpenCode section
 5. Enter your OpenCode API key
 
 The OpenCode API key will be saved in your keychain.
@@ -693,7 +674,7 @@ OpenRouter provides access to multiple AI models through a single API. It suppor
 
 1. Visit [OpenRouter](https://openrouter.ai) and create an account
 2. Generate an API key from your [OpenRouter keys page](https://openrouter.ai/keys)
-3. Open the settings view (`agent: open settings`) and go to the OpenRouter section
+3. Open the settings view ({#action agent::OpenSettings}) and go to the OpenRouter section
 4. Enter your OpenRouter API key
 
 The OpenRouter API key will be saved in your keychain.
@@ -812,7 +793,7 @@ These routing controls let you fine‑tune cost, capability, and reliability tra
 [Vercel AI Gateway](https://vercel.com/ai-gateway) provides access to many models through a single OpenAI-compatible endpoint.
 
 1. Create an API key from your [Vercel AI Gateway keys page](https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway)
-2. Open the settings view (`agent: open settings`) and go to the **Vercel AI Gateway** section
+2. Open the settings view ({#action agent::OpenSettings}) and go to the **Vercel AI Gateway** section
 3. Enter your Vercel AI Gateway API key
 
 The Vercel AI Gateway API key will be saved in your keychain.
@@ -836,7 +817,7 @@ You can also set a custom endpoint for Vercel AI Gateway in your settings file:
 Zed includes a dedicated [xAI](https://x.ai/) provider. You can use your own API key to access Grok models.
 
 1. [Create an API key in the xAI Console](https://console.x.ai/team/default/api-keys)
-2. Open the settings view (`agent: open settings`) and go to the **xAI** section
+2. Open the settings view ({#action agent::OpenSettings}) and go to the **xAI** section
 3. Enter your xAI API key
 
 The xAI API key will be saved in your keychain. Zed will also use the `XAI_API_KEY` environment variable if it's defined.
diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md
index dbe2f10af03..fb3e2b25e01 100644
--- a/docs/src/ai/mcp.md
+++ b/docs/src/ai/mcp.md
@@ -26,7 +26,7 @@ Check out the [MCP Server Extensions](../extensions/mcp-extensions.md) page to l
 Many MCP servers are available as extensions. Find them via:
 
 1. [the Zed website](https://zed.dev/extensions?filter=context-servers)
-2. in the app, open the Command Palette and run the `zed: extensions` action
+2. in the app, open the Command Palette and run the {#action zed::Extensions} action
 3. in the app, go to the Agent Panel's top-right menu and look for the "View Server Extensions" menu item
 
 Popular servers available as an extension include:
@@ -64,7 +64,7 @@ You can connect them by adding their commands directly to your settings file ([h
 }
 ```
 
-Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the `agent: open settings` action).
+Alternatively, you can also add a custom server by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action).
 From there, you can add it through the modal that appears when you click the "Add Custom Server" button.
 
 > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow.
diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md
index bc9e4854475..7f20093cb4c 100644
--- a/docs/src/ai/plans-and-usage.md
+++ b/docs/src/ai/plans-and-usage.md
@@ -1,40 +1,64 @@
 ---
-title: Plans and Usage - Zed AI
-description: Understand Zed's AI plans, token-based usage metering, spend limits, and trial details.
+title: Plans & Pricing
+description: Compare Zed's Free, Pro, and Business plans, and understand token-based usage metering, spend limits, and trial details.
 ---
 
-# Plans and Usage
-
-## Available Plans {#plans}
+# Plans & Pricing
 
 For costs and more information on pricing, visit [Zed's pricing page](https://zed.dev/pricing).
 
 Zed works without AI features or a subscription. No [authentication](../authentication.md) is required for the editor itself.
 
+## Plans {#plans}
+
+|                                           | Free    | Pro       | Student   | Business  |
+| ----------------------------------------- | ------- | --------- | --------- | --------- |
+| Zed-hosted AI models                      | —       | ✓         | ✓         | ✓         |
+| [AI via own API keys](./llm-providers.md) | ✓       | ✓         | ✓         | ✓         |
+| [External agents](./external-agents.md)   | ✓       | ✓         | ✓         | ✓         |
+| Edit Predictions                          | Limited | Unlimited | Unlimited | Unlimited |
+| Org-wide admin controls                   | —       | —         | —         | ✓         |
+| Roles & permissions                       | —       | —         | —         | ✓         |
+| Consolidated billing                      | —       | —         | —         | ✓         |
+
+### Zed Free {#free}
+
+Zed is free to use. You can configure AI agents with your own API keys via [Providers](./llm-providers.md). [Edit Predictions](./edit-prediction.md) are available on a limited basis. Zed's hosted models require a Pro subscription.
+
+### Zed Pro {#pro}
+
+Zed Pro includes access to all hosted AI models and Edit Predictions. The plan includes $5 of monthly token credit; usage beyond that is billed at the rates listed on [the Models page](./models.md). A trial of Zed Pro includes $20 of credit, usable for 14 days.
+
+For details on billing and payment, see [Individual Billing](./billing.md).
+
+### Zed Business {#business}
+
+Zed Business gives every member access to all of Zed's hosted AI models, unlimited edit predictions, plus org-wide controls for administrators: which AI features are available, what data leaves your organization, and how AI spend is tracked. All seats and AI usage are consolidated into a single invoice.
+
+For a full feature overview, see [Zed Business](../business/overview.md). For billing details, see [Billing](./billing.md#organization).
+
+### Student Plan {#student}
+
+The [Zed Student plan](https://zed.dev/education) includes all Zed Pro features: unlimited [Edit Predictions](./edit-prediction.md), all [hosted AI models](./models.md) except Claude Opus, and $10/month in token credits. Available free for one year to verified university students.
+
 ## Usage {#usage}
 
-Usage of Zed's hosted models is measured on a token basis, converted to dollars at the rates lists on [the Models page](./models.md) (list price from the provider, +10%).
+Usage of Zed's hosted models is measured on a token basis, converted to dollars at the rates listed on [the Models page](./models.md) (list price from the provider, +10%).
 
-Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date.
-
-The [Zed Student plan](https://zed.dev/education) includes $10/month in token credits. The Student plan is available free for one year to verified university students.
-
-To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page.
+Monthly included credit resets on your monthly billing date. To view your current usage, navigate to the Billing page at [dashboard.zed.dev](https://dashboard.zed.dev). Usage data from our metering provider, Orb, is embedded on that page.
 
 ## Spend Limits {#usage-spend-limits}
 
-At the top of [the Account page](https://dashboard.zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription.
+On your Billing page you'll find an input for `Monthly Spend Limit`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription.
 
 The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing).
 
 Once the spend limit is hit, we'll stop any further usage until your token spend limit resets.
 
-> **Note:** Spend limits are a Zed Pro feature. Student plan users do not currently have the ability to configure spend limits; usage is capped at the $10/month included credit.
+On Zed Business, administrators set an org-wide spend limit from the Data & Privacy page in the organization dashboard. See [Organization Billing](./billing.md#ai-usage) for details.
 
-## Business Usage {#business-usage}
+> **Note:** Spend limits are a Zed Pro and Business feature. Student plan users cannot configure spend limits; usage is capped at the $10/month included credit.
 
-Email [sales@zed.dev](mailto:sales@zed.dev) with any questions on business plans.
+### Trials {#trials}
 
-## Trials {#trials}
-
-Note that trials will automatically convert to Zed Free plans on termination, and no cancellation is needed to prevent conversion to Zed Pro.
+Trials automatically convert to Zed Free when they end. Trials do not include access to Anthropic's Opus models. No cancellation is needed to prevent conversion to Zed Pro.
diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md
index 828953cca74..47c92d482e2 100644
--- a/docs/src/ai/privacy-and-security.md
+++ b/docs/src/ai/privacy-and-security.md
@@ -1,39 +1,33 @@
 ---
-title: AI Privacy and Security - Zed
-description: "Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency."
+title: Privacy Overview - Zed
+description: "Zed's approach to privacy: opt-in data sharing, zero-data retention with AI providers, and an open-source codebase you can inspect."
 ---
 
-# Privacy and Security
+# Privacy Overview
 
-## Philosophy
+Zed collects minimal data necessary to serve and improve the product. Features that could share data are either opt-in or can be disabled.
 
-Zed collects minimal data necessary to serve and improve our product. Features that could share data, like AI and telemetry, are either opt-in or can be disabled.
+- **Telemetry:** Zed collects only the data necessary to understand usage and fix issues. Client-side telemetry can be disabled in settings. See [Telemetry](../telemetry.md).
 
-- **Telemetry**: Zed collects only the data necessary to understand usage and fix issues. Client-side telemetry can be disabled in settings.
+- **AI:** Zed doesn't store your prompts or code context. Data sharing for AI improvement is opt-in, and each share is a one-time action; it doesn't grant permission for future collection. You can use Zed's AI features without sharing any data with Zed. See [AI Improvement](./ai-improvement.md).
 
-- **AI**: Data sharing for AI improvement is opt-in, and each share is a one-time action; it does not grant permission for future data collection. You can use Zed's AI features without sharing any data with Zed and without authenticating.
+- **Open source:** Zed's codebase is public. You can inspect exactly what data is collected and how it's handled. If you find issues, [report them](https://github.com/zed-industries/zed/issues).
 
-- **Open-Source**: Zed's codebase is public. You can inspect exactly what data is collected and how it's handled. If you find issues, we encourage you to report them.
+On Zed Business, administrators can enforce these settings org-wide so members can't opt in to data sharing individually. See [Privacy for Business](../business/privacy.md).
 
-- **Secure-by-default**: Designing Zed and our Service with "secure-by-default" as an objective is of utmost importance to us. We take your security and ours very seriously and strive to follow industry best-practice in order to uphold that principle.
+## Related documentation
 
-## Related Documentation
+- [Tool Permissions](./tool-permissions.md): Configure which agent actions are auto-approved, blocked, or require confirmation.
+- [Worktree Trust](../worktree-trust.md): How Zed opens files and directories in restricted mode.
+- [Telemetry](../telemetry.md): What telemetry Zed collects and how to control it.
+- [AI Improvement](./ai-improvement.md): How data sharing for AI improvement works and how to opt in.
+- [Privacy for Business](../business/privacy.md): How Zed Business enforces privacy settings across an organization.
+- [Authentication](../authentication.md): When and why authentication is needed.
+- [SOC2](../soc2.md): Zed's security certification status.
 
-- [Tool Permissions](./tool-permissions.md): Configure granular rules to control which agent actions are auto-approved, blocked, or require confirmation.
-
-- [Worktree trust](../worktree-trust.md): How Zed opens files and directories in restricted mode.
-
-- [Telemetry](../telemetry.md): How Zed collects general telemetry data.
-
-- [Zed AI Features and Privacy](./ai-improvement.md): An overview of Zed's AI features, your data when using AI in Zed, and how to opt-in and help Zed improve these features.
-
-- [Accounts](../authentication.md): When and why you'd need to authenticate into Zed, how to do so, and what scope we need from you.
-
-- [Collab](https://zed.dev/faq#data-and-privacy): How Zed's live collaboration works and how data flows. Zed does not store your code.
-
-## Legal Links
+## Legal
 
 - [Terms of Service](https://zed.dev/terms)
 - [Privacy Policy](https://zed.dev/privacy-policy)
-- [Zed's Contributor License and Feedback Agreement](https://zed.dev/cla)
+- [Contributor License and Feedback Agreement](https://zed.dev/cla)
 - [Subprocessors](https://zed.dev/subprocessors)
diff --git a/docs/src/appearance.md b/docs/src/appearance.md
index 1c26d671003..26c268e28c1 100644
--- a/docs/src/appearance.md
+++ b/docs/src/appearance.md
@@ -17,7 +17,7 @@ Here's how to make Zed feel like home:
 
 2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes.
 
-3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes.
+3. **Choose an icon theme**: Run {#action icon_theme_selector::Toggle} from the command palette to browse icon themes.
 
 4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font.
 
@@ -47,7 +47,7 @@ You can also override specific theme attributes for fine-grained control.
 
 ## Icon Themes
 
-Customize file and folder icons in the Project Panel and tabs. Browse available icon themes with the Icon Theme Selector (`icon theme selector: toggle` in the command palette).
+Customize file and folder icons in the Project Panel and tabs. Browse available icon themes with the Icon Theme Selector ({#action icon_theme_selector::Toggle} in the command palette).
 
 Like color themes, icon themes support separate light and dark variants:
 
diff --git a/docs/src/authentication.md b/docs/src/authentication.md
index 0f3dd2ecbce..59809ba410b 100644
--- a/docs/src/authentication.md
+++ b/docs/src/authentication.md
@@ -16,7 +16,7 @@ Signing in to Zed is not required. You can use most features you'd expect in a c
 
 Zed uses GitHub's OAuth flow to authenticate users, requiring only the `read:user` GitHub scope, which grants read-only access to your GitHub profile information.
 
-1. Open Zed and click the `Sign In` button in the top-right corner of the window, or run the `client: sign in` command from the command palette (`cmd-shift-p` on macOS or `ctrl-shift-p` on Windows/Linux).
+1. Open Zed and click the `Sign In` button in the top-right corner of the window, or run the {#action client::SignIn} command from the command palette (`cmd-shift-p` on macOS or `ctrl-shift-p` on Windows/Linux).
 2. Your default web browser will open to the Zed sign-in page.
 3. Authenticate with your GitHub account when prompted.
 4. After successful authentication, your browser will display a confirmation, and you'll be automatically signed in to Zed.
@@ -28,7 +28,7 @@ Zed uses GitHub's OAuth flow to authenticate users, requiring only the `read:use
 To sign out of Zed, you can use either of these methods:
 
 - Click on the profile icon in the upper right corner and select `Sign Out` from the dropdown menu.
-- Open the command palette and run the `client: sign out` command.
+- Open the command palette and run the {#action client::SignOut} command.
 
 ## Email Addresses {#email}
 
diff --git a/docs/src/business/admin-controls.md b/docs/src/business/admin-controls.md
new file mode 100644
index 00000000000..9fdc84d99fe
--- /dev/null
+++ b/docs/src/business/admin-controls.md
@@ -0,0 +1,43 @@
+---
+title: Admin Controls - Zed Business
+description: Configure AI, collaboration, and data sharing settings for your entire Zed Business organization.
+---
+
+# Admin Controls
+
+Owners and admins can configure settings that apply to every member of the organization.
+
+Most controls apply server-side to anything that routes through Zed's infrastructure. Some, like the Collaboration toggle, are enforced client-side and require members to be on a minimum Zed version. These controls don't cover [bring-your-own-key (BYOK) configurations](../ai/llm-providers.md), [external agents](../ai/external-agents.md), or [third-party extensions](../extensions.md), since those work independently of Zed's servers.
+
+## Accessing admin controls
+
+Admin controls are available to owners and admins in the organization dashboard at [dashboard.zed.dev](https://dashboard.zed.dev). Navigate to your organization, then select Data & Privacy from the sidebar to configure these settings.
+
+---
+
+## Collaboration
+
+The **Collaboration** toggle controls whether members can use Zed's real-time collaboration features, including [Channels](../collaboration/channels.md), shared projects, and voice chat. Collaboration is off by default for Business organizations.
+
+This control is configured from the Data & Privacy page in the organization dashboard. It is enforced client-side and requires members to be on Zed **0.233 or later**. Members on older versions will not have the setting enforced.
+
+## Hosted AI models
+
+The **Zed Model Provider** toggle controls whether members can use Zed's [hosted AI models](../ai/models.md):
+
+- **On:** Members can use Zed's hosted models for AI features.
+- **Off:** Members must bring their own API keys via [Providers](../ai/llm-providers.md) or use [external agents](../ai/external-agents.md) for AI features.
+
+## Edit Predictions
+
+The **Edit Prediction** toggle controls whether members can use Zed's hosted [Edit Predictions](../ai/edit-prediction.md) via the Zeta model family. Members using third-party providers or local models for edit predictions are not affected.
+
+**Edit Prediction Feedback** controls whether members can submit feedback on edit predictions. This setting is only configurable when Edit Prediction is enabled.
+
+## Agent Thread Feedback
+
+The **Agent Thread Feedback** toggle controls whether members can submit feedback on agent thread responses. When disabled, members cannot rate or provide feedback on AI agent conversations.
+
+## Data sharing
+
+On Free and Pro, [data sharing with Zed for AI improvement](../ai/ai-improvement.md) is opt-in per member. On Business, it's off by default and controlled by the Agent Thread Feedback and Edit Prediction Feedback toggles above.
diff --git a/docs/src/business/business-support.md b/docs/src/business/business-support.md
new file mode 100644
index 00000000000..d5bd46cf5d8
--- /dev/null
+++ b/docs/src/business/business-support.md
@@ -0,0 +1,14 @@
+---
+title: Business Support - Zed Business
+description: How to contact Zed for business inquiries and support.
+---
+
+# Business Support
+
+For billing and business support (account setup, invoices, organization questions), email [billing-support@zed.dev](mailto:billing-support@zed.dev). Business support is prioritized relative to other support channels.
+
+For general questions, email [hi@zed.dev](mailto:hi@zed.dev).
+
+## Open-source issues
+
+Questions and bugs about the Zed editor itself (features, extensions, language support, crashes) go through the main Zed project on [GitHub](https://github.com/zed-industries/zed/issues).
diff --git a/docs/src/business/organizations.md b/docs/src/business/organizations.md
new file mode 100644
index 00000000000..3ff4ab28ddc
--- /dev/null
+++ b/docs/src/business/organizations.md
@@ -0,0 +1,61 @@
+---
+title: Organizations - Zed Business
+description: Create and manage a Zed Business organization, invite members, and control access for your team.
+---
+
+# Organizations
+
+A Zed organization is your team's Zed Business subscription, with members, billing, and admin controls in one place.
+
+## Personal Organizations
+
+Every Zed account gets a personal organization at sign-up. It has its own subscription, billing, and settings, separate from any team you belong to.
+
+Your personal organization always stays active. Joining a Zed Business organization doesn't replace or affect it.
+
+In the Zed editor, an organization menu in the title bar shows your current organization by name. Click it to see all your organizations and switch between them.
+
+## Multiple Organizations
+
+A Zed account can belong to more than one organization at the same time. If you're invited to a second organization while already a member of one, you simply join both. Each organization has its own subscription, billing, and admin controls.
+
+To switch organizations in the dashboard, use the org switcher in the top-left corner. In the Zed editor, click the organization name in the title bar to see all your organizations and move between them.
+
+## Creating an organization
+
+To create an organization, go to [dashboard.zed.dev/create-organization](https://dashboard.zed.dev/create-organization). The person who creates the organization becomes its owner.
+
+If you don't have a payment method on file, you'll be taken through a checkout flow. If one is already on file, that step is skipped. After that, you'll land on an invite page to add your first members.
+
+## Inviting members
+
+Members are invited by email address. When an invite is accepted, the member's Zed account joins the organization and is covered by its subscription.
+
+To invite a member:
+
+1. Go to the Members page in your organization dashboard.
+2. Select **+ Invite Member**.
+3. Enter the member's email address and choose a role.
+4. They'll receive an email with a link to join.
+
+After accepting, they authenticate with their GitHub account and are added to the organization. For details on what each role can do, see [Roles & Permissions](../roles.md).
+
+## Managing members
+
+Owners and admins can manage members from the Members page in the dashboard.
+
+### Changing a member's role
+
+1. On the Members page, find the member.
+2. Open the menu and select a new role.
+
+### Removing a member
+
+1. On the Members page, find the member.
+2. Select **Remove** and confirm.
+
+Removing a member ends their access to the organization's subscription and admin-managed settings. Their personal Zed account and any other organization memberships are unaffected.
+
+## Organization Dashboard
+
+The dashboard shows your members, roles, and billing. Owners and admins have full access; members have no dashboard access.
diff --git a/docs/src/business/overview.md b/docs/src/business/overview.md
new file mode 100644
index 00000000000..4ce17d5b7b6
--- /dev/null
+++ b/docs/src/business/overview.md
@@ -0,0 +1,41 @@
+---
+title: Zed Business
+description: Zed Business gives every team member full Zed Pro access, with org-wide admin controls and enforced data settings for the whole organization.
+---
+
+# Zed Business
+
+Zed Business is Zed for your whole team. Every member gets access to Zed's hosted AI models and unlimited Edit Predictions, and administrators get controls to manage how Zed is used across the organization: which AI features are available, what data leaves your environment, and how AI spend is tracked.
+
+It's for teams that want modern AI tooling without security trade-offs, and for companies with procurement or compliance requirements that have blocked Zed deployment.
+
+## What's included
+
+Every member gets access to all [hosted AI models](../ai/models.md) and [Edit Predictions](../ai/edit-prediction.md).
+
+For the organization:
+
+- **Enforced data controls:** Administrators configure AI and data settings for
+  the whole organization from the Data & Privacy dashboard. Controls include the
+  [Zed Model Provider](./admin-controls.md#hosted-ai-models),
+  [Edit Predictions](./admin-controls.md#edit-predictions),
+  [Edit Prediction Feedback](./admin-controls.md#edit-predictions), and
+  [Agent Thread Feedback](./admin-controls.md#agent-thread-feedback). Members
+  can't override these settings individually.
+- **Private by default:** Zed doesn't store your prompts or train on them
+  without explicit opt-in.
+  [Data sharing for AI improvement](../ai/ai-improvement.md) is opt-in: members
+  can choose to share but are never enrolled automatically. Administrators can
+  [enforce this org-wide](./admin-controls.md#data-sharing), blocking members
+  from opting in at all.
+- **[Roles and permissions](../roles.md):** Owners, admins, and members have
+  different access levels. Billing and org settings are only visible to the
+  roles that need them.
+- **Consolidated billing:** Your team's licenses and AI usage appear on
+  [one invoice](../ai/billing.md#organization), with no separate bills per member.
+
+## Getting started
+
+To set up Zed Business for your team, see [Organizations](./organizations.md).
+
+For pricing, see [Plans & Pricing](../ai/plans-and-usage.md).
diff --git a/docs/src/business/privacy.md b/docs/src/business/privacy.md
new file mode 100644
index 00000000000..ea74afac9ad
--- /dev/null
+++ b/docs/src/business/privacy.md
@@ -0,0 +1,54 @@
+---
+title: Privacy for Business - Zed Business
+description: How Zed Business handles data privacy across your organization, including enforced protections for prompts and training data.
+---
+
+# Privacy for Business
+
+Zed Business removes the per-member data-sharing options that Free and Pro
+expose. These protections are on by default for every Business organization.
+Administrators can adjust them from
+[Admin Controls](./admin-controls.md); individual members can't opt in or out.
+
+## What's enforced by default
+
+For all members of a Zed Business organization:
+
+- **No prompt sharing:** Conversations and prompts are never shared with Zed.
+  Members can't opt into
+  [AI feedback via ratings](../ai/ai-improvement.md#ai-feedback-with-ratings).
+  Administrators can enable Agent Thread Feedback to allow this.
+- **No training data sharing:** Code context is never shared with Zed for
+  [Edit Prediction model training](../ai/ai-improvement.md#edit-predictions).
+  Members can't opt in individually. Administrators can enable Edit Prediction
+  Feedback to allow this.
+
+These protections are enforced server-side and apply to all org members.
+
+## How individual plans differ
+
+On Free and Pro, data sharing is opt-in:
+
+- Members can rate AI responses, which shares that conversation with Zed.
+- Members can opt into Edit Prediction training data collection for open source projects.
+
+Neither option is available to Zed Business members.
+
+## What data still leaves the organization
+
+These controls cover what Zed stores and trains on. They don't change how AI inference works: when members use Zed's hosted models, prompts and code context are still sent to the relevant provider (Anthropic, OpenAI, Google, etc.) to generate responses. Zed maintains zero-data retention agreements with these providers. See [AI Improvement](../ai/ai-improvement.md#data-retention-and-training) for details.
+
+[Bring-your-own-key](../ai/llm-providers.md) and [external agents](../ai/external-agents.md) are subject to each provider's own terms; Zed has no visibility into how they handle data.
+
+## Additional admin controls
+
+Administrators have additional options in [Admin Controls](./admin-controls.md):
+
+- Disable Zed-hosted models entirely via the Zed Model Provider toggle, so no
+  prompts reach Zed's infrastructure
+- Disable Edit Predictions org-wide
+- Disable Edit Prediction Feedback
+- Disable Agent Thread Feedback
+- Disable real-time collaboration
+
+See [Admin Controls](./admin-controls.md) for the full list.
diff --git a/docs/src/collaboration/channels.md b/docs/src/collaboration/channels.md
index a07979fc019..dc8a5eb833b 100644
--- a/docs/src/collaboration/channels.md
+++ b/docs/src/collaboration/channels.md
@@ -73,7 +73,7 @@ Open channel notes by clicking the document icon to the right of the channel nam
 ## Following Collaborators
 
 To follow a collaborator, click on their avatar in the top left of the title bar.
-You can also cycle through collaborators using {#kb workspace::FollowNextCollaborator} or `workspace: follow next collaborator` in the command palette.
+You can also cycle through collaborators using {#kb workspace::FollowNextCollaborator} or {#action workspace::FollowNextCollaborator} in the command palette.
 
 When you join a project, you'll immediately start following the collaborator that invited you.
 
diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md
index 89f7fc6c606..cff57ca2c06 100644
--- a/docs/src/command-palette.md
+++ b/docs/src/command-palette.md
@@ -9,6 +9,6 @@ The Command Palette is the main way to access actions in Zed. Its keybinding is
 
 ![The opened Command Palette](https://zed.dev/img/features/command-palette.jpg)
 
-To try it, open the Command Palette and type `new file`. The command list should narrow to `workspace: new file`. Press Return to create a new buffer.
+To try it, open the Command Palette and type `new file`. The command list should narrow to {#action workspace::NewFile}. Press Return to create a new buffer.
 
 Any time you see instructions that include commands of the form `zed: ...` or `editor: ...` and so on that means you need to execute them in the Command Palette.
diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md
index 01c884622ed..d4e76534fd1 100644
--- a/docs/src/configuring-languages.md
+++ b/docs/src/configuring-languages.md
@@ -353,7 +353,7 @@ To run linter fixes automatically on save:
 
 ### Formatting Selections
 
-Zed supports formatting only the selected text via `editor: format selections` ({#kb editor::FormatSelections}). How
+Zed supports formatting only the selected text via {#action editor::FormatSelections} ({#kb editor::FormatSelections}). How
 this works depends on the configured formatter:
 
 - The action is only shown when the active formatter can actually format ranges for at least one
@@ -395,7 +395,7 @@ Zed allows you to run both formatting and linting on save. Here's an example tha
 
 If you encounter issues with formatting or linting:
 
-1. Check Zed's log file for error messages (Use the command palette: `zed: open log`)
+1. Check Zed's log file for error messages (Use the command palette: {#action zed::OpenLog})
 2. Ensure external tools (formatters, linters) are correctly installed and in your PATH
 3. Verify configurations in both Zed settings and language-specific config files (e.g., `.eslintrc`, `.prettierrc`)
 
@@ -482,22 +482,22 @@ For language-specific inlay hint settings, refer to the documentation for each l
 
 ### Code Actions
 
-Code actions provide quick fixes and refactoring options. Access code actions using the `editor: Toggle Code Actions` command or by clicking the lightbulb icon that appears next to your cursor when actions are available.
+Code actions provide quick fixes and refactoring options. Access code actions using the {#action editor::ToggleCodeActions} command or by clicking the lightbulb icon that appears next to your cursor when actions are available.
 
 ### Go To Definition and References
 
 Use these commands to navigate your codebase:
 
-- `editor: Go to Definition` (f12|f12)
-- `editor: Go to Type Definition` (cmd-f12|ctrl-f12)
-- `editor: Find All References` (shift-f12|shift-f12)
+- {#action editor::GoToDefinition} (f12|f12)
+- {#action editor::GoToTypeDefinition} (cmd-f12|ctrl-f12)
+- {#action editor::FindAllReferences} (shift-f12|shift-f12)
 
 ### Rename Symbol
 
 To rename a symbol across your project:
 
 1. Place your cursor on the symbol
-2. Use the `editor: Rename Symbol` command (f2|f2)
+2. Use the {#action editor::Rename} command (f2|f2)
 3. Enter the new name and press Enter
 
 These features depend on the capabilities of the language server for each language.
@@ -506,7 +506,7 @@ When renaming a symbol that spans multiple files, Zed will open a preview in a m
 
 ### Hover Information
 
-Use the `editor: Hover` command to display information about the symbol under the cursor. This often includes type information, documentation, and links to relevant resources.
+Use the {#action editor::Hover} command to display information about the symbol under the cursor. This often includes type information, documentation, and links to relevant resources.
 
 ### Workspace Symbol Search
 
@@ -514,7 +514,7 @@ The {#action project_symbols::Toggle} command allows you to search for symbols (
 
 ### Code Completion
 
-Zed provides intelligent code completion suggestions as you type. You can manually trigger completion with the `editor: Show Completions` command. Use tab|tab or enter|enter to accept suggestions.
+Zed provides intelligent code completion suggestions as you type. You can manually trigger completion with the {#action editor::ShowCompletions} command. Use tab|tab or enter|enter to accept suggestions.
 
 ### Diagnostics
 
diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md
index b2a8c1e88a4..2ca5c215ec8 100644
--- a/docs/src/configuring-zed.md
+++ b/docs/src/configuring-zed.md
@@ -16,7 +16,7 @@ The **Settings Editor** ({#kb zed::OpenSettings}) is the primary way to configur
 To open it:
 
 - Press {#kb zed::OpenSettings}
-- Or run `zed: open settings` from the command palette
+- Or run {#action zed::OpenSettings} from the command palette
 
 As you type in the search box, matching settings appear with descriptions and controls to modify them. Changes save automatically to your settings file.
 
@@ -26,7 +26,7 @@ As you type in the search box, matching settings appear with descriptions and co
 
 ### User Settings
 
-Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run `zed: open settings file` from the command palette.
+Your user settings apply globally across all projects. Open the file with {#kb zed::OpenSettingsFile} or run {#action zed::OpenSettingsFile} from the command palette.
 
 The file is located at:
 
diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md
index 1f6b07840b8..4e14aceba40 100644
--- a/docs/src/development/glossary.md
+++ b/docs/src/development/glossary.md
@@ -44,7 +44,7 @@ for any type name, such as `AnyElement` or `LspStore`.
 - `Global`: A singleton type which has only one value, that is stored in the `App`.
 - `Event`: A data type that can be sent by an `Entity` to subscribers.
 - `Action`: An event that represents a user's keyboard input that can be handled by listeners
-  Example: `file finder: toggle`
+  Example: {#action file_finder::Toggle}
 - `Observing`: Reacting to notifications that entities have changed.
 - `Subscription`: An event handler that is used to react to the changes of state in the application.
   1. Emitted event handling
diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md
index 56545111fd1..77af9c8420e 100644
--- a/docs/src/development/linux.md
+++ b/docs/src/development/linux.md
@@ -159,7 +159,7 @@ Use this when Zed is using a lot of CPU. It is not useful for hangs.
   run `sudo chown $USER:$USER perf.data`
 
 - Get build info:
-  Run zed again and type `zed: about` in the command pallet to get the exact commit.
+  Run zed again and type {#action zed::About} in the command pallet to get the exact commit.
 
 The `perf.data` file can be sent to Zed together with the exact commit.
 
diff --git a/docs/src/extensions/agent-servers.md b/docs/src/extensions/agent-servers.md
index 60289f40cf8..23d8c888125 100644
--- a/docs/src/extensions/agent-servers.md
+++ b/docs/src/extensions/agent-servers.md
@@ -17,7 +17,7 @@ At some point in the near future, Agent Server extensions will be deprecated.
 Agent Servers are programs that provide AI agent implementations through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com).
 Agent Server Extensions let you package an Agent Server so users can install the extension and use your agent in Zed.
 
-You can see the current Agent Server extensions either by opening the Extensions tab in Zed (execute the `zed: extensions` command) and changing the filter from `All` to `Agent Servers`, or by visiting [the Zed website](https://zed.dev/extensions?filter=agent-servers).
+You can see the current Agent Server extensions either by opening the Extensions tab in Zed (execute the {#action zed::Extensions} command) and changing the filter from `All` to `Agent Servers`, or by visiting [the Zed website](https://zed.dev/extensions?filter=agent-servers).
 
 ## Defining Agent Server Extensions
 
diff --git a/docs/src/icon-themes.md b/docs/src/icon-themes.md
index 9d4b38700aa..5eb7e95f3e9 100644
--- a/docs/src/icon-themes.md
+++ b/docs/src/icon-themes.md
@@ -9,13 +9,13 @@ Zed comes with a built-in icon theme, with more icon themes available as extensi
 
 ## Selecting an Icon Theme
 
-See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with `icon theme selector: toggle`.
+See what icon themes are installed and preview them via the Icon Theme Selector, which you can open from the command palette with {#action icon_theme_selector::Toggle}.
 
 Navigating through the icon theme list by moving up and down will change the icon theme in real time and hitting enter will save it to your settings file.
 
 ## Installing more Icon Themes
 
-More icon themes are available from the Extensions page, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=icon-themes).
+More icon themes are available from the Extensions page, which you can access via the command palette with {#action zed::Extensions} or the [Zed website](https://zed.dev/extensions?filter=icon-themes).
 
 ## Configuring Icon Themes
 
diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md
index 7b449fea05a..ae64ab00b8c 100644
--- a/docs/src/key-bindings.md
+++ b/docs/src/key-bindings.md
@@ -21,7 +21,7 @@ We currently support:
 - Cursor
 - None (disables _all_ key bindings)
 
-This setting can also be changed via the command palette through the `zed: toggle base keymap selector` action.
+This setting can also be changed via the command palette through the {#action zed::ToggleBaseKeymapSelector} action.
 
 You can also enable `vim_mode` or `helix_mode`, which add modal bindings.
 For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md).
@@ -79,7 +79,7 @@ You can see all of Zed's default bindings for each platform in the default keyma
 - [Windows](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-windows.json)
 - [Linux](https://github.com/zed-industries/zed/blob/main/assets/keymaps/default-linux.json).
 
-If you want to debug problems with custom keymaps, you can use `dev: Open Key Context View` from the command palette.
+If you want to debug problems with custom keymaps, you can use {#action dev::OpenKeyContextView} from the command palette.
 Please file [an issue](https://github.com/zed-industries/zed) if you run into something you think should work but isn't.
 
 ### Keybinding Syntax
@@ -120,7 +120,7 @@ It is possible to match against typing a modifier key on its own. For example, `
 
 If a binding group has a `"context"` key, it will be matched against the currently active contexts in Zed.
 
-Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with the `dev: open key context view` command in the command palette.
+Zed's contexts make up a tree, with the root being `Workspace`. Workspaces contain Panes and Panels, and Panes contain Editors, etc. The easiest way to see what contexts are active at a given moment is the key context view, which you can get to with the {#action dev::OpenKeyContextView} command in the command palette.
 
 For example:
 
@@ -186,7 +186,7 @@ Otherwise, read on...
 
 On Cyrillic, Hebrew, Armenian, and other keyboards that are mostly non-ASCII, macOS automatically maps keys to the ASCII range when `cmd` is held. Zed takes this a step further, and it can always match key-presses against either the ASCII layout or the real layout, regardless of modifiers and the `use_key_equivalents` setting. For example, in Thai, pressing `ctrl-ๆ` will match bindings associated with `ctrl-q` or `ctrl-ๆ`.
 
-On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.), it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity: `option-2` produces `@`. To ensure that all the built-in keyboard shortcuts can still be typed on these keyboards, we move key bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running `dev: open key context view` from the command palette.
+On keyboards that support extended Latin alphabets (French AZERTY, German QWERTZ, etc.), it is often not possible to type the entire ASCII range without `option`. This introduces an ambiguity: `option-2` produces `@`. To ensure that all the built-in keyboard shortcuts can still be typed on these keyboards, we move key bindings around. For example, shortcuts bound to `@` on QWERTY are moved to `"` on a Spanish layout. This mapping is based on the macOS system defaults and can be seen by running {#action dev::OpenKeyContextView} from the command palette.
 
 If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap:
 
diff --git a/docs/src/languages.md b/docs/src/languages.md
index 4b96e551ced..b720e725cca 100644
--- a/docs/src/languages.md
+++ b/docs/src/languages.md
@@ -15,7 +15,7 @@ Some work out-of-the box and others rely on 3rd party extensions.
 - [Ansible](./languages/ansible.md)
 - [AsciiDoc](./languages/asciidoc.md)
 - [Astro](./languages/astro.md)
-- [Bash](./languages/bash.md)
+- [Bash](./languages/bash.md) \*
 - [Biome](./languages/biome.md)
 - [C](./languages/c.md) \*
 - [C++](./languages/cpp.md) \*
diff --git a/docs/src/languages/bash.md b/docs/src/languages/bash.md
index c801b55054c..ce117c87c12 100644
--- a/docs/src/languages/bash.md
+++ b/docs/src/languages/bash.md
@@ -5,14 +5,14 @@ description: "Configure Bash language support in Zed, including language servers
 
 # Bash
 
-Bash support is available through the [Bash extension](https://github.com/zed-extensions/bash).
+Bash support is available natively in Zed.
 
 - Tree-sitter: [tree-sitter/tree-sitter-bash](https://github.com/tree-sitter/tree-sitter-bash)
 - Language Server: [bash-lsp/bash-language-server](https://github.com/bash-lsp/bash-language-server)
 
 ## Configuration
 
-When `shellcheck` is available `bash-language-server` will use it internally to provide diagnostics.
+It is highly recommended to install `shellcheck`, as `bash-language-server` depends on it to provide diagnostics.
 
 ### Install `shellcheck`:
 
diff --git a/docs/src/languages/c.md b/docs/src/languages/c.md
index a4fb8a188a2..4fc054851c2 100644
--- a/docs/src/languages/c.md
+++ b/docs/src/languages/c.md
@@ -45,7 +45,7 @@ IndentWidth: 2
 
 See [Clang-Format Style Options](https://clang.llvm.org/docs/ClangFormatStyleOptions.html) for a complete list of options.
 
-You can trigger formatting via {#kb editor::Format} or the `editor: format` action from the command palette or by enabling format on save.
+You can trigger formatting via {#kb editor::Format} or the {#action editor::Format} action from the command palette or by enabling format on save.
 
 Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > C, or add to your settings file:
 
diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md
index 7fad9a52606..1f63460160c 100644
--- a/docs/src/languages/cpp.md
+++ b/docs/src/languages/cpp.md
@@ -97,7 +97,7 @@ PointerAlignment: Left
 
 See [Clang-Format Style Options](https://clang.llvm.org/docs/ClangFormatStyleOptions.html) for a complete list of options.
 
-You can trigger formatting via {#kb editor::Format} or the `editor: format` action from the command palette or by enabling format on save.
+You can trigger formatting via {#kb editor::Format} or the {#action editor::Format} action from the command palette or by enabling format on save.
 
 Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > C++, or add to your settings file:
 
diff --git a/docs/src/languages/rust.md b/docs/src/languages/rust.md
index 164cac49945..8568ca27ffd 100644
--- a/docs/src/languages/rust.md
+++ b/docs/src/languages/rust.md
@@ -155,7 +155,7 @@ This is enabled by default and can be configured as
 ## Manual Cargo Diagnostics fetch
 
 By default, rust-analyzer has `checkOnSave: true` enabled, which causes every buffer save to trigger a `cargo check --workspace --all-targets` command.
-If disabled with `checkOnSave: false` (see the example of the server configuration json above), it's still possible to fetch the diagnostics manually, with the `editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with `editor: run flycheck` command when the setting is enabled.
+If disabled with `checkOnSave: false` (see the example of the server configuration json above), it's still possible to fetch the diagnostics manually, with the `editor: run/clear/cancel flycheck` commands in Rust files to refresh cargo diagnostics; the project diagnostics editor will also refresh cargo diagnostics with {#action editor::RunFlycheck} command when the setting is enabled.
 
 ## More server configuration
 
diff --git a/docs/src/linux.md b/docs/src/linux.md
index 6ebb179db33..319c74960ed 100644
--- a/docs/src/linux.md
+++ b/docs/src/linux.md
@@ -205,7 +205,7 @@ Using [vkdevicechooser](https://github.com/jiriks74/vkdevicechooser).
 
 If Vulkan is configured correctly, and Zed is still not working for you, please [file an issue](https://github.com/zed-industries/zed) with as much information as possible.
 
-When reporting issues where Zed fails to start due to graphics initialization errors on GitHub, it can be impossible to run the `zed: copy system specs into clipboard` command like we instruct you to in our issue template. We provide an alternative way to collect the system specs specifically for this situation.
+When reporting issues where Zed fails to start due to graphics initialization errors on GitHub, it can be impossible to run the {#action zed::CopySystemSpecsIntoClipboard} command like we instruct you to in our issue template. We provide an alternative way to collect the system specs specifically for this situation.
 
 Passing the `--system-specs` flag to Zed like
 
diff --git a/docs/src/macos.md b/docs/src/macos.md
index 4c95c86122f..b9438185c67 100644
--- a/docs/src/macos.md
+++ b/docs/src/macos.md
@@ -46,7 +46,7 @@ Zed includes a command-line tool for opening files and projects from Terminal. T
 
 1. Open Zed
 2. Open the command palette with `Cmd+Shift+P`
-3. Run `cli: install`
+3. Run {#action cli::InstallCliBinary}
 
 This creates a `zed` command in `/usr/local/bin`. You can then open files and folders:
 
@@ -101,7 +101,7 @@ xattr -cr /Applications/Zed.app
 If the `zed` command isn't available after installation:
 
 1. Check that `/usr/local/bin` is in your PATH
-2. Try reinstalling the CLI via `cli: install` in the command palette
+2. Try reinstalling the CLI via {#action cli::InstallCliBinary} in the command palette
 3. Open a new terminal window to reload your PATH
 
 ### GPU or rendering issues
@@ -116,7 +116,7 @@ Zed uses Metal for rendering. If you experience graphical glitches:
 
 If Zed uses more resources than expected:
 
-1. Check for runaway language servers in the terminal output (`zed: open log`)
+1. Check for runaway language servers in the terminal output ({#action zed::OpenLog})
 2. Try disabling extensions one by one to identify conflicts
 3. For large projects, consider using [project settings](./reference/all-settings.md#file-scan-exclusions) to exclude unnecessary folders from indexing
 
diff --git a/docs/src/migrate/intellij.md b/docs/src/migrate/intellij.md
index 74f7cf226c8..a6a3773affc 100644
--- a/docs/src/migrate/intellij.md
+++ b/docs/src/migrate/intellij.md
@@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f
 
 ## Set Up Editor Preferences
 
-You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly.
+You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
 
 Settings IntelliJ users typically configure first:
 
@@ -125,7 +125,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou
 ### How to Customize Keybindings
 
 - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
-- Run `Zed: Open Keymap Editor`
+- Run {#action zed::OpenKeymap}
 
 This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
 
@@ -182,7 +182,7 @@ This means:
 **How to adapt:**
 
 - Create a `.zed/settings.json` in your project root for project-specific settings
-- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}):
 
 ```json
 [
diff --git a/docs/src/migrate/pycharm.md b/docs/src/migrate/pycharm.md
index 9f45135268e..95c37dcc9a1 100644
--- a/docs/src/migrate/pycharm.md
+++ b/docs/src/migrate/pycharm.md
@@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f
 
 ## Set Up Editor Preferences
 
-You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly.
+You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
 
 Settings PyCharm users typically configure first:
 
@@ -125,7 +125,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou
 ### How to Customize Keybindings
 
 - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
-- Run `Zed: Open Keymap Editor`
+- Run {#action zed::OpenKeymap}
 
 This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
 
@@ -211,7 +211,7 @@ This means:
 **How to adapt:**
 
 - Create a `.zed/settings.json` in your project root for project-specific settings
-- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}):
 
 ```json
 [
diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md
index 34cf03393e6..f4a8bccd6e3 100644
--- a/docs/src/migrate/rustrover.md
+++ b/docs/src/migrate/rustrover.md
@@ -45,7 +45,7 @@ This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` f
 
 ## Set Up Editor Preferences
 
-You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly.
+You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
 
 Settings RustRover users typically configure first:
 
@@ -138,7 +138,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou
 ### How to Customize Keybindings
 
 - Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`)
-- Run `Zed: Open Keymap Editor`
+- Run {#action zed::OpenKeymap}
 
 This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
 
@@ -183,7 +183,7 @@ Both editors store per-project configuration in a hidden folder. RustRover uses
 **How to adapt:**
 
 - Create a `.zed/settings.json` in your project root for project-specific settings
-- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}):
 
 ```json
 [
diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md
index b2f3049fce1..86b36e04446 100644
--- a/docs/src/migrate/vs-code.md
+++ b/docs/src/migrate/vs-code.md
@@ -166,11 +166,11 @@ The following VS Code settings are automatically imported when you use **Import
 
 Zed doesn’t import extensions or keybindings, but this import gets core editor behavior close to your VS Code setup. If you skip that step during setup, you can still import settings manually later via the command palette:
 
-`Cmd+Shift+P → Zed: Import VS Code Settings`
+`Cmd+Shift+P → {#action zed::ImportVsCodeSettings}`
 
 ## Set Up Editor Preferences
 
-You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly.
+You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
 
 Here’s how common VS Code settings translate:
 | VS Code | Zed | Notes |
@@ -244,7 +244,7 @@ Here’s a quick reference for where keybindings match and where they differ.
 To edit your keybindings:
 
 - Open the command palette (`Cmd+Shift+P`)
-- Run `Zed: Open Keymap Editor`
+- Run {#action zed::OpenKeymap}
 
 This opens a list of all available bindings. You can override individual shortcuts, remove conflicts, or build a layout that works better for your setup.
 
@@ -352,7 +352,7 @@ Here are a few useful tweaks:
 "load_direnv": "shell_hook"
 ```
 
-**Custom Tasks**: Define build or run commands in your `tasks.json` (accessed via command palette: `zed: open tasks`):
+**Custom Tasks**: Define build or run commands in your `tasks.json` (accessed via command palette: {#action zed::OpenTasks}):
 
 ```json
 [
@@ -364,4 +364,4 @@ Here are a few useful tweaks:
 ```
 
 **Bring over custom snippets**
-Copy your VS Code snippet JSON directly into Zed's snippets folder (`zed: configure snippets`).
+Copy your VS Code snippet JSON directly into Zed's snippets folder ({#action snippets::ConfigureSnippets}).
diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md
index e5313251ec1..0aa9c43f167 100644
--- a/docs/src/migrate/webstorm.md
+++ b/docs/src/migrate/webstorm.md
@@ -45,7 +45,7 @@ This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go
 
 ## Set Up Editor Preferences
 
-You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run `zed: open settings file` from the Command Palette to edit your settings file directly.
+You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
 
 Settings WebStorm users typically configure first:
 
@@ -118,7 +118,7 @@ If you chose the JetBrains keymap during onboarding, most of your shortcuts shou
 ### How to Customize Keybindings
 
 - Open the Command Palette ({#kb:jetbrains command_palette::Toggle})
-- Run `zed: open keymap`
+- Run {#action zed::OpenKeymap}
 
 This opens a list of all available bindings. You can override individual shortcuts or remove conflicts.
 
@@ -182,7 +182,7 @@ What this means in practice:
 **How to adapt:**
 
 - Create a `.zed/settings.json` in your project root for project-specific settings
-- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`):
+- Define common commands in `tasks.json` (open via Command Palette: {#action zed::OpenTasks}):
 
 ```json
 [
diff --git a/docs/src/multibuffers.md b/docs/src/multibuffers.md
index 5408c44597b..0033f86c0fb 100644
--- a/docs/src/multibuffers.md
+++ b/docs/src/multibuffers.md
@@ -18,28 +18,28 @@ One of the superpowers Zed gives you is the ability to edit multiple files simul
   >
 
 
-Editing a multibuffer is the same as editing a normal file. Changes you make will be reflected in the open copies of that file in the rest of the editor, and you can save all files with `editor: Save` (bound to `cmd-s` on macOS, `ctrl-s` on Windows/Linux, or `:w` in Vim mode).
+Editing a multibuffer is the same as editing a normal file. Changes you make will be reflected in the open copies of that file in the rest of the editor, and you can save all files with {#action workspace::Save} (bound to `cmd-s` on macOS, `ctrl-s` on Windows/Linux, or `:w` in Vim mode).
 
 When in a multibuffer, it is often useful to use multiple cursors to edit every file simultaneously. If you want to edit a few instances, you can select them with the mouse (`option-click` on macOS, `alt-click` on Window/Linux) or the keyboard. `cmd-d` on macOS, `ctrl-d` on Windows/Linux, or `gl` in Vim mode will select the next match of the word under the cursor.
 
-When you want to edit all matches you can select them by running the `editor: Select All Matches` command (`cmd-shift-l` on macOS, `ctrl-shift-l` on Windows/Linux, or `g a` in Vim mode).
+When you want to edit all matches you can select them by running the {#action editor::SelectAllMatches} command (`cmd-shift-l` on macOS, `ctrl-shift-l` on Windows/Linux, or `g a` in Vim mode).
 
 ## Navigating to the Source File
 
-While you can easily edit files in a multibuffer, navigating directly to the source file is often beneficial. You can accomplish this by clicking on any of the divider lines between excerpts or by placing your cursor in an excerpt and executing the `editor: open excerpts` command. It’s key to note that if multiple cursors are being used, the command will open the source file positioned under each cursor within the multibuffer.
+While you can easily edit files in a multibuffer, navigating directly to the source file is often beneficial. You can accomplish this by clicking on any of the divider lines between excerpts or by placing your cursor in an excerpt and executing the {#action editor::OpenExcerpts} command. It’s key to note that if multiple cursors are being used, the command will open the source file positioned under each cursor within the multibuffer.
 
 Additionally, if you prefer to use the mouse and would like to double-click on an excerpt to open it, you can enable this functionality with the setting: `"double_click_in_multibuffer": "open"`.
 
 ## Project search
 
-To start a search run the `pane: Toggle Search` command (`cmd-shift-f` on macOS, `ctrl-shift-f` on Windows/Linux, or `g/` in Vim mode). After the search has completed, the results will be shown in a new multibuffer. There will be one excerpt for each matching line across the whole project.
+To start a search run the {#action pane::DeploySearch} command (`cmd-shift-f` on macOS, `ctrl-shift-f` on Windows/Linux, or `g/` in Vim mode). After the search has completed, the results will be shown in a new multibuffer. There will be one excerpt for each matching line across the whole project.
 
 ## Diagnostics
 
-If you have a language server installed, the diagnostics pane can show you all errors across your project. You can open it by clicking on the icon in the status bar, or running the `diagnostics: Deploy` command (`cmd-shift-m` on macOS, `ctrl-shift-m` on Windows/Linux, or `:clist` in Vim mode).
+If you have a language server installed, the diagnostics pane can show you all errors across your project. You can open it by clicking on the icon in the status bar, or running the {#action diagnostics::Deploy} command (`cmd-shift-m` on macOS, `ctrl-shift-m` on Windows/Linux, or `:clist` in Vim mode).
 
 ## Find References
 
-If you have a language server installed, you can find all references to the symbol under the cursor with the `editor: Find References` command (`cmd-click` on macOS, `ctrl-click` on Windows/Linux, or `g A` in Vim mode.
+If you have a language server installed, you can find all references to the symbol under the cursor with the {#action editor::FindAllReferences} command (`cmd-click` on macOS, `ctrl-click` on Windows/Linux, or `g A` in Vim mode.
 
-Depending on your language server, commands like `editor: Go To Definition` and `editor: Go To Type Definition` will also open a multibuffer if there are multiple possible definitions.
+Depending on your language server, commands like {#action editor::GoToDefinition} and {#action editor::GoToTypeDefinition} will also open a multibuffer if there are multiple possible definitions.
diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md
index 7b31725bf2c..aa4c193a5eb 100644
--- a/docs/src/outline-panel.md
+++ b/docs/src/outline-panel.md
@@ -5,7 +5,7 @@ description: Navigate code structure with Zed's outline panel. View symbols, jum
 
 # Outline Panel
 
-In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` (`outline panel: toggle focus` via the command palette), or by clicking the `Outline Panel` button in the status bar.
+In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` ({#action outline_panel::ToggleFocus} via the command palette), or by clicking the `Outline Panel` button in the status bar.
 
 When viewing a "singleton" buffer (i.e., a single file on a tab), the outline panel works similarly to that of the outline modal-it displays the outline of the current buffer's symbols. Each symbol entry shows its type prefix (such as "struct", "fn", "mod", "impl") along with the symbol name, helping you quickly identify what kind of symbol you're looking at. Clicking on an entry allows you to jump to the associated section in the file. The outline view will also automatically scroll to the section associated with the current cursor position within the file.
 
@@ -29,7 +29,7 @@ View a summary of all errors and warnings reported by the language server.
 
 ### Find All References
 
-Quickly navigate through all references when using the `editor: find all references` action.
+Quickly navigate through all references when using the {#action editor::FindAllReferences} action.
 
 ![Using the outline panel while viewing `find all references` multi-buffer](https://zed.dev/img/outline-panel/find-all-references.png)
 
diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md
index bc20f1cf57a..6591a47b353 100644
--- a/docs/src/reference/all-settings.md
+++ b/docs/src/reference/all-settings.md
@@ -174,6 +174,12 @@ On Linux:
 ls ~/.local/share/zed/extensions/installed
 ```
 
+On Windows:
+
+```pwsh
+Get-ChildItem "$env:LOCALAPPDATA\Zed\extensions\installed" -Name
+```
+
 Define extensions which should be installed (`true`) or never installed (`false`).
 
 ```json [settings]
@@ -3147,7 +3153,7 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en
 
 ### Performance Profiler
 
-- Description: Collects timing data for foreground and background executor tasks so they can be inspected via the `zed: open performance profiler` action. Enabling this may lead to increased memory usage, hence it's disabled by default for regular builds.
+- Description: Collects timing data for foreground and background executor tasks so they can be inspected via the {#action zed::OpenPerformanceProfiler} action. Enabling this may lead to increased memory usage, hence it's disabled by default for regular builds.
 - Setting: `instrumentation.performance_profiler.enabled`
 - Default: `false`
 
@@ -5559,7 +5565,7 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting
 
 ## Settings Profiles
 
-- Description: Configure any number of settings profiles that are temporarily applied when selected from `settings profile selector: toggle`.
+- Description: Configure any number of settings profiles that are temporarily applied when selected from {#action settings_profile_selector::Toggle}.
 - Setting: `profiles`
 - Default: `{}`
 
@@ -5601,7 +5607,7 @@ Example:
 }
 ```
 
-To preview and enable a settings profile, open the command palette via {#kb command_palette::Toggle} and search for `settings profile selector: toggle`.
+To preview and enable a settings profile, open the command palette via {#kb command_palette::Toggle} and search for {#action settings_profile_selector::Toggle}.
 
 ## An example configuration:
 
diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md
index 788e287c3ab..5842bc2e7be 100644
--- a/docs/src/reference/cli.md
+++ b/docs/src/reference/cli.md
@@ -9,7 +9,7 @@ Use Zed's command-line interface (CLI) to open files and directories, integrate
 
 ## Installation
 
-**macOS:** Run the `cli: install` command from the command palette ({#kb command_palette::Toggle}) to install the `zed` CLI to `/usr/local/bin/zed`.
+**macOS:** Run the {#action cli::InstallCliBinary} command from the command palette ({#kb command_palette::Toggle}) to install the `zed` CLI to `/usr/local/bin/zed`.
 
 **Linux:** The CLI is included with Zed packages. The binary name may vary by distribution (commonly `zed` or `zeditor`).
 
diff --git a/docs/src/repl.md b/docs/src/repl.md
index 2e782cb0c14..b1704c5b852 100644
--- a/docs/src/repl.md
+++ b/docs/src/repl.md
@@ -39,21 +39,21 @@ Zed supports running code in multiple languages. To get started, you need to ins
 - [Julia](#julia)
 - [Scala (Almond)](#scala)
 
-Once installed, you can start using the REPL in the respective language files, or other places those languages are supported, such as Markdown. If you recently added the kernels, run the `repl: refresh kernelspecs` command to make them available in the editor.
+Once installed, you can start using the REPL in the respective language files, or other places those languages are supported, such as Markdown. If you recently added the kernels, run the {#action repl::RefreshKernelspecs} command to make them available in the editor.
 
 ## Using the REPL
 
-To start the REPL, open a file with the language you want to use and use the `repl: run` command (defaults to `ctrl-shift-enter` on macOS) to run a block, selection, or line. You can also click on the REPL icon in the toolbar.
+To start the REPL, open a file with the language you want to use and use the {#action repl::Run} command (defaults to `ctrl-shift-enter` on macOS) to run a block, selection, or line. You can also click on the REPL icon in the toolbar.
 
-The `repl: run` command will be executed on your selection(s), and the result will be displayed below the selection.
+The {#action repl::Run} command will be executed on your selection(s), and the result will be displayed below the selection.
 
-Outputs can be cleared with the `repl: clear outputs` command, or from the REPL menu in the toolbar.
+Outputs can be cleared with the {#action repl::ClearOutputs} command, or from the REPL menu in the toolbar.
 
 ### Cell mode
 
 Zed supports [notebooks as scripts](https://jupytext.readthedocs.io/en/latest/formats-scripts.html) using the `# %%` cell separator in Python and `// %%` in TypeScript. This allows you to write code in a single file and run it as if it were a notebook, cell by cell.
 
-The `repl: run` command will run each block of code between the `# %%` markers as a separate cell.
+The {#action repl::Run} command will run each block of code between the `# %%` markers as a separate cell.
 
 ```python
 # %% Cell 1
@@ -201,7 +201,7 @@ If execution is interrupted while an input prompt is active, the prompt automati
 
 ## Debugging Kernelspecs
 
-Available kernels are shown via the `repl: sessions` command. To refresh the kernels you can run, use the `repl: refresh kernelspecs` command.
+Available kernels are shown via the {#action repl::Sessions} command. To refresh the kernels you can run, use the {#action repl::RefreshKernelspecs} command.
 
 If you have `jupyter` installed, you can run `jupyter kernelspec list` to see the available kernels.
 
diff --git a/docs/src/semantic-tokens.md b/docs/src/semantic-tokens.md
index d26666ca7e7..1afcde80974 100644
--- a/docs/src/semantic-tokens.md
+++ b/docs/src/semantic-tokens.md
@@ -41,7 +41,7 @@ You can configure this globally or per-language:
 }
 ```
 
-> **Note:** Changing the `semantic_tokens` mode may require a language server restart to take effect. Use the `lsp: restart language servers` command from the command palette if highlighting doesn't update immediately.
+> **Note:** Changing the `semantic_tokens` mode may require a language server restart to take effect. Use the {#action editor::RestartLanguageServer} command from the command palette if highlighting doesn't update immediately.
 
 ## Customizing Token Colors
 
@@ -150,7 +150,7 @@ Zed's default semantic token rules map standard LSP token types to common theme
 - `class` → `type.class`, `class`, or `type` style (first found)
 - `comment` with `documentation` modifier → `comment.documentation` or `comment.doc` style
 
-The full default configuration can be shown in Zed with the `zed: show default semantic token rules` command.
+The full default configuration can be shown in Zed with the {#action zed::ShowDefaultSemanticTokenRules} command.
 
 ## Standard Token Types
 
@@ -184,7 +184,7 @@ For the complete specification, see the [LSP Semantic Tokens documentation](http
 
 ## Inspecting Semantic Tokens
 
-To see semantic tokens applied to your code in real-time, use the `dev: open highlights tree view` command from the command palette. This opens a panel showing all highlights (including semantic tokens) for the current buffer, making it easier to understand which tokens are being applied and debug your custom rules.
+To see semantic tokens applied to your code in real-time, use the {#action dev::OpenHighlightsTreeView} command from the command palette. This opens a panel showing all highlights (including semantic tokens) for the current buffer, making it easier to understand which tokens are being applied and debug your custom rules.
 
 ## Troubleshooting
 
@@ -192,12 +192,12 @@ To see semantic tokens applied to your code in real-time, use the `dev: open hig
 
 1. Ensure `semantic_tokens` is set to `"combined"` or `"full"` for the language
 2. Verify the language server supports semantic tokens (not all do)
-3. Try restarting the language server with `lsp: restart language servers`
-4. Check the LSP logs (`workspace: open lsp log`) for errors
+3. Try restarting the language server with {#action editor::RestartLanguageServer}
+4. Check the LSP logs ({#action dev::OpenLanguageServerLogs}) for errors
 
 ### Colors not updating after changing settings
 
-Changes to `semantic_tokens` mode may require a language server restart. Use `lsp: restart language servers` from the command palette.
+Changes to `semantic_tokens` mode may require a language server restart. Use {#action editor::RestartLanguageServer} from the command palette.
 
 ### Theme styles not being applied
 
diff --git a/docs/src/soc2.md b/docs/src/soc2.md
new file mode 100644
index 00000000000..30e79705b59
--- /dev/null
+++ b/docs/src/soc2.md
@@ -0,0 +1,10 @@
+---
+title: SOC2 - Zed
+description: Zed's SOC2 certification status.
+---
+
+# SOC2
+
+Zed is working toward SOC2 Type 1 certification.
+
+For updates or compliance questions, email [sales@zed.dev](mailto:sales@zed.dev).
diff --git a/docs/src/tasks.md b/docs/src/tasks.md
index 401cef6a4cc..b1b872e176f 100644
--- a/docs/src/tasks.md
+++ b/docs/src/tasks.md
@@ -62,9 +62,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated [terminal](
 ]
 ```
 
-There are two actions that drive the workflow of using tasks: `task: spawn` and `task: rerun`.
-`task: spawn` opens a modal with all available tasks in the current file.
-`task: rerun` reruns the most recently spawned task. You can also rerun tasks from the task modal.
+There are two actions that drive the workflow of using tasks: {#action task::Spawn} and {#action task::Rerun}.
+{#action task::Spawn} opens a modal with all available tasks in the current file.
+{#action task::Rerun} reruns the most recently spawned task. You can also rerun tasks from the task modal.
 
 By default, rerunning tasks reuses the same terminal (due to the `"use_new_terminal": false` default) but waits for the previous task to finish before starting (due to the `"allow_concurrent_runs": false` default).
 
@@ -74,8 +74,8 @@ Keep `"use_new_terminal": false` and set `"allow_concurrent_runs": true` to allo
 
 Tasks can be defined:
 
-- in the global `tasks.json` file; such tasks are available in all Zed projects you work on. This file is usually located in `~/.config/zed/tasks.json`. You can edit them by using the `zed: open tasks` action.
-- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the `zed: open project tasks` action.
+- in the global `tasks.json` file; such tasks are available in all Zed projects you work on. This file is usually located in `~/.config/zed/tasks.json`. You can edit them by using the {#action zed::OpenTasks} action.
+- in the worktree-specific (local) `.zed/tasks.json` file; such tasks are available only when working on a project with that worktree included. You can edit worktree-specific tasks by using the {#action zed::OpenProjectTasks} action.
 - on the fly with [oneshot tasks](#oneshot-tasks). These tasks are project-specific and do not persist across sessions.
 - by language extension.
 
@@ -167,16 +167,16 @@ Set default values to such variables to have such tasks always displayed:
 
 ## Oneshot tasks
 
-The same task modal opened via `task: spawn` supports arbitrary bash-like command execution: type a command inside the modal text field, and use `opt-enter` to spawn it.
+The same task modal opened via {#action task::Spawn} supports arbitrary bash-like command execution: type a command inside the modal text field, and use `opt-enter` to spawn it.
 
-The task modal persists these ad-hoc commands for the duration of the session, `task: rerun` will also rerun such tasks if they were the last ones spawned.
+The task modal persists these ad-hoc commands for the duration of the session, {#action task::Rerun} will also rerun such tasks if they were the last ones spawned.
 
 You can also adjust the currently selected task in a modal (`tab` is the default key binding). Doing so will put its command into a prompt that can then be edited & spawned as a oneshot task.
 
 ### Ephemeral tasks
 
-You can use the `cmd` modifier when spawning a task via a modal; tasks spawned this way will not have their usage count increased (thus, they will not be respawned with `task: rerun` and they won't have a high rank in the task modal).
-The intended use of ephemeral tasks is to stay in the flow with continuous `task: rerun` usage.
+You can use the `cmd` modifier when spawning a task via a modal; tasks spawned this way will not have their usage count increased (thus, they will not be respawned with {#action task::Rerun} and they won't have a high rank in the task modal).
+The intended use of ephemeral tasks is to stay in the flow with continuous {#action task::Rerun} usage.
 
 ### More task rerun control
 
@@ -306,7 +306,7 @@ In doing so, you can change which task is shown in the runnables indicator.
 
 ## Keybindings to run tasks bound to runnables
 
-When you have a task definition that is bound to the runnable, you can quickly run it using [Code Actions](https://zed.dev/docs/configuring-languages?#code-actions) that you can trigger either via `editor: Toggle Code Actions` command or by the `cmd-.`/`ctrl-.` shortcut. Your task will be the first in the dropdown. The task will run immediately if there are no additional Code Actions for this line.
+When you have a task definition that is bound to the runnable, you can quickly run it using [Code Actions](https://zed.dev/docs/configuring-languages?#code-actions) that you can trigger either via {#action editor::ToggleCodeActions} command or by the `cmd-.`/`ctrl-.` shortcut. Your task will be the first in the dropdown. The task will run immediately if there are no additional Code Actions for this line.
 
 ## Running Bash Scripts
 
diff --git a/docs/src/telemetry.md b/docs/src/telemetry.md
index a8ca9f3e03c..89bc18d79c1 100644
--- a/docs/src/telemetry.md
+++ b/docs/src/telemetry.md
@@ -64,6 +64,12 @@ When using Zed's hosted services, we collect metadata for rate limiting and bill
 
 For details on AI data handling, see [Zed AI Features and Privacy](./ai/ai-improvement.md).
 
+## Zed Business
+
+Administrators on Zed Business can enforce a no-sharing policy org-wide; members can't opt into [edit prediction training data sharing](./ai/ai-improvement.md#edit-predictions) or [AI feedback ratings](./ai/ai-improvement.md#ai-feedback-with-ratings). See [Data Sharing](./business/admin-controls.md#data-sharing) in Admin Controls.
+
+
+
 ## Concerns and Questions
 
 If you have concerns about telemetry, you can [open an issue](https://github.com/zed-industries/zed/issues/new/choose) or email hi@zed.dev.
diff --git a/docs/src/terminal.md b/docs/src/terminal.md
index b3c75f338fe..e4e876ab2db 100644
--- a/docs/src/terminal.md
+++ b/docs/src/terminal.md
@@ -15,14 +15,14 @@ Zed includes a built-in terminal emulator that supports multiple terminal instan
 | Open new terminal       | `Ctrl+~`        | `Ctrl+~`        |
 | Open terminal in center | Command palette | Command palette |
 
-You can also open a terminal from the command palette with `terminal panel: toggle` or `workspace: new terminal`.
+You can also open a terminal from the command palette with {#action terminal_panel::Toggle} or {#action workspace::NewTerminal}.
 
 ### Terminal Panel vs Center Terminal
 
 Terminals can open in two locations:
 
 - **Terminal Panel** — Docked at the bottom (default), left, or right of the workspace. Toggle with `` Ctrl+` ``.
-- **Center Pane** — Opens as a regular tab alongside your files. Use `workspace: new center terminal` from the command palette.
+- **Center Pane** — Opens as a regular tab alongside your files. Use {#action workspace::NewCenterTerminal} from the command palette.
 
 ## Working with Multiple Terminals
 
diff --git a/docs/src/themes.md b/docs/src/themes.md
index d78f9625087..347967a3f8d 100644
--- a/docs/src/themes.md
+++ b/docs/src/themes.md
@@ -9,13 +9,13 @@ Zed comes with a number of built-in themes, with more themes available as extens
 
 ## Selecting a Theme
 
-See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with the `theme selector: toggle` (bound to {#kb theme_selector::Toggle}) action.
+See what themes are installed and preview them via the Theme Selector, which you can open from the command palette with the {#action theme_selector::Toggle} (bound to {#kb theme_selector::Toggle}) action.
 
 Navigating through the theme list by moving up and down will change the theme in real time and hitting enter will save the selected one to your settings file.
 
 ## Installing New Themes
 
-You can find hundreds of different theme options in Zed's extensions store, which you can access via the command palette with `zed: extensions` or the [Zed website](https://zed.dev/extensions?filter=themes).
+You can find hundreds of different theme options in Zed's extensions store, which you can access via the command palette with {#action zed::Extensions} or the [Zed website](https://zed.dev/extensions?filter=themes).
 
 Many popular themes have been ported to Zed, and if you're struggling to choose one, visit [zed-themes.com](https://zed-themes.com), a third-party gallery with visible previews for many of them.
 
diff --git a/docs/src/update.md b/docs/src/update.md
index 1a43bf8d8e1..8cd3ce3988c 100644
--- a/docs/src/update.md
+++ b/docs/src/update.md
@@ -19,7 +19,7 @@ To check which version of Zed you're using:
 
 Open the Command Palette (Cmd+Shift+P on macOS, Ctrl+Shift+P on Linux/Windows).
 
-Type and select `zed: about`. A modal will appear with your version information.
+Type and select {#action zed::About}. A modal will appear with your version information.
 
 ## How to control update behavior
 
diff --git a/docs/src/vim.md b/docs/src/vim.md
index e53e37fb312..1f777537ba8 100644
--- a/docs/src/vim.md
+++ b/docs/src/vim.md
@@ -30,7 +30,7 @@ There are four types of features in vim mode that use Zed's core functionality,
 
 When you first open Zed, you'll see a checkbox on the welcome screen that allows you to enable vim mode.
 
-If you missed this, you can toggle vim mode on or off anytime by opening the command palette and using the workspace command `toggle vim mode`.
+If you missed this, you can toggle vim mode on or off anytime by opening the command palette and using the workspace command {#action workspace::ToggleVimMode}.
 
 > **Note**: This command toggles the following property in your user settings:
 >
diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md
index 35c25cda0e2..4d5a18d7b20 100644
--- a/docs/src/worktree-trust.md
+++ b/docs/src/worktree-trust.md
@@ -50,7 +50,7 @@ Zed has multiple layers of trust, based on the requests, from the least to most
 - "single file worktree"
 
 After opening an empty Zed window, you can open a single file. You can also open a file outside the current directory after opening a directory.
-A common example is `zed: open settings file`, which may start a language server for that file and create a new single-file worktree.
+A common example is {#action zed::OpenSettingsFile}, which may start a language server for that file and create a new single-file worktree.
 
 Spawning a language server presents a risk should the language server experience a supply-chain attack; therefore, Zed restricts that by default. Each single file worktree requires a separate trust grant, unless the directory containing it is trusted or all worktrees are trusted.
 
diff --git a/script/triage_project_sync.py b/script/triage_project_sync.py
index 239ae9f7ab4..8c5c75988ee 100644
--- a/script/triage_project_sync.py
+++ b/script/triage_project_sync.py
@@ -72,6 +72,7 @@ STAFF_TEAM_SLUG = "staff"
 # (Casing matters — GH Projects single-select option matching is case-sensitive.)
 STATUS_NEEDS_LABELS = "Needs labels"
 STATUS_NEEDS_REPRO_ATTEMPT = "Needs repro attempt"
+STATUS_NEEDS_ASK = "Needs ask"
 STATUS_USER_REPLIED = "User replied (review)"
 STATUS_AWAITING_USER = "Awaiting user"
 STATUS_RESPONDED_NO_REPRO = "Responded, no repro"
@@ -90,6 +91,8 @@ AGE_THRESHOLDS_DAYS = {
     STATUS_NEEDS_REPRO_ATTEMPT: 7,
     STATUS_AWAITING_USER: 14,
     STATUS_USER_REPLIED: 3,
+    # Needs ask is handled explicitly in derive_aged (always flagged), so
+    # it doesn't need a threshold here.
 }
 
 TERMINAL_OR_RESTING_STATUSES = {
@@ -370,34 +373,46 @@ def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]:
             "reproducible, no assignee, no substantive staff comment — close the loop",
         )
 
-    if "state:needs triage" in L:
-        return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present"
-
+    # R4 (state:needs info) and R5 (state:needs repro) intentionally come
+    # before R3 (state:needs triage). Per the team's actual practice,
+    # state:needs triage is often left on while triage is in progress; only
+    # when no other state label is more specific should we treat the issue
+    # as "needs initial labels."
     if "state:needs info" in L:
-        last_staff = None
+        # R4 splits into three sub-cases based on whether we've actually
+        # asked anything (substantive staff comment) and whether the reporter
+        # or a third-party has responded.
+        substantive_staff = None
         for c in issue.comments:
-            if c["user"]["login"] in staff and not is_bot(c["user"]):
-                last_staff = c
-        if last_staff is None:
-            return STATUS_AWAITING_USER, "R4a", "needs info, no staff comment yet"
+            if is_substantive_staff_comment(c, staff):
+                substantive_staff = c
+        if substantive_staff is None:
+            # state:needs info applied without an actual question to the user.
+            # Runbook violation — we owe the reporter a comment explaining
+            # what info we need.
+            return (
+                STATUS_NEEDS_ASK,
+                "R4c",
+                "state:needs info present but no substantive staff comment exists — we haven't asked anything",
+            )
         last_comment = issue.comments[-1] if issue.comments else None
         if last_comment is not None:
             author = last_comment["user"]["login"]
             non_staff = author not in staff and not is_bot(last_comment["user"])
             if non_staff:
                 ct = parse_dt(last_comment["created_at"])
-                st = parse_dt(last_staff["created_at"])
+                st = parse_dt(substantive_staff["created_at"])
                 if ct and st and ct > st:
                     relation = "reporter" if author == issue.reporter else "third-party"
                     return (
                         STATUS_USER_REPLIED,
                         "R4b",
-                        f"{relation} (@{author}) replied {ct.isoformat()} after staff @ {st.isoformat()}",
+                        f"{relation} (@{author}) replied {ct.isoformat()} after substantive staff @ {st.isoformat()}",
                     )
         return (
             STATUS_AWAITING_USER,
             "R4a",
-            f"last staff comment @ {last_staff['created_at']}, no non-staff reply since",
+            f"substantive staff comment @ {substantive_staff['created_at']}, no non-staff reply since",
         )
 
     if "state:needs repro" in L:
@@ -412,6 +427,13 @@ def derive_status(issue: IssueData, staff: set[str]) -> tuple[str, str, str]:
                 )
         return STATUS_NEEDS_REPRO_ATTEMPT, "R5a", "no substantive staff comment after reporter's last activity"
 
+    # R3 (state:needs triage) is checked LAST among recognized state labels.
+    # If state:needs triage is the only state label, the issue genuinely needs
+    # initial labeling. If any other state label is also present, that state
+    # has already been matched above and won.
+    if "state:needs triage" in L:
+        return STATUS_NEEDS_LABELS, "R3", "state:needs triage label present (no other state:* matched)"
+
     return STATUS_UNKNOWN, "R6", f"open with no recognized state label (labels: {sorted(L) or ''})"
 
 
@@ -425,12 +447,18 @@ def derive_stale_since(
         return issue.created_at
     if status == STATUS_NEEDS_REPRO_ATTEMPT:
         return latest_reporter_activity(issue)
+    if status == STATUS_NEEDS_ASK:
+        # Anchor on issue creation — measures how long the runbook violation
+        # has gone unaddressed. Aging threshold is 0 (always flagged).
+        return issue.created_at
     if status == STATUS_AWAITING_USER:
-        last_staff = None
+        # Anchor on the most recent SUBSTANTIVE staff comment (the actual
+        # "ask"), consistent with R4's substantive-comment requirement.
+        substantive_staff = None
         for c in issue.comments:
-            if c["user"]["login"] in staff and not is_bot(c["user"]):
-                last_staff = c
-        return parse_dt(last_staff["created_at"]) if last_staff else issue.created_at
+            if is_substantive_staff_comment(c, staff):
+                substantive_staff = c
+        return parse_dt(substantive_staff["created_at"]) if substantive_staff else issue.created_at
     if status == STATUS_USER_REPLIED:
         last_non_staff = None
         for c in issue.comments:
@@ -450,6 +478,8 @@ def derive_aged(status: str, stale_since: datetime | None) -> tuple[str, str]:
     """Returns ('Yes' | 'No', why)."""
     if status == STATUS_HANDOFF_INCOMPLETE:
         return "Yes", "always-flagged for loop closure"
+    if status == STATUS_NEEDS_ASK:
+        return "Yes", "always-flagged: state:needs info applied without a substantive staff comment"
     if status in TERMINAL_OR_RESTING_STATUSES or status == STATUS_UNKNOWN:
         return "No", "terminal/resting"
     if not stale_since:
diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs
index b275503f34d..1043c1fc009 100644
--- a/tooling/xtask/src/tasks/workflows.rs
+++ b/tooling/xtask/src/tasks/workflows.rs
@@ -3,6 +3,7 @@ use clap::Parser;
 use gh_workflow::Workflow;
 use std::fs;
 use std::path::{Path, PathBuf};
+use strum::IntoEnumIterator;
 
 use crate::tasks::workflow_checks::{self};
 
@@ -167,14 +168,17 @@ pub enum WorkflowType {
 }
 
 impl WorkflowType {
+    const PREAMBLE: &str = "# Generated from xtask::workflows::";
+
     fn disclaimer(&self, workflow_name: &str) -> String {
         format!(
             concat!(
-                "# Generated from xtask::workflows::{}{}\n",
+                "{preamble}{workflow_name}{external_disclaimer}\n",
                 "# Rebuild with `cargo xtask workflows`.",
             ),
-            workflow_name,
-            (*self != WorkflowType::Zed)
+            preamble = Self::PREAMBLE,
+            workflow_name = workflow_name,
+            external_disclaimer = (*self != WorkflowType::Zed)
                 .then_some(" within the Zed repository.")
                 .unwrap_or_default(),
         )
@@ -187,6 +191,26 @@ impl WorkflowType {
             WorkflowType::ExtensionsShared => PathBuf::from("extensions/workflows/shared"),
         }
     }
+
+    fn remove_generated_workflows() -> Result<()> {
+        for workflow_type in Self::iter() {
+            for path in fs::read_dir(workflow_type.folder_path())? {
+                let entry = path?;
+                if !entry.file_type().is_ok_and(|file_type| file_type.is_file()) {
+                    continue;
+                }
+
+                let path = entry.path();
+                if fs::read_to_string(&path)
+                    .is_ok_and(|content| content.starts_with(Self::PREAMBLE))
+                {
+                    fs::remove_file(path)?;
+                }
+            }
+        }
+
+        Ok(())
+    }
 }
 
 pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
@@ -194,6 +218,9 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
         anyhow::bail!("xtask workflows must be ran from the project root");
     }
 
+    // Remove all previously generated workflows to ensure these do not become stale.
+    WorkflowType::remove_generated_workflows()?;
+
     let workflows = [
         WorkflowFile::zed(after_release::after_release),
         WorkflowFile::zed(autofix_pr::autofix_pr),