From 750a94c32d5f980953ee0f4f78c24ae4f60339e8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 5 May 2026 21:29:49 +0200 Subject: [PATCH 01/98] Update to wasmtime 36.0.9 (#55811) Brings in backported fixes to solve some panics we've been seeing Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 168 ++++++++++++++++++++++++++--------------------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ee81109be5..cea560d65a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,36 +3960,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 +3997,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 +4027,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 +4040,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 +4066,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 +4078,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 +4095,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" @@ -5021,7 +5021,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5811,7 +5811,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 +7269,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8597,7 +8597,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -8615,7 +8615,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -11264,7 +11264,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]] @@ -13879,9 +13879,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", @@ -13891,9 +13891,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", @@ -13996,7 +13996,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", @@ -14033,9 +14033,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]] @@ -15251,7 +15251,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -17739,7 +17739,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -19934,9 +19934,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", @@ -19995,9 +19995,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", @@ -20022,9 +20022,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", ] @@ -20041,9 +20041,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", @@ -20056,15 +20056,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", @@ -20089,9 +20089,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", @@ -20105,9 +20105,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", @@ -20115,9 +20115,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", @@ -20127,24 +20127,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", @@ -20155,9 +20155,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", @@ -20166,9 +20166,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", @@ -20183,9 +20183,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", @@ -20196,9 +20196,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", @@ -20227,9 +20227,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", @@ -20694,9 +20694,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", @@ -20709,9 +20709,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", @@ -20723,9 +20723,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", @@ -20755,7 +20755,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]] @@ -20766,9 +20766,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", From c60c0d5f496cb8106afdcc7eff52679328a73b8e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 5 May 2026 15:51:55 -0400 Subject: [PATCH 02/98] Strengthen guidance for git commands in the terminal tool (#55787) Move guidance about pty-blocking commands from the `TerminalToolInput` struct-level doc comment to the `command` field's doc comment so it's surfaced more prominently to the model in the tool's JSON schema, and call out `--no-pager` and `GIT_EDITOR=true` explicitly for git operations. In practice, the previous wording about `git --no-pager diff` was easy for agents to overlook, leading to terminal calls that block waiting on `less` (for `git log`/`git diff`/`git show`) or on an interactive editor (for `git rebase`/`git commit`/`git merge`). Unit eval change before/after wording change: image Closes AI-154 Release Notes: - Zed Agent's terminal tool now much more consistently uses `--no-pager` and `GIT_EDITOR` with `git` commands --- crates/agent/src/tools/evals.rs | 2 + crates/agent/src/tools/evals/terminal_tool.rs | 528 ++++++++++++++++++ crates/agent/src/tools/terminal_tool.rs | 9 +- 3 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 crates/agent/src/tools/evals/terminal_tool.rs diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs index b5d9f47ea5d..a2e09b3f8aa 100644 --- a/crates/agent/src/tools/evals.rs +++ b/crates/agent/src/tools/evals.rs @@ -1,2 +1,4 @@ #[cfg(all(test, feature = "unit-eval"))] mod edit_file; +#[cfg(all(test, feature = "unit-eval"))] +mod terminal_tool; 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..3769df5abed --- /dev/null +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -0,0 +1,528 @@ +use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput}; +use Role::*; +use anyhow::{Context as _, Result}; +use client::{Client, RefreshLlmTokenListener, UserStore}; +use futures::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<()> { + 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 = TerminalToolTest::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 { + 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( + 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/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, From 2d5c4cede1c20fbe209d414ce52aa34da7bb8013 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 5 May 2026 17:07:52 -0400 Subject: [PATCH 03/98] git_graph: Keep shared parents on the leftmost incoming lane (#55818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes git graph lane selection when multiple active lanes point to the same parent commit, matching Git’s `--graph` behavior by keeping the commit on the leftmost incoming lane instead of the first discovered lane. This should make it much easier to track long lived branches ### Before image ### After Screenshot 2026-05-05 at 4 07 28 PM Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Remco Smits --- crates/git_graph/src/git_graph.rs | 72 ++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) 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(()) From 4e082632fcbc51e5419b7376e542c2024db8c7de Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 5 May 2026 17:44:06 -0400 Subject: [PATCH 04/98] git_graph: Add remote support (#55788) Follow up: https://github.com/zed-industries/zed/pull/55167, https://github.com/zed-industries/zed/pull/54468 This is the final PR for adding remote support on the git graph. It uses the client stream request support added in #55167 to add support for the initial graph data request. I also fixed a bug where `GitGraph::FullyLoaded` repository event was never emitted. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes ##53040 Co-authored-by: Remco Smits \ Release Notes: - git_graph: Add remote support --------- Co-authored-by: Ben Kunkle --- crates/project/src/git_store.rs | 366 +++++++++++++++++++++++++++----- crates/proto/proto/git.proto | 24 +++ crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 4 + 4 files changed, 338 insertions(+), 60 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 20facc32640..d5c6565b6cc 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -361,6 +361,7 @@ pub struct InitialGitGraphData { pub error: Option, pub commit_data: Vec>, pub commit_oid_to_index: HashMap, + subscribers: Vec>, SharedString>>>, } pub struct GraphDataResponse<'a> { @@ -680,6 +681,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); } @@ -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, @@ -4413,23 +4514,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(), @@ -4481,6 +4566,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, @@ -4501,6 +4588,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() } @@ -5121,28 +5227,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 +5279,7 @@ impl Repository { error: None, commit_data: Vec::new(), commit_oid_to_index: HashMap::default(), + subscribers: Vec::new(), } }); @@ -5164,6 +5294,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 +5358,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, @@ -8249,6 +8438,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(), diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index afea6cf34a3..bf7bbeb4359 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -705,6 +705,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, From 20620a8c08cb160b5a83c54f771aa022043d8d49 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 5 May 2026 18:46:22 -0300 Subject: [PATCH 05/98] editor: Fix panic in `text_layout_details` pre layout (#55816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a panic at `Editor::text_layout_details` when called against an editor whose element has never been laid out (i.e., `set_style` has never been called, so the cached `style` is still `None`). We've seen this crash once through a helix motion. The exact production sequence isn't clear — for the editor to receive a vim action without ever having been drawn, the active item would have to have changed inside the same update tick that ends with the deferred `search_submit`, which is narrow but not impossible (since it's dispatched by the workspace, not the editor). Release Notes: - Fixed a rare panic when invoking helix motions on an editor that had not yet been laid out. --- crates/editor/src/editor.rs | 2 +- crates/vim/src/helix.rs | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 649ffbfae8a..7d68b02d4e2 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5985,7 +5985,7 @@ impl Editor { 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(), + 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(), diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index d61b0547aef..544a19167ac 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -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; From be705e677bc1dd7fc6715cb4638d186a41130232 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 5 May 2026 16:41:13 -0600 Subject: [PATCH 06/98] Merge gpui::Task and scheduler::Task (#53674) Release Notes: - N/A or Added/Fixed/Improved ... --- crates/agent/src/agent.rs | 2 +- crates/agent_ui/src/agent_configuration.rs | 2 +- .../add_llm_provider_modal.rs | 2 +- .../configure_context_server_modal.rs | 3 +- crates/agent_ui/src/agent_diff.rs | 2 +- crates/agent_ui/src/agent_panel.rs | 2 +- crates/agent_ui/src/conversation_view.rs | 2 +- .../src/conversation_view/thread_view.rs | 1 + crates/agent_ui/src/inline_assistant.rs | 4 +- crates/agent_ui/src/message_editor.rs | 3 +- crates/agent_ui/src/model_selector.rs | 2 +- crates/agent_ui/src/thread_import.rs | 2 +- crates/agent_ui/src/thread_metadata_store.rs | 2 +- crates/agent_ui/src/threads_archive_view.rs | 3 +- crates/agent_ui/src/ui/mention_crease.rs | 3 +- crates/ai_onboarding/src/ai_onboarding.rs | 2 +- crates/auto_update/src/auto_update.rs | 4 +- crates/auto_update_ui/src/auto_update_ui.rs | 3 +- crates/call/src/call_impl/mod.rs | 2 +- crates/call/src/call_impl/room.rs | 2 +- crates/client/src/client.rs | 2 +- crates/client/src/llm_token.rs | 1 + crates/client/src/user.rs | 3 +- .../random_project_collaboration_tests.rs | 2 +- .../src/collab_panel/channel_modal.rs | 3 +- .../incoming_call_notification.rs | 2 +- .../project_shared_notification.rs | 2 +- crates/command_palette/src/command_palette.rs | 2 +- .../src/copilot_edit_prediction_delegate.rs | 2 +- crates/copilot_chat/src/copilot_chat.rs | 1 + crates/copilot_ui/src/sign_in.rs | 2 +- crates/debugger_tools/src/dap_log.rs | 3 +- crates/debugger_ui/src/attach_modal.rs | 2 +- crates/debugger_ui/src/debugger_panel.rs | 2 +- crates/debugger_ui/src/debugger_ui.rs | 2 +- crates/debugger_ui/src/new_process_modal.rs | 2 +- crates/debugger_ui/src/session/running.rs | 2 +- .../src/session/running/stack_frame_list.rs | 2 +- .../src/session/running/variable_list.rs | 5 +- crates/diagnostics/src/buffer_diagnostics.rs | 2 +- crates/edit_prediction/src/edit_prediction.rs | 1 + crates/edit_prediction/src/mercury.rs | 2 +- crates/edit_prediction/src/ollama.rs | 2 +- crates/edit_prediction/src/zeta.rs | 2 +- .../src/edit_prediction_context.rs | 4 +- .../src/edit_prediction_button.rs | 2 +- .../src/edit_prediction_ui.rs | 1 + crates/editor/src/clangd_ext.rs | 2 +- crates/editor/src/code_context_menus.rs | 4 +- crates/editor/src/code_lens.rs | 2 +- crates/editor/src/editor_tests.rs | 4 +- crates/editor/src/element.rs | 6 +- crates/editor/src/hover_popover.rs | 4 +- crates/editor/src/rust_analyzer_ext.rs | 2 +- crates/extension_host/src/extension_host.rs | 4 +- .../src/extension_store_test.rs | 2 +- crates/extensions_ui/src/extensions_ui.rs | 2 +- crates/file_finder/src/file_finder.rs | 2 +- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/git_panel.rs | 4 +- crates/git_ui/src/git_ui.rs | 2 +- crates/git_ui/src/stash_picker.rs | 2 +- crates/git_ui/src/worktree_picker.rs | 4 +- crates/git_ui/src/worktree_service.rs | 2 +- crates/gpui/src/executor.rs | 106 ++++-------------- crates/gpui/src/prelude.rs | 2 +- crates/inspector_ui/src/inspector.rs | 2 +- crates/journal/src/journal.rs | 2 +- .../language_models/src/provider/anthropic.rs | 2 +- .../language_models/src/provider/bedrock.rs | 3 +- crates/language_models/src/provider/cloud.rs | 2 +- .../language_models/src/provider/deepseek.rs | 2 +- crates/language_models/src/provider/google.rs | 2 +- .../language_models/src/provider/lmstudio.rs | 2 +- .../language_models/src/provider/mistral.rs | 2 +- crates/language_models/src/provider/ollama.rs | 2 +- .../language_models/src/provider/open_ai.rs | 2 +- .../src/provider/open_ai_compatible.rs | 2 +- .../src/provider/open_router.rs | 2 +- .../language_models/src/provider/opencode.rs | 2 +- .../src/provider/vercel_ai_gateway.rs | 2 +- crates/language_models/src/provider/x_ai.rs | 2 +- .../src/language_selector.rs | 2 +- crates/language_tools/src/lsp_button.rs | 2 +- crates/onboarding/src/basics_page.rs | 2 +- crates/outline_panel/src/outline_panel.rs | 2 +- crates/project/src/agent_registry_store.rs | 2 +- crates/project/src/agent_server_store.rs | 2 +- crates/project/src/buffer_store.rs | 3 +- crates/project/src/context_server_store.rs | 4 +- crates/project/src/debugger/dap_store.rs | 2 +- crates/project/src/debugger/session.rs | 2 +- crates/project/src/git_store.rs | 2 +- crates/project/src/lsp_command.rs | 2 +- crates/project/src/lsp_store.rs | 2 +- crates/project/src/lsp_store/log_store.rs | 4 +- crates/project/src/project.rs | 2 +- crates/project/src/worktree_store.rs | 2 +- crates/project_benchmarks/src/main.rs | 1 + crates/project_symbols/src/project_symbols.rs | 4 +- crates/recent_projects/src/recent_projects.rs | 2 +- crates/recent_projects/src/remote_servers.rs | 2 +- .../src/sidebar_recent_projects.rs | 2 +- crates/remote/src/remote_client.rs | 2 +- crates/remote_server/src/headless_project.rs | 2 +- crates/repl/src/notebook/notebook_ui.rs | 2 +- crates/repl/src/repl_sessions_ui.rs | 2 +- crates/repl/src/repl_store.rs | 4 +- crates/rules_library/src/rules_library.rs | 2 +- crates/search/src/buffer_search.rs | 2 +- crates/search/src/project_search.rs | 3 +- .../pages/edit_prediction_provider_setup.rs | 2 +- crates/sidebar/src/sidebar.rs | 4 +- crates/snippets_ui/src/snippets_ui.rs | 2 +- crates/tab_switcher/src/tab_switcher.rs | 2 +- crates/task/src/static_source.rs | 2 +- crates/tasks_ui/src/tasks_ui.rs | 2 +- crates/terminal_view/src/terminal_panel.rs | 4 +- .../src/terminal_path_like_target.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 4 +- crates/title_bar/src/collab.rs | 4 +- crates/title_bar/src/onboarding_banner.rs | 2 +- crates/title_bar/src/title_bar.rs | 2 +- crates/ui/src/components/context_menu.rs | 2 +- crates/vim/src/command.rs | 3 +- crates/vim/src/helix.rs | 2 +- crates/vim/src/normal.rs | 2 +- crates/vim/src/normal/mark.rs | 2 +- crates/vim/src/normal/search.rs | 2 +- crates/vim/src/state.rs | 3 +- crates/workspace/src/item.rs | 2 +- crates/workspace/src/multi_workspace.rs | 4 +- crates/workspace/src/pane.rs | 4 +- crates/workspace/src/persistence.rs | 1 + crates/workspace/src/tasks.rs | 2 +- crates/workspace/src/welcome.rs | 2 +- crates/workspace/src/workspace.rs | 4 +- crates/zed/src/main.rs | 4 +- crates/zed/src/reliability.rs | 2 +- crates/zed/src/zed.rs | 2 +- crates/zed/src/zed/open_listener.rs | 2 +- .../zed/src/zed/quick_action_bar/repl_menu.rs | 1 + crates/zed/src/zed/remote_debug.rs | 105 ++++++++--------- 143 files changed, 256 insertions(+), 291 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 1a7aaffb580..95b79a0cc15 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}; diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index da0704889e7..39b1302555b 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; 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_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..921d1347ffb 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -60,7 +60,7 @@ 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; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c01d8d8c04c..9dd97975a18 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, }; 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/message_editor.rs b/crates/agent_ui/src/message_editor.rs index d839e87d98e..16f69b297cf 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -22,7 +22,8 @@ use editor::{ 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; 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/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/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 82836928876..5da5526b3df 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}; 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..bc1dabefd28 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)] diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index a057a30c6d3..c1b15aa3b6c 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; 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/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..658c2b62064 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; 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/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_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/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..36327d7695c 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 _; 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/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/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 6c98e296ef4..e61cafa6adc 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, 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..7f9f1288470 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -10,7 +10,7 @@ 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, 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_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index d8e52fe8a7b..9f2b7a5f1fc 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; 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_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..c78620e25fd 100644 --- a/crates/editor/src/code_lens.rs +++ b/crates/editor/src/code_lens.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{MouseButton, SharedString, Task, WeakEntity}; +use gpui::{MouseButton, SharedString, Task, TaskExt, WeakEntity}; use itertools::Itertools; use language::{BufferId, ClientCommand}; use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _}; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 304f44d3c38..9649b638a3b 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::{ diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e751535910..22eaeca92e4 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::{ 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/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/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index ca43b4a3993..4ebee680621 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -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::{ 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/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/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 69829231619..64f1032ce59 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -8,7 +8,7 @@ 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, + SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; use project::git_store::{Repository, RepositoryEvent}; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 820c880a1bd..a9e558b1566 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -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}; 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/gpui/src/executor.rs b/crates/gpui/src/executor.rs index ab253472ad8..07f1667b620 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,9 +109,9 @@ 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) } } @@ -426,7 +360,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 +374,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/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/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_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..97eb5456e5d 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; 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..65eea3b696b 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, diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index f38321b7c88..d117bce3784 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, 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_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..63529ea0cf3 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, Entity, Subscription, Task, TaskExt, WeakEntity, actions}; use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; use project::{ 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..9e179c97a7d 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, }; 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..3d231a3e8ef 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; 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..1ea6d2c4188 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 _; 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 d5c6565b6cc..8f49e4c9183 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -47,7 +47,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, 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..85229cfdcde 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -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 _; 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..ac34cbdd061 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, 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_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_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/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 4b99ed37a38..a5fb5f60450 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -25,7 +25,7 @@ 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::{ 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/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/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/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 57b7c9b2cbb..0000aac3f36 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -23,8 +23,8 @@ use feature_flags::{ }; 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 menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, 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_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 642243ae147..4ad40b06e67 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}; 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..07c638c1604 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; 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/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/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 544a19167ac..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}; 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/state.rs b/crates/vim/src/state.rs index 3ca4d704c7c..0851604e1ab 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}; 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/tasks.rs b/crates/workspace/src/tasks.rs index 3ea35678865..2d68d7d2ab0 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; 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..bc675729f14 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::{ diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 8f417ee08ab..de49d220cd4 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -22,8 +22,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/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(); +} From dbbc25fc44eec2505c7216a43c8cea43d1a01e80 Mon Sep 17 00:00:00 2001 From: sunwukk990 <82385875+grgwuk990@users.noreply.github.com> Date: Tue, 5 May 2026 19:20:14 -0400 Subject: [PATCH 07/98] project: Fix cmd task quoting with venv activation (#55531) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #55022 ## Summary When a task explicitly uses `cmd.exe`, Zed prepares it as a shell invocation like `cmd.exe /S /C ""`. If Python virtual environment activation is enabled, `Project::create_terminal_task` wraps that prepared task in an outer activation shell so the venv can be activated before the task runs. Before this change, the activation formatter treated the prepared `/C` command string as a normal shell argument and quoted it again. On Windows, that can make cmd receive escaped quotes literally, producing errors like `'\\"echo Hi there\\"' is not recognized...`. This preserves the prepared cmd `/C` command string while building the activation command, and keeps the existing quoting path for ordinary task arguments. ## Verification - `cargo test -p project formats_prepared_cmd_task` - `cargo test -p project formats_non_cmd_task_for_activation` - `cargo check -p project` - `cargo fmt --check --package project` - Manually verified on Windows with a selected Python `.venv` that a `cmd.exe` task prints `Hi there` and finishes successfully. Release Notes: - Fixed Windows `cmd.exe` tasks failing when run with a selected Python virtual environment. --- crates/project/src/terminals.rs | 179 +++++++++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 16 deletions(-) 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'" + ); + } +} From 7b2acab0404684ad55da064b74a323e154231374 Mon Sep 17 00:00:00 2001 From: hayatosc <145091553+hayatosc@users.noreply.github.com> Date: Wed, 6 May 2026 09:06:51 +0900 Subject: [PATCH 08/98] Fix remote worktree path separators (#55486) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #54641 Release Notes: - Fixed creating git worktrees in WSL remote projects from Windows. ## Summary - Preserve the repository `PathStyle` when constructing git worktree paths. - Avoid using local OS path separators when creating worktrees for remote Posix projects such as WSL. - Keep worktree archive path checks aligned with the same path-style-aware worktree directory calculation. ## Root Cause Remote repository paths are stored as `PathBuf`s, but `PathBuf::join` and related path operations use the client OS separator. On Windows clients connected to WSL, this could turn a remote Linux path into a mixed path like `/home//\home\\dev\worktrees\...`, causing worktree creation and opening to fail. ## Validation - `cargo fmt --all --check` - `git diff --check` - `cargo test -p project test_new_worktree_path_uses_posix_style_for_remote_paths` - `cargo test -p project test_worktree_directory_uses_remote_path_style` - `cargo test -p project test_join_path_for_style_uses_remote_separator` --------- Co-authored-by: Max Brunsfeld --- .../agent_ui/src/thread_worktree_archive.rs | 12 ++- crates/project/src/git_store.rs | 55 +++++++++--- crates/project/tests/integration/git_store.rs | 42 ++++++--- crates/util/src/paths.rs | 90 +++++++++++++++++++ 4 files changed, 172 insertions(+), 27 deletions(-) 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/project/src/git_store.rs b/crates/project/src/git_store.rs index 8f49e4c9183..b39f24bb4ce 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -6824,10 +6824,15 @@ 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>> { @@ -7123,7 +7128,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 { @@ -8145,14 +8155,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!( @@ -8169,12 +8179,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 }; @@ -8659,7 +8676,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| { @@ -8668,6 +8685,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/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/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) } From 11f0ca5881ee8c32728244ee5ce3abedc851010f Mon Sep 17 00:00:00 2001 From: Jake Norris Date: Tue, 5 May 2026 21:49:36 -0400 Subject: [PATCH 09/98] Fix git graph file diff view opening wrong file if a previous one is already open (#55595) Fixed the portion of the open() function of the CommitView struct that checked to see if the commit view was already open in a tab. Previously, it did not account for files being filtered, and called pane.activate_item() when it found a matching commit SHA open. Now, the pane item is deleted and replaced with the new CommitView, respecting the position of the tab. This allows for the filtered files to be updated and work according to the expectations laid out in the mentioned issue. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #55446 Release Notes: - Fix git graph file diff view opening wrong file if a previous one is already open --------- Co-authored-by: Christopher Biscardi --- crates/git_ui/src/commit_view.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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); } From 923f315f262168ba3fbedc7d3a03ec62f2e230f6 Mon Sep 17 00:00:00 2001 From: b5l <35809732+b5l@users.noreply.github.com> Date: Wed, 6 May 2026 05:40:02 +0200 Subject: [PATCH 10/98] gpui_linux: Fix Wayland flickering under CPU load by skipping redundant surface commit (#54214) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #32792 Closes #38266 Closes #54133 Release Notes: - Fixed graphical corruption that could occur when using Wayland ---- **What**: Fixes flickering on Wayland (Sway/wlroots) under CPU load (e.g. rust-analyzer running). The bug only reproduces in release builds - debug builds are too slow to hit the race window. Environment where this was reproduced: Intel GPU (both Xe KMD and i915), Mesa 26.0.4, Sway 1.11. **Why**: When wgpu presents a frame on Wayland+Vulkan, it calls `vkQueuePresentKHR,` which - as required by the Vulkan spec - synchronously issues `wl_surface.attach`, `wl_surface.damage`, and `wl_surface.commit` to the compositor before returning. The commit also picks up any pending frame callbacks. Zed's `completed_frame()` independently calls `state.surface.commit()`. This is a redundant second commit on the same surface. Under load, the Wayland socket dispatch can be delayed enough that both commits are in flight in close succession, and the ordering becomes timing-dependent. When Zed's commit reaches the compositor before Mesa's attach+commit sequence has been fully flushed, the compositor sees a commit with no buffer attached, fires `wl_callback::Done` immediately, and Zed starts the next frame too early - Mesa's real buffer arrives late, causing the visible flicker. Under no load, Mesa's synchronous commit consistently reaches the compositor first, so the bug doesn't appear. **Fix**: Track whether `renderer.draw()` actually called `frame.present()`. When it did, Mesa owns the `wl_surface.commit()` for that frame - skip Zed's commit in `completed_frame()`. Only commit ourselves when wgpu didn't present (surface not configured, lost, occluded, etc.) - in those cases Mesa won't commit, and we need to keep the frame callback alive. --------- Co-authored-by: Benjamin Laib Co-authored-by: John Tur --- crates/gpui_linux/src/linux/wayland/window.rs | 15 ++++++++++++--- crates/gpui_wgpu/src/wgpu_renderer.rs | 18 +++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) 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_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; } } From fe1f7a60e4feedc7f568a0b94116d57d886f454c Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 6 May 2026 08:57:06 +0200 Subject: [PATCH 11/98] Skip Git tracking for invisible worktrees (#55760) Release Notes: - N/A or Added/Fixed/Improved ... --- crates/project/src/git_store.rs | 7 -- crates/worktree/src/worktree.rs | 110 +++++++++++++--------- crates/worktree/tests/integration/main.rs | 44 +++++++++ 3 files changed, 109 insertions(+), 52 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b39f24bb4ce..509d694885b 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1495,13 +1495,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(), 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, From f5945344cc21ebff07eb7ea04dab0555fbe49007 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 6 May 2026 09:35:40 +0200 Subject: [PATCH 12/98] gpui(windows): Fix unwrap panic when monitor goes missing (#55630) Fixes ZED-5K1 Release Notes: - Fixed a panic on windows when a monitor disappears from windows monitor enumeration --------- Co-authored-by: John Tur --- crates/gpui/src/platform.rs | 10 +- crates/gpui_linux/src/linux/wayland/client.rs | 6 +- .../gpui_linux/src/linux/wayland/display.rs | 2 +- crates/gpui_linux/src/linux/x11/client.rs | 2 +- crates/gpui_linux/src/linux/x11/display.rs | 2 +- crates/gpui_linux/src/linux/x11/window.rs | 4 +- crates/gpui_macos/src/display.rs | 2 +- crates/gpui_windows/src/display.rs | 108 +++--------------- crates/gpui_windows/src/events.rs | 8 +- crates/gpui_windows/src/window.rs | 9 +- 10 files changed, 37 insertions(+), 116 deletions(-) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index a00d158bc51..00cd9d13a47 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -326,22 +326,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_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 5edd9477b33..00b0508e19c 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -768,7 +768,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(), @@ -810,11 +810,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/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index bedd3c3e297..1e573a54bf5 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -1567,7 +1567,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 325d70eb311..0e402a7d63b 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -343,7 +343,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()) }) @@ -429,7 +429,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_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..a4c47789191 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) } diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 2fd7c3c6461..130d3dd7214 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -465,11 +465,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, From cc279a46fa5ee4e47e27c345a246834a21897c7a Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 6 May 2026 11:28:05 +0200 Subject: [PATCH 13/98] editor: Improve find_matches and replace_all perf (#51941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helps with https://github.com/zed-industries/zed/issues/38927 - **editor: Add a benchmark for find/replace** - **text: batch fragment insertions before turning them into a SumTree** ## Context ## How to Review ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved performance of "Replace All" in buffer search --------- Co-authored-by: Smit Barmase --- Cargo.lock | 18 ++ Cargo.toml | 1 + crates/editor/src/items.rs | 248 +++++++++++++++++++++--- crates/editor_benchmarks/Cargo.toml | 22 +++ crates/editor_benchmarks/LICENSE-GPL | 1 + crates/editor_benchmarks/src/main.rs | 180 +++++++++++++++++ crates/multi_buffer/src/multi_buffer.rs | 26 ++- crates/text/src/text.rs | 49 ++++- 8 files changed, 506 insertions(+), 39 deletions(-) create mode 100644 crates/editor_benchmarks/Cargo.toml create mode 120000 crates/editor_benchmarks/LICENSE-GPL create mode 100644 crates/editor_benchmarks/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index cea560d65a5..48e486af705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" 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/crates/editor/src/items.rs b/crates/editor/src/items.rs index 125f09c9661..c352ec9d03f 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}; @@ -1871,6 +1873,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 +1882,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 +2144,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 +2211,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_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/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/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>, From 9a679b3226b22749aa6a7e452d1e51b55d99899c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 6 May 2026 11:35:43 +0200 Subject: [PATCH 14/98] eval_cli: Set global filesystem in eval CLI init (#55862) Some dependency started requiring this, so fixing some runtime errors. Release Notes: - N/A --- crates/eval_cli/src/headless.rs | 1 + 1 file changed, 1 insertion(+) 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()); From 759f027f8aade180748604978f5a62e565145481 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 6 May 2026 11:36:43 +0200 Subject: [PATCH 15/98] acp: Show running agent version in configuration (#55824) Store the ACP agent version from agent_info and expose it through AgentConnection so the configuration UI can display it for connected agents. Helpful when debugging to know which version is currently running. image Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - acp: Show running agent version in the External Agent settings --- crates/acp_thread/src/connection.rs | 4 ++++ crates/agent_servers/src/acp.rs | 20 ++++++++++++++++--- crates/agent_ui/src/agent_configuration.rs | 12 +++++++---- crates/agent_ui/src/agent_connection_store.rs | 7 +++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index bbb967530e3..41ca3e4c6a6 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, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 93efddb03d8..cc467eb8cd0 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, diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 39b1302555b..67d21211026 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -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_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() From 7b5b0e4e9549c041406bae41a41bfb6b44ba568c Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Wed, 6 May 2026 03:56:20 -0700 Subject: [PATCH 16/98] fuzzy_nucleo: Refactor multi-atom code to use nucleo::Pattern (#55264) refactor of the fuzzy_nucleo string and path matching code, instead of handling the multiple atoms ourselves we can just use `nucleo::Pattern` and abstract that all away. this replaces the for loop in the path/string_match_helper functions. all functionality is exactly the same. basically the same / within some tiny margin of the original. this could enable the use of `nucleo::Pattern::parse` in the future if that was wanted, which allows some extra syntax to activate different matching modes. [more info from deepwiki](https://deepwiki.com/search/how-do-the-different-atom-matc_37e510de-af27-44a1-a52f-3fc367462e6e?mode=fast). I'm pretty sure that enabling that is as simple as switching a `Pattern::new(...)` call with `Pattern::parse(...)`. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/fuzzy_nucleo/src/fuzzy_nucleo.rs | 80 ++++++++++ crates/fuzzy_nucleo/src/paths.rs | 202 ++++++++---------------- crates/fuzzy_nucleo/src/strings.rs | 126 +++------------ 3 files changed, 168 insertions(+), 240 deletions(-) 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() { From b470b50d5282937f1afe4069f14128252740246c Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Wed, 6 May 2026 13:57:36 +0200 Subject: [PATCH 17/98] editor: Extract rewrap and config out of `editor.rs` (#55855) cc @SomeoneToIgnore ## Summary Follow-up to https://github.com/zed-industries/zed/discussions/55352, where the conclusion was to split `editor.rs` incrementally by topic instead of all at once. This mechanically extracts editor config and reflow-related code into `crates/editor/src/config.rs` and `crates/editor/src/rewrap.rs`, while preserving existing behavior and keeping externally-used APIs public where needed. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/config.rs | 352 +++++ crates/editor/src/{editor => }/diagnostics.rs | 0 crates/editor/src/editor.rs | 1132 +---------------- crates/editor/src/editor_tests.rs | 8 +- crates/editor/src/element.rs | 4 +- crates/editor/src/rewrap.rs | 782 ++++++++++++ crates/git_ui/src/git_panel.rs | 2 +- crates/vim/src/rewrap.rs | 6 +- 8 files changed, 1148 insertions(+), 1138 deletions(-) create mode 100644 crates/editor/src/config.rs rename crates/editor/src/{editor => }/diagnostics.rs (100%) create mode 100644 crates/editor/src/rewrap.rs 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/editor.rs b/crates/editor/src/editor.rs index 7d68b02d4e2..9b9330d8313 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -57,8 +57,9 @@ mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; -#[path = "editor/diagnostics.rs"] +mod config; mod diagnostics; +mod rewrap; pub(crate) use actions::*; use diagnostics::{ActiveDiagnostic, GlobalDiagnosticRenderer, InlineDiagnostic}; @@ -5166,7 +5167,7 @@ impl Editor { .line_len(MultiBufferRow(latest.start.row)) == latest.start.column { - this.rewrap_impl( + this.rewrap( RewrapOptions { override_language_settings: true, preserve_existing_whitespace: true, @@ -13856,410 +13857,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, @@ -21579,332 +21176,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,16 +22395,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()) @@ -26853,393 +26114,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; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 9649b638a3b..d71c3f4e4a2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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 22eaeca92e4..c872500a467 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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); 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/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a9e558b1566..0b6316c4adc 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2244,7 +2244,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, 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() From 5d55b93def55523c9394369ec1162defeeabb273 Mon Sep 17 00:00:00 2001 From: Kuroda Kayn Date: Wed, 6 May 2026 20:17:42 +0800 Subject: [PATCH 18/98] agent: Fix create-directory tool icon (#55549) Fixes #55436 The create-directory agent tool was classified as a read tool, which caused the agent UI to render it with the search icon. This PR marks `create_directory` as an edit tool and gives it a folder-add icon in the conversation tool card. Creating a directory mutates the project, so treating it as an edit tool better matches the behavior and avoids the misleading search icon. Validation: - `cargo build -p zed` - `cargo test -p agent test_create_directory_tool_kind_is_edit -- --nocapture` - `git diff --check` - Manually verified in a local dev build that asking the agent to create `tmp- zed-icon-test-dir` shows a folder icon instead of a search icon, and that the directory is created successfully. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/ zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed create-directory agent tool cards showing a search icon. 07547c9f-2cf2-432b-8c0f-86f336dce3c5 2b3f2b62-4782-42f1-a20d-c26f9cfe8940 --------- Co-authored-by: Ben Brandt --- crates/agent/src/tools/create_directory_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From 8dd9a5aa984c851a358d1eae96d84ea2ee5b43b6 Mon Sep 17 00:00:00 2001 From: Adrian Lumpe <26437714+alumpe@users.noreply.github.com> Date: Wed, 6 May 2026 14:19:29 +0200 Subject: [PATCH 19/98] Add OpenCode to provider autocomplete enum (#55586) The `opencode` built-in provider is registered in the language model registry but missing from the JSON schema enum in `LanguageModelProviderSetting`. image
OpenCode is one of the "first class" providers, so I was surprised it was missing from the auto-complete for the provider fields in the settings file. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Release Notes - N/A --- crates/settings_content/src/agent.rs | 1 + 1 file changed, 1 insertion(+) 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", From 86e64e2a55f50897c83ea34387f27989d9f30e5c Mon Sep 17 00:00:00 2001 From: GitGlimpse895 Date: Wed, 6 May 2026 17:49:33 +0530 Subject: [PATCH 20/98] agent_ui: Treat whitespace-only input as empty in MessageEditor (#55530) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #54276 Release Notes: - Fixed: the Send button in the Agent panel no longer activates when the message input contains only whitespace (spaces or tabs). --- crates/agent_ui/src/message_editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 16f69b297cf..66887019d31 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -677,7 +677,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 { From 0dd5427e0293b384f40bd4b1fdd0b41902dbbded Mon Sep 17 00:00:00 2001 From: Gabriel Linder Date: Wed, 6 May 2026 14:27:58 +0200 Subject: [PATCH 21/98] Cleanup and update mistral models based on their documentation (#55443) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - Mistral: Added Ministral 3 models and removed deprecated models --------- Signed-off-by: Gabriel Linder --- .../language_models/src/provider/mistral.rs | 2 +- crates/mistral/src/mistral.rs | 77 +++++++------------ 2 files changed, 29 insertions(+), 50 deletions(-) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 65eea3b696b..9776dfffbc8 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -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/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), From 65107c90b10d2719b4739277ed5c06612d3180a4 Mon Sep 17 00:00:00 2001 From: marius851000 Date: Wed, 6 May 2026 15:03:00 +0200 Subject: [PATCH 22/98] ollama: Fix thinking being also sent as content (#55540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "msg.string_contents();" return more than just content. It also return tool result (which need special handling, and should be emitted from the tool role) and thinking (which need already implemented special handling). This resulted in thinking being sent as content, which apparently ollama’s gemma4 parser didn’t handled well, and caused a number of issue that manifested by 1. outputting end of though token where unappropriate and 2. repeating things endlessly Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) (no UI/UX impact) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable (a quick review, without benchmark, show that this should be at least as fast as the previous code. This is called only exceptionally anyway.) Closes #55537 Release Notes: - Fixed "thinking" text being badly formatted when sent to Ollama --- crates/language_models/src/provider/ollama.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index d117bce3784..df9f8f383e0 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -381,11 +381,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 +405,7 @@ impl OllamaLanguageModel { } } messages.push(ChatMessage::Assistant { - content, + content: text_content, tool_calls: Some(tool_calls), images: if images.is_empty() { None From 3afcafe3fc0eb6d2aec28ec8d7900a455f873cc3 Mon Sep 17 00:00:00 2001 From: Prohect Date: Wed, 6 May 2026 14:26:09 +0100 Subject: [PATCH 23/98] agent: Return clear error when read_file tool path is a directory (#54303) Fixes #54244 When the `read_file` tool is called with a path that points to a directory instead of a file, it now returns a clear, actionable error message telling the agent to use `list_directory` instead. Previously the tool would fail with an unhelpful generic error. Now it explicitly checks whether the path is a directory before attempting to read it. A test covering this case is also included. Release Notes: - Fixed `read_file` tool returning an unhelpful error when given a directory path; it now suggests using `list_directory` instead. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/agent/src/tools/read_file_tool.rs | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) 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); From 29aad02404ae1c0049a2d79b841f7e0a7fdb9048 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 6 May 2026 09:30:07 -0400 Subject: [PATCH 24/98] Fix MCP server processes leaking as zombies (#54793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused MCP server child processes (e.g. `npm`/`node` for `mcp-remote`) to accumulate as zombie processes that were never cleaned up: **Bug 1: `stop_server()` only called `stop()` for `Running` servers** If a server completed initialization but was still in `Starting` state when `stop_server()` was called (a race between the init task completing and `maintain_servers` restarting), the client/transport/process were never released. The `Arc` was moved into a `Stopped` state with its inner client still holding the transport and child process handle. Fix: call `stop()` unconditionally in `stop_server()`. It is a safe no-op when the client has not been initialized (`None`). **Bug 2: `kill_on_drop` only killed the direct child, not the process tree** `StdioTransport` used a raw `smol::process::Child` with `kill_on_drop(true)`, which sends SIGKILL only to the direct child process (the shell/`npm` wrapper). The actual MCP server (e.g. `node mcp-remote`) runs as a grandchild and survives the kill, getting reparented to launchd. Fix: use `util::process::Child`, which already exists in the codebase for exactly this purpose. It calls `setsid()` via `pre_exec` to make the child a process group leader, and uses `killpg()` to terminate the entire process tree on kill. This requires passing a `std::process::Command` (via `build_std_command`) instead of a `smol::process::Command` (via `build_smol_command`), because that is what `util::process::Child::spawn` accepts — it needs to call `pre_exec` on the `std::process::Command` before internally converting it to `smol::process::Command` for async I/O. Release Notes: - Fixed zombie MCP server processes accumulating over time --- Cargo.lock | 1 - crates/context_server/Cargo.toml | 1 - .../src/transport/stdio_transport.rs | 23 +++++++++---------- crates/project/src/context_server_store.rs | 5 +--- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48e486af705..6133cdf67e8 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", 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/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/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 1ea6d2c4188..9effe4a4638 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -607,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( From 6787fb13ad10226dfbc582341b62a7fb19a1061b Mon Sep 17 00:00:00 2001 From: Adam Kocoloski Date: Wed, 6 May 2026 09:33:06 -0400 Subject: [PATCH 25/98] Avoid sending null "params" in MCP notification (#54807) MCP servers that strictly validate the schema will reject notification requests with params:null. Zed needs to either send an empty object or omit the key altogether: https://modelcontextprotocol.io/specification/2024-11-05/basic/messages#notifications This one-line patch omits the key, consistent with the behavior in Request messages. I tested it with a dev build and confirmed that our internal MCP server now accepts Zed's "notifications/initialized" request, and Zed is subsequently able to discover the tools provided by this server. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/context_server/src/client.rs | 1 + 1 file changed, 1 insertion(+) 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, } From 4e28b0a3fc6f7080de89919fef5ab3bd4c6813fa Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 6 May 2026 19:36:48 +0530 Subject: [PATCH 26/98] recent_projects: Allow bulk deleting entries without scroll jumping (#54777) Closes #52292 This PR preserves the scroll offset of the List used in Recent Projects while deleting items. It does that by: - Adding the `is_scrolled_to_end` method to the GPUI list, which helps us determine where the new selection should be, since it depends on whether items are taking the deleted slot from below or above. - Adding `ScrollBehavior` to `update_matches`, which lets you preserve the scroll offset even for `List` (not `UniformList`) after a reset. Before: https://github.com/user-attachments/assets/e3eb7092-59ec-4b54-b57a-503555addd27 After: https://github.com/user-attachments/assets/6929f6a0-04d7-44f9-a9b2-f9e5c077b368 Release Notes: - Fixed the recent projects list jumping to the top after deleting a project, so you can now bulk-delete entries by repeatedly clicking the delete icon or pressing the keybind. --- crates/gpui/src/elements/list.rs | 23 ++ crates/gpui/src/elements/uniform_list.rs | 14 +- crates/picker/src/picker.rs | 60 +++- crates/recent_projects/Cargo.toml | 1 + crates/recent_projects/src/recent_projects.rs | 313 +++++++++++++++++- 5 files changed, 388 insertions(+), 23 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index c7409687faf..558e89dd83e 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -271,6 +271,7 @@ struct ListItemSummary { unrendered_count: usize, height: Pixels, has_focus_handles: bool, + has_unknown_height: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -399,6 +400,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) { @@ -1385,6 +1405,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 +1415,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 +1432,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; } } 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/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/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 a5fb5f60450..a53d524885f 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -29,7 +29,7 @@ use gpui::{ }; 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); From 25a2d3ac237d0f9d0c2e4feac94dc09eca248119 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:45:32 +0000 Subject: [PATCH 27/98] Bump Zed to v1.3.0 (#55896) Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6133cdf67e8..f23f4064eae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22394,7 +22394,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.2.0" +version = "1.3.0" dependencies = [ "acp_thread", "acp_tools", 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 "] From 7dc02263e78055fa032541ed90e070853625b2ce Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 6 May 2026 18:03:49 +0300 Subject: [PATCH 28/98] ep: Don't check for special tokens on client (#55899) Closes #55692 Release Notes: - N/A Co-authored-by: Ben Kunkle --- crates/edit_prediction/src/zeta.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 7f9f1288470..33b347be175 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -24,8 +24,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}, }; @@ -120,10 +119,6 @@ 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); if let Some(debug_tx) = &debug_tx { From 00d8d45685673dae12c977e63e6502e3a95fa021 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 6 May 2026 12:14:25 -0300 Subject: [PATCH 29/98] workspace: Prompt to save dirty buffers when close would orphan them (#55889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hot-exit shortcut in `save_all_internal` silently serializes dirty buffers instead of prompting. It assumes the workspace will still be reachable, but that's not true for `ReplaceWindow`, or for `CloseWindow` of an empty workspace on macOS — both detach the workspace and orphan its serialized buffers in the DB with no UI path back to them. Only allow the shortcut when the workspace is actually recoverable (`Quit`, `save_last_workspace`, or has visible worktrees). Otherwise prompt. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #55726 Release Notes: - Fixed unsaved untitled buffers being silently lost when opening a file or project from an empty window --- crates/workspace/src/tasks.rs | 2 +- crates/workspace/src/workspace.rs | 218 +++++++++++++++++++++++++++--- 2 files changed, 201 insertions(+), 19 deletions(-) diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 2d68d7d2ab0..501fc583f2d 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -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/workspace.rs b/crates/workspace/src/workspace.rs index bc675729f14..9cc1fa30865 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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() { @@ -11473,7 +11500,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 +11537,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| { From 538151a55e01ccdac89c3e8bafacafb348606269 Mon Sep 17 00:00:00 2001 From: Lucas White Date: Wed, 6 May 2026 08:22:50 -0700 Subject: [PATCH 30/98] Rework GH Project status logic to reflect triage runbook (#55845) Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [ x] Unsafe blocks (if any) have justifying comments - [ n/a] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ n/a] Tests cover the new/changed behavior - [ n/a] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --- script/triage_project_sync.py | 60 ++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 15 deletions(-) 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: From cd2053e583893366013812b9e600dbe89670cd77 Mon Sep 17 00:00:00 2001 From: Jannis Vajen Date: Wed, 6 May 2026 17:32:26 +0200 Subject: [PATCH 31/98] helix: Add keybinding for tab switcher toggle (#55868) Add support for Helix' buffer picker that is opened via `space b` by default. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability Closes https://github.com/zed-industries/zed/issues/55867 --- assets/keymaps/vim.json | 1 + 1 file changed, 1 insertion(+) 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", From 70ee54da8fc7710316bb1ff7589dfc141e510f37 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 6 May 2026 17:47:18 +0200 Subject: [PATCH 32/98] agent: Add `write_file` tool (#55865) Splits the edit tool into two separate tools `write_file` (previously `mode = write`), and `edit_file` (previously `mode = edit`). This makes the JSON schema for the `edit_tool` much simpler. We've seen models (especially older ones) struggle with providing `mode = edit + edits` and `mode = write + content` fields. This seems to improve eval scores for Sonnet 4.6 slightly. Also added two unit evals to ensure that the model uses the tool to create new/override existing files Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- assets/settings/default.json | 1 + crates/agent/src/tests/mod.rs | 12 +- crates/agent/src/thread.rs | 9 +- crates/agent/src/tools.rs | 4 + crates/agent/src/tools/edit_file_tool.rs | 2271 ++--------------- crates/agent/src/tools/edit_session.rs | 1067 ++++++++ .../reindent.rs | 0 .../streaming_fuzzy_matcher.rs | 0 .../streaming_parser.rs | 2 +- crates/agent/src/tools/evals.rs | 2 + crates/agent/src/tools/evals/edit_file.rs | 62 +- crates/agent/src/tools/evals/write_file.rs | 561 ++++ crates/agent/src/tools/write_file_tool.rs | 1190 +++++++++ crates/settings_ui/src/pages.rs | 1 + .../src/pages/tool_permissions_setup.rs | 8 + 15 files changed, 3037 insertions(+), 2153 deletions(-) create mode 100644 crates/agent/src/tools/edit_session.rs rename crates/agent/src/tools/{edit_file_tool => edit_session}/reindent.rs (100%) rename crates/agent/src/tools/{edit_file_tool => edit_session}/streaming_fuzzy_matcher.rs (100%) rename crates/agent/src/tools/{edit_file_tool => edit_session}/streaming_parser.rs (99%) create mode 100644 crates/agent/src/tools/evals/write_file.rs create mode 100644 crates/agent/src/tools/write_file_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 624dcc0f012..64f97c451b0 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, diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 2a4e9c255fb..57cec0bc5d0 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -6062,9 +6062,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, @@ -6496,9 +6494,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 +6564,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..78a4b2fd488 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,7 +4,7 @@ use crate::{ FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template, - Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, + Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; @@ -822,6 +822,7 @@ 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 @@ -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(), diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 71ee0b2ba17..c52e0e4745e 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; @@ -27,6 +28,7 @@ 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}; @@ -85,6 +87,7 @@ 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),* $(,)?) => { @@ -179,4 +182,5 @@ tools! { TerminalTool, UpdatePlanTool, WebSearchTool, + WriteFileTool, } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 69f7be4662a..1061d5a5b7e 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,23 +731,21 @@ 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(); @@ -1855,7 +753,6 @@ mod tests { // Edit 2 appears — edit 1 is now complete and should be applied sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"} @@ -1877,7 +774,6 @@ mod tests { // Edit 3 appears — edit 2 is now complete and should be applied sender.send_partial(json!({ "path": "root/file.txt", - "mode": "edit", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"}, @@ -1899,7 +795,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 +811,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 +836,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 +861,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 +897,20 @@ 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", "new_text": "goodbye world"}] })); cx.run_until_parked(); @@ -2045,7 +934,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 +946,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 +959,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 +971,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 +983,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 +1003,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 +1026,7 @@ mod tests { } async fn test_resolve_path( - mode: &EditFileMode, + mode: &EditSessionMode, path: &str, cx: &mut TestAppContext, ) -> Result { @@ -2221,7 +1046,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 +1055,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 +1072,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 +1083,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 +1109,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 +1119,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 +1139,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 +1158,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 +1169,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 +1219,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 +1264,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 +1319,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 +1348,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 +1371,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 +1418,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 +1447,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 +1481,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 +1505,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 +1537,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 +1575,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 +1589,7 @@ mod tests { "src/main.rs" ); assert_eq!( - tool.initial_title( + edit_tool.initial_title( Err(json!({ "path": "", })), @@ -3043,77 +1598,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 +1632,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 +1654,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 +1676,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 +1729,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 +1761,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 +1811,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, @@ -3363,7 +1848,7 @@ mod tests { #[gpui::test] async fn test_streaming_dirty_buffer_detected(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(), @@ -3408,15 +1893,13 @@ mod tests { // Try to edit - should fail because buffer has unsaved changes 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, @@ -3457,16 +1940,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 +1959,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 +1970,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 +1984,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 +2004,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 +2011,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 +2018,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 +2030,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 +2057,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 +2067,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,116 +2094,28 @@ 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( - 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": "write" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "mode": "write", - "content": "new_content" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "mode": "write", - "content": "new_content", - "path": "root" - })); - cx.run_until_parked(); - - // Send final. - sender.send_full(json!({ - "mode": "write", - "content": "new_content", - "path": "root/file.txt" - })); - - 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_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": "edit" - })); - cx.run_until_parked(); - - sender.send_partial(json!({ - "mode": "edit", "edits": [{"old_text": "old_content"}] })); cx.run_until_parked(); sender.send_partial(json!({ - "mode": "edit", "edits": [{"old_text": "old_content", "new_text": "new_content"}] })); cx.run_until_parked(); sender.send_partial(json!({ - "mode": "edit", "edits": [{"old_text": "old_content", "new_text": "new_content"}], "path": "root" })); @@ -3940,7 +2123,6 @@ mod tests { // Send final. sender.send_full(json!({ - "mode": "edit", "edits": [{"old_text": "old_content", "new_text": "new_content"}], "path": "root/file.txt" })); @@ -3968,7 +2150,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 +2159,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 +2194,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 +2222,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 +2249,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 +2286,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..1be22a579a0 --- /dev/null +++ b/crates/agent/src/tools/edit_session.rs @@ -0,0 +1,1067 @@ +mod reindent; +mod streaming_fuzzy_matcher; +mod streaming_parser; + +use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; +use super::save_file_tool::SaveFileTool; +use crate::{AgentTool, Thread, ToolCallEventStream}; +use acp_thread::Diff; +use action_log::ActionLog; +use agent_client_protocol::schema::{ToolCallLocation, ToolCallUpdateFields}; +use anyhow::Result; +use collections::HashSet; +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 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, &context, 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); + + 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)); + }); +} + +fn ensure_buffer_saved( + buffer: &Entity, + abs_path: &PathBuf, + context: &EditSessionContext, + cx: &mut AsyncApp, +) -> Result { + let last_read_mtime = context + .action_log + .read_with(cx, |log, _| log.file_read_time(abs_path)); + let check_result = context.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: 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 99% 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..a976b08b004 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)] diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs index a2e09b3f8aa..30960689311 100644 --- a/crates/agent/src/tools/evals.rs +++ b/crates/agent/src/tools/evals.rs @@ -2,3 +2,5 @@ mod edit_file; #[cfg(all(test, feature = "unit-eval"))] mod terminal_tool; +#[cfg(all(test, feature = "unit-eval"))] +mod write_file; diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index cce9f41c6ef..4c96b0797f8 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| { @@ -1499,46 +1484,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/write_file.rs b/crates/agent/src/tools/evals/write_file.rs new file mode 100644 index 00000000000..f34528fcd78 --- /dev/null +++ b/crates/agent/src/tools/evals/write_file.rs @@ -0,0 +1,561 @@ +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::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<()> { + 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 = WriteToolTest::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: eval_utils::OutcomeKind::Passed, + metadata: (), + }, + Err(err) => eval_utils::EvalOutput { + data: format!("{err:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: (), + }, + } +} + +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/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs new file mode 100644 index 00000000000..c9cd548f316 --- /dev/null +++ b/crates/agent/src/tools/write_file_tool.rs @@ -0,0 +1,1190 @@ +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" + ); + } + + 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/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 401534b6605..63c3965d095 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -17,4 +17,5 @@ pub use tool_permissions_setup::{ 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_write_file_tool_config, }; diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 12693cb99d9..05cd51e3f92 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", @@ -303,6 +309,7 @@ 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, @@ -1383,6 +1390,7 @@ 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"); From 978fe7d7c48c3159256564dfb8be44aca6b4bc41 Mon Sep 17 00:00:00 2001 From: Jona Abdinghoff Date: Wed, 6 May 2026 17:47:59 +0200 Subject: [PATCH 33/98] editor: Support file:line:col navigation from hover links (#55877) I added navigation for file:line:col hover links e.g. `file.rs:83`, similar to what the cli and terminal already do. I also added backticks as file delimiters so that you can open file paths in markdown documents, see: https://github.com/user-attachments/assets/e31fca8e-6a22-4b5c-97c5-b8ddf8982e72 Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Btw the Zed docs don't mention this yet, but on native ARM64 Windows the build fails with `error: instruction requires: fullfp16` from the `gemm-f16` crate. I fixed this by adding `+fp16` to the target feature flags: ```toml [target.'cfg(target_os = "windows")'] rustflags = [ "--cfg", "windows_slim_errors", "-C", "target-feature=+crt-static,+fp16", ] ``` CI cross-compiles from x86_64 so it doesn't hit this. Closes https://github.com/zed-industries/zed/discussions/41123 Release Notes: - Added file:line:col navigation from ctrl+click hover links in the editor --- crates/editor/src/editor.rs | 49 +++- crates/editor/src/hover_links.rs | 413 +++++++++++++++++++++++++++++-- 2 files changed, 433 insertions(+), 29 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9b9330d8313..6dcc10b0ee2 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -17766,6 +17766,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() { @@ -17777,7 +17799,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]), @@ -18240,12 +18262,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(()) }) @@ -18276,8 +18300,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 } }) @@ -18411,18 +18435,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), 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, |_| {}); From e5d86ae5c5f5a21afdcaaa06f05ba4b254cc22df Mon Sep 17 00:00:00 2001 From: alkinun Date: Wed, 6 May 2026 19:03:20 +0300 Subject: [PATCH 34/98] Escape markdown special chars in file deletion confirmation dialog (#55697) ## Summary Filenames containing md syntax (e.g. `__somefile__`, `*somefile*`) were being rendered as markdown text in the file deletion confirmation dialog. Fixes #55651 ## Changes Wrapped file paths with `MarkdownEscaped` in the single and multi-file deletion confirmation dialogs in `project_panel.rs`, so special md chars like `_`, `*`, and `[` are escaped before being rendered. ## Testing Created a file named `__somefile__` and tried to delete it, the name now displays literally in the confirmation dialog instead of being rendered as bold text: img Also added `test_delete_prompt_escapes_markdown_in_file_name` in `project_panel_tests.rs` that verifies filenames with markdown special characters render literally in the confirmation dialog. Release Notes: - Fixed file names containing markdown special characters (e.g. `__somefile__`) being rendered as formatted text in the file deletion confirmation dialog. --- crates/project_panel/src/project_panel.rs | 16 ++++++--- .../project_panel/src/project_panel_tests.rs | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 1ae5f424845..4f9bc801d63 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() 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__`?" + ); +} From e0b43b3713ac4d82a224def68889bec833ef4813 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Wed, 6 May 2026 11:07:12 -0500 Subject: [PATCH 35/98] docs: Restructure nav and add Zed Business section (#51915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Restructures docs nav: new **Account & Billing**, **Zed Business**, and **Privacy & Security** sections, positioned above Platform Support - Moves Plans & Pricing and Billing out of AI > Subscription into Account & Billing - Moves Models and Providers to direct children of AI (removes Subscription wrapper) - Adds 4 new Zed Business pages: Overview, Organizations, Admin Controls, Business Support - Existing `roles.md` wired in as Roles & Permissions - Privacy & Security, Privacy for Business, and SOC2 remain as draft entries pending BIZOPS-983 - Adds redirect: `/ai/subscription.html` → `/docs/ai/plans-and-usage.html` ## Status Draft — active work in progress. Remaining issues on this branch: - BIZOPS-982 (due 3/21): Account & Billing content updates - BIZOPS-983 (due 3/23): Privacy & Security content - BIZOPS-984 (due 3/25): Final review and QA ## Notes for reviewers - Implementation-specific UI flows in the Business pages are stubbed with `` comments — these need to be filled in closer to launch once the dashboard is finalized Release Notes: - N/A --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Katie Geer Co-authored-by: Marshall Bowers --- docs/book.toml | 1 + docs/src/SUMMARY.md | 39 +++++++++------ docs/src/ai/billing.md | 68 +++++++++++++++++---------- docs/src/ai/plans-and-usage.md | 60 ++++++++++++++++------- docs/src/ai/privacy-and-security.md | 42 +++++++---------- docs/src/business/admin-controls.md | 43 +++++++++++++++++ docs/src/business/business-support.md | 14 ++++++ docs/src/business/organizations.md | 61 ++++++++++++++++++++++++ docs/src/business/overview.md | 41 ++++++++++++++++ docs/src/business/privacy.md | 54 +++++++++++++++++++++ docs/src/soc2.md | 10 ++++ docs/src/telemetry.md | 6 +++ 12 files changed, 359 insertions(+), 80 deletions(-) create mode 100644 docs/src/business/admin-controls.md create mode 100644 docs/src/business/business-support.md create mode 100644 docs/src/business/organizations.md create mode 100644 docs/src/business/overview.md create mode 100644 docs/src/business/privacy.md create mode 100644 docs/src/soc2.md 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/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/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/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/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/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. From c83f3fe581247abd32cabe0b3a6eb15540243b26 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 6 May 2026 19:11:45 +0300 Subject: [PATCH 36/98] ep: Compute kept rate even when there's no matches (#55912) Previously, we were skipping examples with zero overlap, inflating the aggregated metric Release Notes: - N/A Co-authored-by: Ben Kunkle --- .../src/prediction_score.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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)); + } +} From 8e4c329ee32ab651c9c5107d329d993b8d043795 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 6 May 2026 19:01:49 +0200 Subject: [PATCH 37/98] language: Reset pooled tree-sitter parsers after cancellation (#55866) It seems new cancellation behavior in tree-sitter caused at least one issue for a user. Attempting to proactively reset before any parser use to make sure things are clean. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/language/src/language.rs | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index ef495d462b9..8bfc4efb1ff 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -129,6 +129,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); @@ -1677,6 +1683,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) { From 1252513037a8a40ca510858bed2aa9b085b1e306 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 6 May 2026 20:03:01 +0300 Subject: [PATCH 38/98] ep: Drop dependency on language/gpui (#55917) Release Notes: - N/A Co-authored-by: Ben Kunkle --- Cargo.lock | 2 +- crates/edit_prediction_metrics/Cargo.toml | 2 +- .../edit_prediction_metrics/src/reversal.rs | 153 +++++++++++++++++- 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f23f4064eae..d9a42436e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5396,8 +5396,8 @@ dependencies = [ name = "edit_prediction_metrics" version = "0.1.0" dependencies = [ + "imara-diff", "indoc", - "language", "pretty_assertions", "serde", "serde_json", 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/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); From 177b2d5e9ea18674ceb09ef1544e656efda540f8 Mon Sep 17 00:00:00 2001 From: Charles Stevano <256171067+charlestevano@users.noreply.github.com> Date: Thu, 7 May 2026 00:53:29 +0700 Subject: [PATCH 39/98] docs: Add Windows command to grab extensions list (#55836) Currently, there is no method provided to grab installed extensions on Windows in Auto install extensions page. ```pwsh Get-ChildItem "$env:LOCALAPPDATA\Zed\extensions\installed" -Name ``` will return extension list in a simple format that look like this: {32ADE0F7-8EAF-4893-8E94-51DDFF6FF169} - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- docs/src/reference/all-settings.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index bc20f1cf57a..907676c77c8 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] From 5bc4243182a9aefdf675b8e7db7488024c02cd78 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 6 May 2026 19:55:54 +0200 Subject: [PATCH 40/98] extension_host: Make extension updates more resilient (#54355) Following our incident earlier, I noticed a report that mentioned their extension was uninstalled upon update which made them stumble across the issue for our outage. The reason for this was that currently, whenever we install extension updates, we _first_ removed the old directory and _then_ went ahead and downloaded the new version. This would in turn obviousy lead to issus where something malfunctioned during the update/install download - whether that is an issue on our side with the servers or an issue on the users side when they have a poor network connection or lose that. This PR changes this - we now do the entirety of the download on a background task, unpack the archive into a temporary directory if possible and then proceed with removing the old extension contents and moving the new contents only _after_ everything prior succeeded. I also moved this off of the main thread since there is no reason to do this there as well as the `Drop` impl of a `TempDir` doing some blocking work on the main thread otherwise. Release Notes: - Made extension updates more resilient to network and upstream failures --- crates/extension_host/src/extension_host.rs | 92 +++++++++++++-------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 4ebee680621..8a15148c8f4 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 _, @@ -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; From dacf984596b412db98dbd40dbf9db1a544161fc3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 6 May 2026 14:15:36 -0400 Subject: [PATCH 41/98] collab: Introduce `UserService` (#55449) This PR introduces a `UserService` trait to Collab. This is a step towards moving Collab away from reading user information directly from the database. We currently have two implementations for the trait: - The `DatabaseUserService`, which leverages the existing query methods to talk to the database - The `FakeUserService`, which will be used in tests Once we're ready, we'll be able to replace the `DatabaseUserService` with a `CloudUserService` to fetch the users from Cloud. Release Notes: - N/A --- .../20221109000000_test_schema.sql | 24 +- crates/collab/src/db/queries/channels.rs | 20 ++ crates/collab/src/db/tables/user.rs | 2 + crates/collab/src/entities/user.rs | 2 + crates/collab/src/lib.rs | 5 + crates/collab/src/rpc.rs | 43 +++- crates/collab/src/services.rs | 3 + crates/collab/src/services/user_service.rs | 243 ++++++++++++++++++ .../tests/integration/channel_guest_tests.rs | 2 +- .../collab/tests/integration/test_server.rs | 24 +- 10 files changed, 334 insertions(+), 34 deletions(-) create mode 100644 crates/collab/src/services.rs create mode 100644 crates/collab/src/services/user_service.rs 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/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..91259b4ce40 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,8 @@ use serde::Deserialize; use std::{path::PathBuf, sync::Arc}; use util::ResultExt; +use crate::services::{DatabaseUserService, UserService}; + pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA"); @@ -216,6 +219,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, } @@ -259,6 +263,7 @@ impl AppState { } else { None }, + user_service: Arc::new(DatabaseUserService::new(db)), 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..e2696e99ff2 --- /dev/null +++ b/crates/collab/src/services/user_service.rs @@ -0,0 +1,243 @@ +use std::sync::Arc; + +use async_trait::async_trait; +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 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/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/test_server.rs b/crates/collab/tests/integration/test_server.rs index 32f0e29c6dc..820bcbd3376 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,6 +581,7 @@ 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(), From cafa1fcc48b2c45a1901bcb7f30b2a4a2b59172d Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 6 May 2026 20:50:53 +0200 Subject: [PATCH 42/98] ci: Ensure generated workflows do not become stale (#55198) This ensures that upon workflow removal in Rust code, the corresponding workflow file will not accidentally stay in the repository. Release Notes: - N/A --- tooling/xtask/src/tasks/workflows.rs | 33 +++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) 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), From 3767d69d6470f028411cfb75d25adbe5548ff9ca Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 6 May 2026 20:52:31 +0200 Subject: [PATCH 43/98] extension_builder: Use `cfg_select` for WASI SDK asset name (#55660) Non-functional change that just makes use of our most recent version bump and helps with readability IMO. Also helps with showing that we are in fact missing dev extension installation support for Windows ARM at the moment. Release Notes: - N/A --- crates/extension/src/extension_builder.rs | 26 ++++++++--------------- 1 file changed, 9 insertions(+), 17 deletions(-) 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 { From 7ba7b4b981b5dec2e0d3beeb7076ea3256a4a99b Mon Sep 17 00:00:00 2001 From: Josh Robson Chase Date: Wed, 6 May 2026 15:29:09 -0400 Subject: [PATCH 44/98] fs: Pre-filter the event kind before acquiring global lock and cloning callback handlers (#55683) In #54481, the `handle_event` function was altered to check for an `Access` event after the callbacks were acquired via a global state lock. The lock/Vec-collect has enough overhead (or maybe there's enough lock contention?) that the handler isn't performant enough to keep up with the volume of inotify events, and its queue fills up, resulting in [a rescan event getting emitted](https://github.com/notify-rs/notify/blob/79007aefb41d9f853d00656eb768600e3ea41ee0/notify/src/inotify.rs#L304-L306), which presumably results in *more* access events for the file as it's rescanned, which further serve to fill up the inotify queue. Moving the check for an `Access` event and returning before doing anything remotely expensive seems to resolve the issues I've been having lately. Not sure if it addresses the original issue in #53480 though. Longer-term, it might be prudent to do the event handler's heavy-lifting in a separate thread with its own event queue, and let the handler passed to the `notify` crate be just a dumb `tx` sender. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Related to #53480 Fixes #55829 Release Notes: - Fixed inotify event queue overflows on linux --- crates/fs/src/fs_watcher.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index 6db36992dec..f99d5d0b70e 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}, @@ -458,6 +458,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,9 +482,6 @@ 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)); } From 0e479e0e312c2513f75eb8dfc77bf3bb72ee2c91 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 6 May 2026 15:52:34 -0400 Subject: [PATCH 45/98] Derive data directory names from APP_NAME constant (#55805) Replace all hardcoded `"Zed"` and `"zed"` directory names in `config_dir()`, `data_dir()`, `state_dir()`, and `temp_dir()` with a single `APP_NAME` constant in the `paths` crate. - On macOS/Windows (native paths like Application Support, LocalAppData, Caches), `APP_NAME` is used directly (`"Zed"`). - On Linux/FreeBSD (XDG-style paths), `app_name_lowercase()` is used (`"zed"`). This ensures forks that change `APP_NAME` automatically get their own data directories, preventing them from stomping on Zed users' databases and config. Release Notes: - N/A --- crates/paths/src/paths.rs | 69 ++++++++++++++++++++++++++++++--------- crates/zed/src/main.rs | 10 ++++++ 2 files changed, 63 insertions(+), 16 deletions(-) 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/zed/src/main.rs b/crates/zed/src/main.rs index de49d220cd4..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; From a4005b6d99aaa96458ec7cafd786e6e747a56b5a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 6 May 2026 16:53:55 -0300 Subject: [PATCH 46/98] acp: Fix npm version spec breaking on Windows (#55938) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable #55770 changed the npm package version spec to `package@<=1.2.3`. On Windows this fails with `The system cannot find the file specified.` because: - `npm` resolves to `npm.cmd`, a batch file. Windows runs `.cmd` files via cmd.exe, which parses the invocation and treats unquoted `<` as input redirection. - The single quotes our shell builder emits around args 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 `npm.cmd` bare and cmd.exe fails before the batch body even runs. Switch to npm's hyphen-range syntax (`0.0.0 - `, equivalent to `<=`), which has no `<`. Closes #55921 Release Notes: - Fixed ACP agents failing to launch on Windows with "The system cannot find the file specified" --- crates/project/src/agent_server_store.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 3d231a3e8ef..e0e044e5638 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -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"), From dfd8328f7b87a959c46d51bf964a58158a5019ab Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 6 May 2026 16:05:53 -0400 Subject: [PATCH 47/98] gpui: Fix material list unstable scrollbar position (#55808) Before this PR, `ListState` always used a proportional pending scroll fraction to preserve the relative scroll position when list items were resized. This caused text to creep in the agent panel when the scrollbar was in the middle of the list while the agent was generating text, because streaming updates changed the logical scroll top slightly. This PR fixes that behavior by using an absolute offset when only a subset of items is remeasured, added, or deleted, keeping the logical top position stable. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --------- Co-authored-by: Remco Smits --- crates/gpui/src/elements/list.rs | 158 ++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 26 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 558e89dd83e..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 { @@ -336,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 @@ -347,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()) } - } + }; } } @@ -894,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); + } + _ => {} } } } @@ -1669,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(); From 0de588c0c9a2582ca9029023534d34850e3dc6f4 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 7 May 2026 01:38:42 +0530 Subject: [PATCH 48/98] git_ui: Add force delete for unmerged branches (#55927) Git's `-d` flag deletes a branch only if it's fully merged into its upstream or HEAD - this is what we were using before, which caused the "not fully merged" error. The `-D` flag force deletes a branch even with unmerged changes (equivalent to `--delete --force`). ### Before Deleting an unmerged branch failed with a "not fully merged" error toast. ### After - Deleting an unmerged branch prompts for confirmation to force delete - Delete button tooltip shows "Hold alt to force delete" hint - Holding **alt** turns the delete icon red and tooltip changes to "Force Delete Branch" - Force delete keybinding: `cmd-alt-shift-backspace` Release Notes: - Added confirmation prompt when deleting unmerged git branches, with option to force delete. - Added alt+click on delete button to force delete a branch immediately. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/fs/src/fake_git_repo.rs | 15 +- crates/git/src/repository.rs | 28 +- crates/git_ui/src/branch_picker.rs | 514 ++++++++++++++++++++++++---- crates/git_ui/src/git_picker.rs | 16 +- crates/project/src/git_store.rs | 22 +- crates/proto/proto/git.proto | 1 + 9 files changed, 521 insertions(+), 78 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 66f527ef024..9c49646b5a7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1499,6 +1499,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..d0ac2c22e03 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1552,6 +1552,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..66195c604fe 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1479,6 +1479,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/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/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_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 64f1032ce59..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, TaskExt, 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/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/project/src/git_store.rs b/crates/project/src/git_store.rs index 509d694885b..61cca22ff77 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -37,7 +37,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::{ @@ -2981,10 +2981,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??; @@ -7367,21 +7368,19 @@ 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(), - ), + 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 @@ -7390,6 +7389,7 @@ impl Repository { repository_id: id.to_proto(), is_remote, branch_name, + force, }) .await?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index bf7bbeb4359..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 { From 4339b65ce61955c3c0675cd90e82010891d9bd9a Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 6 May 2026 17:54:27 -0300 Subject: [PATCH 49/98] Fix crash when following into a multibuffer with recent edits (#55948) A follower could crash when following another collaborator into a newly-opened multibuffer if the leader's recent edits hadn't yet propagated. The follower would receive the view state with excerpt anchors pointing into still-unobserved edits, tripping `panic_bad_anchor`. The fix waits for each buffer to observe the anchors timestamps before resolving them, matching what's already done for selection and scroll anchors. Includes a regression test in `collab` that reproduces the race deterministically. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a crash in follow mode when opening multibuffers --- .../tests/integration/following_tests.rs | 103 ++++++++++++++++++ crates/editor/src/items.rs | 95 +++++++++++----- 2 files changed, 168 insertions(+), 30 deletions(-) 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/editor/src/items.rs b/crates/editor/src/items.rs index c352ec9d03f..9434da98973 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -101,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; @@ -108,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(), @@ -402,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; }; @@ -539,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> { From fb3218e01e22d5dcc2791fd6b94d22cf37d8e42f Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Wed, 6 May 2026 23:18:29 +0200 Subject: [PATCH 50/98] theme: Expose editor diff hunk colors (#51784) This PR makes editor diff hunk colors configurable from themes, instead of hardcoded values. It introduces 6 new optional theme keys: - `editor.diff_hunk.added.background` - `editor.diff_hunk.added.hollow_background` - `editor.diff_hunk.added.hollow_border` - `editor.diff_hunk.deleted.background` - `editor.diff_hunk.deleted.hollow_background` - `editor.diff_hunk.deleted.hollow_border` When a theme omits these keys, each color falls back to the existing version-control color with the previous hardcoded opacity values: - Light defaults: - background_opacity = 0.16 - hollow_background_opacity = 0.08 - hollow_border_opacity = 0.48 - Dark defaults: - background_opacity = 0.12 - hollow_background_opacity = 0.06 - hollow_border_opacity = 0.36 There is an existing feature request https://github.com/zed-industries/zed/discussions/51667 ## Screenshots I used `Modus Themes` (Modus Vivendi) since these themes provide highly accessible themes. Original version: CleanShot 2026-03-17 at 20 26
41@2x This version: CleanShot 2026-03-17 at 20 23
09@2x Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Added theme keys for configuring editor diff hunk colors. --------- Co-authored-by: MrSubidubi --- crates/editor/src/element.rs | 51 +++-- crates/settings_content/src/theme.rs | 24 ++ crates/theme/src/default_colors.rs | 12 + crates/theme/src/fallback_themes.rs | 6 + crates/theme/src/styles/colors.rs | 12 + crates/theme_settings/src/schema.rs | 241 ++++++++++++++++++-- crates/theme_settings/src/settings.rs | 6 +- crates/theme_settings/src/theme_settings.rs | 7 +- 8 files changed, 318 insertions(+), 41 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index c872500a467..fe3ec5cb462 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -10083,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| { @@ -10124,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, @@ -10184,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/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/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); From 5dd9082d05e4c9eb13623c1281fb7c4b0071b3a3 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Thu, 7 May 2026 06:38:44 +0800 Subject: [PATCH 51/98] bash: Add built-in language server support (#52811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces native LSP support for Bash by integrating `bash-language-server`. Combined with the existing Tree-sitter grammar, Zed now provides a complete, out-of-the-box development experience for shell scripting. The implementation is very similar to other npm-managed language servers. With `shellcheck` installed, standard LSP features—including diagnostics, code actions, go-to-definition, find-references, and code completion—work as expected. Since I am not a frequent user of Bash, I have intentionally limited this implementation to a standard, "out-of-the-box" setup. I lack the hands-on experience to identify specific pain points or advanced LSP features that might require custom integration, so I've avoided adding any speculative or specialized configurations, especially within the `LspAdapter` trait. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #51917 Release Notes: - Added built-in language server support for Bash --------- Co-authored-by: Finn Evers --- crates/extension_host/src/extension_host.rs | 2 +- crates/languages/src/bash.rs | 149 ++++++++++++++++++++ crates/languages/src/lib.rs | 2 + docs/src/languages.md | 2 +- docs/src/languages/bash.md | 4 +- 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 8a15148c8f4..a59f93610d5 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -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 { diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index a947eefd13d..a002968fa40 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::{path::PathBuf, vec}; use task::{TaskTemplate, TaskTemplates, VariableName}; +use util::{ResultExt, maybe}; pub(super) fn bash_task_context() -> ContextProviderWithTasks { ContextProviderWithTasks::new(TaskTemplates(vec![ @@ -17,6 +26,146 @@ 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()], + }) + } + + async fn check_if_version_installed( + &self, + version: &Self::BinaryVersion, + container_dir: &PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_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 { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: self.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 + } + + async fn fetch_server_binary( + &self, + latest_version: Self::BinaryVersion, + container_dir: std::path::PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let server_path = container_dir + .join("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + + self.node + .npm_install_packages( + &container_dir, + &[(Self::PACKAGE_NAME, &latest_version.to_string())], + ) + .await?; + + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: self.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/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/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`: From 0bcf71f786ccf2194901ce1542fef7d9b4e2e82e Mon Sep 17 00:00:00 2001 From: Juan Pablo Briones Date: Thu, 7 May 2026 00:25:42 -0400 Subject: [PATCH 52/98] vim: Add C preprocessor check in matching function (#55515) Closes #24820 This PR fixes the bug specified in issue https://github.com/zed-industries/zed/issues/24820, now the matching function checks if the cursor is above a comment or a directive before defaulting to a bracket range as neovim does. It also fixes fixes the `line_end` calculations so that when `%` is pressed inside a bracket range https://github.com/user-attachments/assets/f59daa6f-9769-45e8-bb8c-2d533470b59d Release Notes: - `fn matching()` checks for `preprocessor directives` or `comments` before defaulting to any bracket range. - In `fn matching()`line_end calculations avoid expanding a blank current line into start..EOF. --- crates/vim/src/motion.rs | 137 +++++++++++------- .../vim/test_data/test_matching_comments.json | 3 + ...test_matching_preprocessor_directives.json | 14 +- 3 files changed, 96 insertions(+), 58 deletions(-) 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/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"}} From 6aa90e750ded06c684393ab4cccf159c731f58b6 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Thu, 7 May 2026 14:55:11 +0800 Subject: [PATCH 53/98] docs: Update actions format (#54869) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Change the actions in docs to adopt the right format. Release Notes: - N/A --- docs/.conventions/CONVENTIONS.md | 4 ++-- docs/.doc-examples/configuration.md | 4 ++-- docs/AGENTS.md | 8 +++---- docs/README.md | 4 ++-- docs/src/ai/agent-panel.md | 4 ++-- docs/src/ai/agent-settings.md | 2 +- docs/src/ai/external-agents.md | 14 ++++++------ docs/src/ai/llm-providers.md | 32 ++++++++++++++-------------- docs/src/ai/mcp.md | 4 ++-- docs/src/appearance.md | 4 ++-- docs/src/authentication.md | 4 ++-- docs/src/collaboration/channels.md | 2 +- docs/src/command-palette.md | 2 +- docs/src/configuring-languages.md | 18 ++++++++-------- docs/src/configuring-zed.md | 4 ++-- docs/src/development/glossary.md | 2 +- docs/src/development/linux.md | 2 +- docs/src/extensions/agent-servers.md | 2 +- docs/src/icon-themes.md | 4 ++-- docs/src/key-bindings.md | 8 +++---- docs/src/languages/c.md | 2 +- docs/src/languages/cpp.md | 2 +- docs/src/languages/rust.md | 2 +- docs/src/linux.md | 2 +- docs/src/macos.md | 6 +++--- docs/src/migrate/intellij.md | 6 +++--- docs/src/migrate/pycharm.md | 6 +++--- docs/src/migrate/rustrover.md | 6 +++--- docs/src/migrate/vs-code.md | 10 ++++----- docs/src/migrate/webstorm.md | 6 +++--- docs/src/multibuffers.md | 14 ++++++------ docs/src/outline-panel.md | 4 ++-- docs/src/reference/all-settings.md | 6 +++--- docs/src/reference/cli.md | 2 +- docs/src/repl.md | 12 +++++------ docs/src/semantic-tokens.md | 12 +++++------ docs/src/tasks.md | 20 ++++++++--------- docs/src/terminal.md | 4 ++-- docs/src/themes.md | 4 ++-- docs/src/update.md | 2 +- docs/src/vim.md | 2 +- docs/src/worktree-trust.md | 2 +- 42 files changed, 130 insertions(+), 130 deletions(-) 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/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/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..e1b5a50779f 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] @@ -179,7 +179,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 +232,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 +275,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 +289,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 +353,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. @@ -502,7 +502,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 +512,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 +570,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 +626,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 +693,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 +812,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 +836,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/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/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/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 907676c77c8..6591a47b353 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3153,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` @@ -5565,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: `{}` @@ -5607,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/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/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. From 0a52f80824a2f1e9b48dbdc615d63453eaaaf1f9 Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 7 May 2026 05:17:16 -0300 Subject: [PATCH 54/98] acp_thread: Clear running_turn when prompt task drops tx (#55562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AcpThread::status` is purely `running_turn.is_some()`. The cleanup that takes `running_turn` sat below the early-return guard that fires when the prompt response oneshot resolves to `Err(Cancelled)` (the inner `send_task` was dropped before `tx.send`). Any code path that drops the in-flight `send_task` therefore left the panel stuck in `Generating`. Reordered so cleanup runs before the dropped-tx guard; the same-turn invariant is preserved. Related to #47928 (partial — that issue also has an upstream `claude-agent-acp` component this PR does not address). Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed agent panel staying in a generating state when the underlying prompt task was cancelled before completing --- crates/acp_thread/src/acp_thread.rs | 72 +++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2c448d34307..769131a8e0d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2294,10 +2294,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 +2302,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 +5520,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" + ); + } } From 7fcc4ba3438e3ea8a2d1c436090a88fc8c853b6c Mon Sep 17 00:00:00 2001 From: YangChengxxyy <45156288+YangChengxxyy@users.noreply.github.com> Date: Thu, 7 May 2026 16:58:17 +0800 Subject: [PATCH 55/98] =?UTF-8?q?eslint:=20Fix=20`workspaceFolder.uri`=20s?= =?UTF-8?q?ent=20as=20raw=20path=20instead=20of=20`file:/=E2=80=A6=20(#543?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …/` URI The ESLint adapter was sending `workspaceFolder.uri` as a raw filesystem path (e.g. `/Users/foo/project`) instead of a proper `file://` URI (e.g. `file:///Users/foo/project`). This caused the vscode-eslint server's `workingDirectory: { mode: "auto" }` to fail when resolving the workspace root, falling back to the linted file's directory as the working directory. As a result, `eslint-import-resolver-typescript` could not locate `tsconfig.json`, breaking path alias resolution for rules like `import/order` — producing different lint results compared to VS Code. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --- crates/languages/src/eslint.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 7ef55c64ef1..e9b94380191 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -254,7 +254,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(), From 42017bcad2631b6bafef52942899b4989d2c0c72 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 10:59:28 +0200 Subject: [PATCH 56/98] agent: Handle out of order old_text/new_text in edit file tool (#55894) In the case where the model would respond with `new_text` before `old_text`, we would just emit an empty `old_text`, because the parsing layer was operating under the assumption that `old_text` occurs before `new_text`. We now hold back new text chunks if we receive them first, and only emit them once old_text is complete. In addition to that we also need to handle the case where the first chunk contains `old_text` and `new_text`. In that case we don't know which one of the two fields have finished streaming, since we can't rely on the ordering anymore. Therefore we hold back all events until we receive the full edit, and emit a single OldTextChunk (done = true) and a single NewTextChunk (done = true) Closes #55398 Release Notes: - agent: Fixed an issue where editing would sometimes fail for specific models (Deepseek v4) --- crates/agent/src/tools/edit_file_tool.rs | 118 +++++++++ .../tools/edit_session/streaming_parser.rs | 246 +++++++++++++----- 2 files changed, 305 insertions(+), 59 deletions(-) diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 1061d5a5b7e..31eb788dfa3 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -753,6 +753,15 @@ mod tests { // 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", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"} @@ -774,6 +783,16 @@ mod tests { // 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", "edits": [ {"old_text": "aaa", "new_text": "AAA"}, {"old_text": "ccc", "new_text": "CCC"}, @@ -909,6 +928,12 @@ mod tests { })); cx.run_until_parked(); + sender.send_partial(json!({ + "path": "root/file.txt", + "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"}] @@ -2135,6 +2160,99 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_file_tool_new_and_old_text_appear_together( + 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", "old_text": "old"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "mode": "edit", + "path": "root/file.txt", + "edits": [{"new_text": "new_content", "old_text": "old_content"}] + })); + 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"); + }; + 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"); + }; + assert_eq!(new_text, "new_content"); + } + #[gpui::test] async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { let file_content = indoc::indoc! {r#" diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index a976b08b004..3961edf564c 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -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()); } From b3a67c988f9d28a009086952d46fc8c977ad028c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 11:31:37 +0200 Subject: [PATCH 57/98] markdown_preview: Implement reload (#56016) If you implement can_save, you need to also support reload. Fix a bug introduced in #53236 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed missing reload implementation for markdown preview. --- .../src/markdown_preview_view.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) 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); From c6e9a95eba5a8635f9a237ce5159d2433f4e1e5f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 11:56:04 +0200 Subject: [PATCH 58/98] x_ai: Update models list (#55931) Updates the list of models being available based on https://docs.x.ai/developers/models From the xAI site: image Closes #55883 Release Notes: - agent: Added support for grok-4.3, grok-4.2 and removed deprecated xAI models --- crates/x_ai/src/x_ai.rs | 147 +++++++--------------------------------- 1 file changed, 26 insertions(+), 121 deletions(-) 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, } } } From 7d19e899889fc2e4a7999e9ce77a4bd438e3dc3b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 12:22:52 +0200 Subject: [PATCH 59/98] Fix DirectX atlas panic after GPU device recovery (#55878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A Sentry-reported crash on Windows (Intel Iris Xe Graphics, v1.0.1): ``` index out of bounds: the len is 1 but the index is 1 ``` panicking at `DirectXAtlasState::texture` in [`crates/gpui_windows/src/directx_atlas.rs`](https://github.com/zed-industries/zed/blob/main/crates/gpui_windows/src/directx_atlas.rs): ```rust AtlasTextureKind::Subpixel => { &self.subpixel_textures[id.index as usize].as_ref().unwrap() } ``` ## Root cause After a GPU device-lost recovery, GPUI's view cache replays stale `AtlasTile` references from the previous frame's `paint_operations` via `Scene::replay`. 1. **Atlas grows past one texture.** A long enough session pushes `subpixel_textures.textures.len() ≥ 2` (easy on Iris Xe at the default 1024×1024 atlas size). Top-level views in Zed use `cached(...)`, so their `AnyViewState.paint_range` records into `rendered_frame.scene.paint_operations`, referencing both index `0` and index `1`. 2. **Device lost.** `handle_device_lost` clears every `AtlasTextureList` (`textures.len() == 0`) and `tiles_by_key`, then sets `skip_draws = true`. 3. **`WM_GPUI_FORCE_UPDATE_WINDOW` arrives.** `mark_drawable()` flips `skip_draws` back to `false` and `request_frame` runs with `force_render: true`. 4. **The cache hit.** Inside `Window::draw`, `AnyView::prepaint`'s cache check (`!dirty_views.contains(...) && !window.refreshing`) succeeds for every cached view because the recovery doesn't touch invalidator state and `force_render` doesn't propagate into `Window`. `AnyView::paint` calls `window.reuse_paint` → `Scene::replay` → `primitive.clone()`, which (since `SubpixelSprite`/`AtlasTile` are `Copy`) verbatim copies a `Primitive::SubpixelSprite { tile: { texture_id: { index: 1, ... }, ... } }` into `next_frame.scene`. 5. **Atlas regrows to one.** Dirty/uncached parts of the same frame (caret, animations, anything that called `cx.notify`) fall through to `paint_glyph` → `get_or_insert_with` → `push_texture`, growing `subpixel_textures.textures` from `0` to **`1`** with index `0` valid. 6. **Panic.** After `mem::swap`, `rendered_frame.scene` contains a mix of fresh `index = 0` and replayed `index = 1` sprites. `Scene::batches` emits separate batches per `texture_id`; the `index = 1` batch reaches `atlas.get_texture_view` → `subpixel_textures[1]` → panic with `len = 1, index = 1`. The two earlier related fixes do not catch this: - **#52389 / dbd95ea7** (`if force_render { mark_drawable }`) protects the 200 ms recovery sleep — pending `WM_PAINT`s carry `force_render = false` and so do not clear `skip_draws`. But `WM_GPUI_FORCE_UPDATE_WINDOW` carries `force_render = true`, so `mark_drawable` runs, then `Window::draw`'s `reuse_paint` still reproduces stale tiles. - The unmerged Windows draft `2e5d890e37` (`force_render_after_recovery`) similarly only forces the forced-render branch — it doesn't bypass the view cache. ## Fix Two parts: **1. Bypass the view cache on a forced draw (cross-platform).** In the platform-agnostic `request_frame` closure in `Window::new`, call `window.refresh()` whenever `RequestFrameOptions::force_render` is `true`. `Window::refresh` is the documented escape hatch for cached views (per the `AnyView::cached` docs: *"The one exception is when [Window::refresh] is called, in which case caching is ignored."*). With `refreshing = true` every `AnyView::prepaint` cache check fails, every cached view fully repaints, and `paint_glyph` allocates fresh tiles for every glyph, so `rendered_frame.scene` ends up free of stale `AtlasTile`s. **2. Add the `force_render_after_recovery` flag on Windows.** Mirror the Linux fix from #52389: a per-window `Cell` set after `WindowsWindowInner::handle_device_lost` succeeds and consumed at the top of `draw_window`. Together with the GPUI change above, the first frame after recovery (whether a stray `WM_PAINT` during the 200 ms recovery sleep or the explicit `WM_GPUI_FORCE_UPDATE_WINDOW`) is treated as a forced render that both clears `skip_draws` and bypasses the view cache. ## Testing - `script/clippy -p gpui` is clean. - I do not have a Windows toolchain available locally, so I have not cross-compiled `gpui_windows`. Reviewers with Windows access — please smoke-test on a machine where the device-lost path can be exercised (Intel iGPU, suspend/resume, or running a TDR-inducing test on a GPU driver). ## Related - Sentry issue ID 7457971403 (DirectX subpixel atlas crash, Intel Iris Xe). - Builds on / fixes the residual gap in #52389 (`gpui_linux: Force scene rebuild after GPU device recovery"). The GPUI change here also hardens the corresponding Linux path against the same `reuse_paint` mechanism. Release Notes: - Fixed a crash on Windows when the GPU device is lost and recovered during use (typically driver crash, suspend/resume, or display reconfiguration, most commonly on Intel iGPUs) --- crates/gpui/src/window.rs | 5 +++++ crates/gpui_windows/src/events.rs | 6 ++++++ crates/gpui_windows/src/window.rs | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dc387c67f39..46b1ab64a18 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(); diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index a4c47789191..77c4cde9788 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -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 130d3dd7214..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, From 47ea7de9c8e154fa5ba27c36d0907ea0a60c51e9 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 12:51:34 +0200 Subject: [PATCH 60/98] Fix leak detector causing panics in unit evals (#56029) Fixed an issue where the leak detector would sometimes cause panics when running unit evals. Fixed this by matching the tear-down logic that we use in the `gpui::test` macro > thread 'tools::evals::edit_file::eval_from_pixels_constructor' (14336149) panicked at crates/gpui/src/app/entity_map.rs:1116:9: Exited with leaked handles: Leaked handle for entity language::buffer::Buffer (EntityId(50v1)): Release Notes: - N/A --- crates/agent/src/tools/evals.rs | 46 +++++++++++++++++++ crates/agent/src/tools/evals/edit_file.rs | 38 ++++++--------- crates/agent/src/tools/evals/terminal_tool.rs | 40 +++++++--------- crates/agent/src/tools/evals/write_file.rs | 36 ++++++--------- 4 files changed, 90 insertions(+), 70 deletions(-) diff --git a/crates/agent/src/tools/evals.rs b/crates/agent/src/tools/evals.rs index 30960689311..ac11ffe74a0 100644 --- a/crates/agent/src/tools/evals.rs +++ b/crates/agent/src/tools/evals.rs @@ -1,6 +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 4c96b0797f8..79c5a7c2689 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -547,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( diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index 3769df5abed..92ebd61d162 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -2,7 +2,7 @@ use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput}; use Role::*; use anyhow::{Context as _, Result}; use client::{Client, RefreshLlmTokenListener, UserStore}; -use futures::StreamExt; +use futures::{FutureExt as _, StreamExt}; use gpui::{AppContext as _, AsyncApp, TestAppContext}; use http_client::StatusCode; use language_model::{ @@ -428,33 +428,25 @@ async fn retry_on_rate_limit(mut request: impl AsyncFnMut() -> Result) -> } 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 = TerminalToolTest::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 = 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 - }, - metadata: (), + } }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), - }, - } + ) } fn message( diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index f34528fcd78..60eda3ab3e6 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -6,7 +6,7 @@ use Role::*; use anyhow::{Context as _, Result}; use client::{Client, RefreshLlmTokenListener, UserStore}; use fs::FakeFs; -use futures::StreamExt; +use futures::{FutureExt as _, StreamExt}; use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _}; use http_client::StatusCode; use language::language_settings::FormatOnSave; @@ -365,29 +365,19 @@ impl WriteToolTest { } 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 = WriteToolTest::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: eval_utils::OutcomeKind::Passed, - metadata: (), + 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() }, - Err(err) => eval_utils::EvalOutput { - data: format!("{err:?}"), - outcome: eval_utils::OutcomeKind::Error, - metadata: (), - }, - } + |_| eval_utils::OutcomeKind::Passed, + ) } fn message( From 59daeba295f2fa04de0f81b87aa9772c6b8ea86c Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 7 May 2026 05:55:07 -0500 Subject: [PATCH 61/98] vim: Add setting to control whether edit predictions are shown in normal mode (#55956) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Added a setting [vim.show_edit_predictions_in_normal_mode](zed://settings/vim.show_edit_predictions_in_normal_mode) to control whether edit predictions are shown in normal mode. --- assets/settings/default.json | 3 +++ .../settings_content/src/settings_content.rs | 3 +++ crates/settings_ui/src/page_data.rs | 24 ++++++++++++++++++- crates/vim/src/state.rs | 4 ++++ crates/vim/src/vim.rs | 6 ++++- 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 64f97c451b0..d6d6feac644 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2515,6 +2515,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/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_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/vim/src/state.rs b/crates/vim/src/state.rs index 0851604e1ab..85bf84d8878 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -79,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(), } } } From e6b8b30e2218dbe806a2787a668186e5b7130490 Mon Sep 17 00:00:00 2001 From: David Alecrim <35930364+davidalecrim1@users.noreply.github.com> Date: Thu, 7 May 2026 09:26:13 -0300 Subject: [PATCH 62/98] markdown: Improve table cell alignment (#53465) ## Summary Markdown preview tables kept text pinned to the top of a row when a neighboring cell contained a taller image. This made mixed text-and-image tables look unbalanced and inconsistent with common editor behavior. This change makes table text stay visually centered within taller rows so Markdown tables are easier to scan and match expected rendering more closely. ## Before / After | Before | After | | --- | --- | | Screenshot 2026-04-08 at 19 55 50 | Screenshot 2026-04-08 at 21 47
31 | ## References Inspired by comparing this with VS Code preview Screenshot 2026-04-08 at 21 54 06 Release Notes: - Improved Markdown preview table cells to vertically center content in tall rows and respect column alignment from the table header. --------- Co-authored-by: Smit Barmase --- crates/markdown/src/html/html_rendering.rs | 31 +++++++++- crates/markdown/src/markdown.rs | 68 +++++++++++++++++----- 2 files changed, 85 insertions(+), 14 deletions(-) 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..937e38c3950 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -2000,20 +2000,49 @@ 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.alignments.get(col_index).copied(); + let text_align = match alignment { + Some(Alignment::Left) => TextAlign::Left, + Some(Alignment::Center) => TextAlign::Center, + Some(Alignment::Right) => TextAlign::Right, + _ => 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 +2142,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 => { @@ -2702,7 +2733,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 +2742,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> { From 9a125a553dd2e031c1a7fd76091147e70aa89211 Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 13:40:48 +0100 Subject: [PATCH 63/98] agent_ui: Preserve selection mentions when starting a new thread (#55203) When the "+" button created a fresh draft, `active_initial_content` fell back to the raw editor text when the async `draft_prompt` observer had not yet resolved. That raw text contains fold placeholder strings (e.g. "selection") rather than the mention links, so creases and their registered URIs were dropped from the carried-over draft. Related to https://github.com/zed-industries/zed/issues/53981. Release Notes: - Fixed a bug where selection mentions would resolve to the literal `selection` rather than the URI in draft threads. --- crates/agent_ui/src/agent_panel.rs | 41 ++--- crates/agent_ui/src/mention_set.rs | 10 + crates/agent_ui/src/message_editor.rs | 253 ++++++++++++++++++-------- crates/editor/src/display_map.rs | 4 + 4 files changed, 205 insertions(+), 103 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 921d1347ffb..7a2ee6d00c0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2597,31 +2597,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, }) } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 8c98b9458bb..fc2cc6523c8 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -178,6 +178,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 66887019d31..c6fd040f7e9 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -17,6 +17,7 @@ use editor::{ EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, actions::{Copy, Paste}, code_context_menus::CodeContextMenu, + display_map::{CreaseId, CreaseSnapshot}, scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; @@ -768,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); @@ -1874,6 +1831,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)> { @@ -4197,6 +4240,56 @@ mod tests { 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_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, 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( From 4f54a04147f139db9ca1f51ff30350f59554faab Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 13:40:53 +0100 Subject: [PATCH 64/98] agent_ui: Restore `Ctrl + >` behavior for whole lines (#54698) Restores current line fallback when using `Ctrl + >` to add context to the agent. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 73 +-- crates/agent_ui/src/completion_provider.rs | 611 ++++++++++++++------- crates/agent_ui/src/conversation_view.rs | 29 +- crates/agent_ui/src/message_editor.rs | 33 +- crates/terminal_view/src/terminal_panel.rs | 6 +- crates/workspace/src/dock.rs | 12 +- 6 files changed, 479 insertions(+), 285 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7a2ee6d00c0..e60a4834ae2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -32,6 +32,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, @@ -67,10 +68,7 @@ use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; 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_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, @@ -413,61 +411,36 @@ pub fn init(cx: &mut App) { ) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { - let active_editor = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)); - let has_editor_selection = active_editor.is_some_and(|editor| { - editor.update(cx, |editor, cx| { - editor.has_non_empty_selection(&editor.display_snapshot(cx)) - }) - }); - - let has_terminal_selection = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - .is_some_and(|terminal_view| { - terminal_view - .read(cx) - .terminal() - .read(cx) - .last_content - .selection_text - .as_ref() - .is_some_and(|text| !text.is_empty()) - }); - - let has_terminal_panel_selection = - workspace.panel::(cx).is_some_and(|panel| { - 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.dock_at_position(position).read(cx).is_open(); - dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() - }); - - if !has_editor_selection - && !has_terminal_selection - && !has_terminal_panel_selection - { - 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); }); } }); @@ -707,6 +680,7 @@ pub struct AgentPanel { _base_view_observation: Option, _draft_editor_observation: Option, _thread_metadata_store_subscription: Subscription, + last_context_source: Option, } impl AgentPanel { @@ -1065,6 +1039,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 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 9dd97975a18..00cc74a9b87 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -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}; @@ -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| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c6fd040f7e9..ec966f2af54 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}, }; @@ -1365,7 +1365,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 { @@ -1376,17 +1381,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; @@ -2010,7 +2011,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, @@ -3731,11 +3732,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(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4ad40b06e67..34ec1eddcc8 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -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 { diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 1983b2921ff..461726757d7 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -13,7 +13,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::*, @@ -301,6 +301,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 { From bd2fb74037b8a209c6707a613b1faca227606616 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Thu, 7 May 2026 15:02:11 +0200 Subject: [PATCH 65/98] editor: Extract `completions` and `code_actions` out of `editor.rs` (#56030) cc @SomeoneToIgnore ## Summary Follow-up to https://github.com/zed-industries/zed/discussions/55352, where the conclusion was to split `editor.rs` incrementally by topic instead of all at once. This mechanically extracts two editor topics into focused sibling modules: - `crates/editor/src/code_actions.rs` - `crates/editor/src/completions.rs` One odd boundary remains: `Editor::context_menu()` is still a general context-menu accessor, but it now lives in `code_actions.rs` because it was part of the moved code actions block and is also used by completions, Vim tests, agent UI, and the quick action bar. Would you prefer that generic context-menu accessor stay in `editor.rs` for now until context-menu code gets its own extraction? ## Testing - `cargo check -p editor --lib` - `cargo check -p editor --tests` - `cargo check -p editor --lib --features test-support` Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/code_actions.rs | 523 ++++++++ crates/editor/src/completions.rs | 1489 +++++++++++++++++++++ crates/editor/src/editor.rs | 2036 +---------------------------- 3 files changed, 2019 insertions(+), 2029 deletions(-) create mode 100644 crates/editor/src/code_actions.rs create mode 100644 crates/editor/src/completions.rs 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/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/editor.rs b/crates/editor/src/editor.rs index 6dcc10b0ee2..175b430ff01 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -57,11 +57,18 @@ mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; +mod code_actions; +mod completions; mod config; mod diagnostics; mod rewrap; 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::{ @@ -3362,15 +3369,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() } @@ -3578,10 +3576,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, @@ -5748,44 +5742,6 @@ impl Editor { 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) { @@ -5917,1279 +5873,6 @@ impl Editor { }); } - 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_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, - } - } - - 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, @@ -7329,181 +6012,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; @@ -19752,10 +18260,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, @@ -24932,13 +23436,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; @@ -26242,478 +24739,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, @@ -28115,53 +26140,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; From 7556cf8ced19cae275fb4d6678858050d4d51df4 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 7 May 2026 15:47:13 +0200 Subject: [PATCH 66/98] agent: Fix race-condition for LSP tool registration (#56044) This fixes a race condition where the thread would not get the LSP tools at startup if the feature flag was not resolved yet. We now always add the tools, but filter them out when we start a new turn if the feature flag is not set. Release Notes: - N/A --- crates/agent/src/tests/mod.rs | 99 +++++++++++++++++++++++++++++++++++ crates/agent/src/thread.rs | 42 ++++++++------- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 57cec0bc5d0..e4dd5a24257 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); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 78a4b2fd488..ef03f47a8d3 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1577,20 +1577,19 @@ impl Thread { 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)); @@ -2894,6 +2893,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(); @@ -2957,10 +2967,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); } From 5c0b33f72e75281ce70c9866d90cc0f6a7121c22 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 7 May 2026 16:09:39 +0200 Subject: [PATCH 67/98] gpui_windows: Avoid process-wide priority elevation (#56050) We were incorrectly calling this with a thread handle, additionally changing the process priority here doesn't make sense, so just drop this. Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/gpui_windows/src/dispatcher.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) 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(); From 07c1943b439715321c0fc5f539eaa2081d806aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Craig?= Date: Thu, 7 May 2026 11:41:30 -0300 Subject: [PATCH 68/98] Add telemetry events for agent profile usage and configuration (#56054) Release Notes: - N/A --- .../manage_profiles_modal.rs | 26 ++++++++++++++++++- crates/agent_ui/src/profile_selector.rs | 5 ++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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/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(); } } From bfe5dfb4a1a47b60212f5a19259973b359cbb8e8 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Thu, 7 May 2026 23:07:36 +0800 Subject: [PATCH 69/98] gpui_wgpu: Remove redundant match arms for backend priority (#56032) Release Notes: - N/A --- crates/gpui_wgpu/src/wgpu_context.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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, }; From 675ed70f59483cfe7d07e0f90dc487ec02e65c68 Mon Sep 17 00:00:00 2001 From: Sathwik Chirivelli <146921254+chirivelli@users.noreply.github.com> Date: Thu, 7 May 2026 20:51:08 +0530 Subject: [PATCH 70/98] Fix multibuffer initialization based on RHS state (#56058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update modifies the initialization of the left-hand side multibuffer in the SplittableEditor. It now checks if the right-hand side multibuffer is a singleton and uses a `MultiBuffer::without_headers` instead. Before Screenshot: Screenshot 2026-05-07 at 7 30
16 PM After Screenshot: Screenshot 2026-05-07 at 7 32
48 PM Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Optimized multibuffer creation by conditionally using headers based on RHS state. --- crates/editor/src/split.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 }); From 5fc8a836ddc9931474887be2e0b33d1077e031d6 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 17:39:16 +0200 Subject: [PATCH 71/98] sidebar: Experimental Terminal Mode (#56063) Experiment with allowing users to manage terminal sessions along with threads in the sidebar. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- Cargo.lock | 1 + assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 7 + assets/keymaps/default-windows.json | 7 + crates/agent_ui/src/agent_panel.rs | 800 ++++++++++++++++++++- crates/agent_ui/src/agent_ui.rs | 4 +- crates/agent_ui/src/conversation_view.rs | 4 +- crates/feature_flags/src/flags.rs | 12 + crates/project/src/project.rs | 9 +- crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 801 +++++++++++++++++----- crates/sidebar/src/sidebar_tests.rs | 193 +++++- crates/terminal_view/src/terminal_view.rs | 2 +- 13 files changed, 1626 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9a42436e3d..f505b58b5f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16329,6 +16329,7 @@ dependencies = [ "git", "gpui", "http_client", + "itertools 0.14.0", "language", "language_model", "log", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9c49646b5a7..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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d0ac2c22e03..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, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 66195c604fe..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": { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e60a4834ae2..b17c52818be 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::{ @@ -57,6 +58,7 @@ 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, @@ -68,7 +70,10 @@ use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; +use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; +use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, @@ -79,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"; @@ -96,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, @@ -152,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>, } @@ -172,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); } }) @@ -411,6 +449,48 @@ pub fn init(cx: &mut App) { ) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { + let active_editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + let has_editor_selection = active_editor.is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + + let has_terminal_selection = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .is_some_and(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()) + }); + + let has_terminal_panel_selection = + workspace.panel::(cx).is_some_and(|panel| { + 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.dock_at_position(position).read(cx).is_open(); + dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() + }); + + if !has_editor_selection + && !has_terminal_selection + && !has_terminal_panel_selection + { + return; + } + let Some(agent_panel) = workspace.panel::(cx) else { return; }; @@ -603,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 { @@ -625,6 +754,7 @@ enum OverlayView { enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), + Terminal(&'a Entity), Configuration(Option<&'a Entity>), } @@ -635,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, + } } } @@ -663,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, @@ -690,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 @@ -748,6 +884,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { selected_agent: Some(selected_agent), + last_created_entry_kind, last_active_thread, draft_thread_prompt, }, @@ -840,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 { @@ -1009,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, @@ -1023,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(), @@ -1163,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( @@ -1176,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( @@ -1287,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 @@ -1844,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)) @@ -2022,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); @@ -2074,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( @@ -2089,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; @@ -2112,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), } } @@ -2368,7 +2889,7 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); this.serialize(cx); } else { - cx.emit(AgentPanelEvent::RetainedThreadChanged); + cx.emit(AgentPanelEvent::EntryChanged); } cx.notify(); }) @@ -2395,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) @@ -2412,8 +2934,8 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, + ActiveViewFocused, + EntryChanged, ThreadInteracted { thread_id: ThreadId }, } @@ -2535,12 +3057,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) @@ -2725,6 +3251,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() } @@ -2870,25 +3420,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< @@ -2963,6 +3516,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); @@ -3080,7 +3660,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() @@ -3120,7 +3704,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(); @@ -3296,7 +3881,7 @@ impl AgentPanel { return false; } } - BaseView::Uninitialized => { + BaseView::Terminal { .. } | BaseView::Uninitialized => { return false; } } @@ -3348,7 +3933,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(); @@ -3477,16 +4062,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 } @@ -3536,6 +4123,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()) } @@ -3715,6 +4303,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)] @@ -4689,6 +5332,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 = @@ -4707,6 +5351,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/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 00cc74a9b87..773507e2af1 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2708,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 { .. } => {} }, )); 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/project/src/project.rs b/crates/project/src/project.rs index ac34cbdd061..dde7d4f1b39 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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/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 0000aac3f36..a25fbac1513 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,20 +12,23 @@ use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; 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, 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 +121,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 +228,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 +268,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 +328,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 +342,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 +356,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, } @@ -328,6 +425,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 +621,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 +876,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 +955,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 +967,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 +983,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 +1057,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 +1089,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 +1141,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 +1205,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 +1439,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 +1486,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 +1514,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 +1537,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 +1555,7 @@ impl Sidebar { self.contents = SidebarContents { entries, notified_threads, + notified_terminals, project_header_indices, has_open_projects, }; @@ -1436,7 +1592,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 +1639,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 +1661,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 +1866,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 +2288,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 +2555,10 @@ impl Sidebar { } } } + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, false, window, cx); + } } } @@ -2514,7 +2682,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 +2751,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 +3125,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 +3152,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 +3221,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 +3374,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 +3436,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 +3457,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 +3484,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 +3578,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 +3614,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 +3632,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 +3644,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 +3658,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 +3669,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 +3703,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 +3753,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 +3913,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 +4016,7 @@ impl Sidebar { timestamp, }) } + ListEntry::Terminal(_) => None, }) .collect(); @@ -3755,8 +4068,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 +4099,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 +4120,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 +4137,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 +4215,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 +4363,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 +4494,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 +4544,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 +4552,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 +4560,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 +4720,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 +4748,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 +5314,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 +5490,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..dc806009a9f 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 + ); + } } } @@ -517,6 +527,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 +1422,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 +2898,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 +2934,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 +4031,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 +6405,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 +6442,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 +9937,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 +9947,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 +9959,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/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 07c638c1604..37c4c165836 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -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 From a6f41d1b8344baa42ddacbb081c0ac1f8bd71029 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Thu, 7 May 2026 08:43:43 -0700 Subject: [PATCH 72/98] Fix sign in disclaimer to accurately show trial benefits (#55964) Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [x ] Unsafe blocks (if any) have justifying comments - [x ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x ] Tests cover the new/changed behavior - [x ] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/ai_onboarding/src/ai_onboarding.rs | 4 ++-- crates/ai_onboarding/src/plan_definitions.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index bc1dabefd28..30aaa4206fe 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -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")) From 68256f2e1da53fbe9b7966a8b9bc69da3cf6f652 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 7 May 2026 16:56:32 +0100 Subject: [PATCH 73/98] git: Add `dev: show git job queue` (#55904) Adds a command to help debugging stuck git job queues Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Anthony Eid --- crates/git_ui/src/git_panel.rs | 83 ++ crates/project/src/git_store.rs | 758 ++++++++++-------- .../project/src/git_store/job_debug_queue.rs | 222 +++++ crates/project/src/telemetry_snapshot.rs | 2 +- 4 files changed, 745 insertions(+), 320 deletions(-) create mode 100644 crates/project/src/git_store/job_debug_queue.rs diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0b6316c4adc..61423e39b78 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -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)] @@ -3880,6 +3895,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, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 61cca22ff77..52c16e8a2ba 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::{ @@ -380,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>>, @@ -507,6 +509,7 @@ impl EventEmitter for Repository {} impl EventEmitter for GitStore {} pub struct GitJob { + id: JobId, job: Box Task<()>>, key: Option, } @@ -1384,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 @@ -4523,6 +4526,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, @@ -4574,6 +4578,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(), @@ -4609,6 +4614,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 { @@ -4768,6 +4774,7 @@ impl Repository { pub fn send_job( &mut self, + description: &'static str, status: Option, job: F, ) -> oneshot::Receiver @@ -4776,11 +4783,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, @@ -4793,29 +4801,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(); }) @@ -4898,43 +4916,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? }) } @@ -4980,6 +5002,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 { @@ -5027,7 +5050,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, @@ -5055,7 +5078,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 @@ -5083,7 +5106,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 @@ -5869,6 +5892,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 { @@ -6095,7 +6119,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, @@ -6131,7 +6155,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, @@ -6165,7 +6189,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, @@ -6204,40 +6228,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( @@ -6255,7 +6283,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, @@ -6299,6 +6327,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 { @@ -6337,46 +6366,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( @@ -6389,36 +6422,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( @@ -6452,6 +6489,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 { @@ -6544,48 +6582,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( @@ -6600,6 +6642,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 { @@ -6674,6 +6717,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 { @@ -6700,6 +6744,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 { @@ -6728,7 +6773,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 { @@ -6772,7 +6817,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 @@ -6831,7 +6876,7 @@ impl Repository { 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 @@ -6866,38 +6911,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( @@ -6924,24 +6973,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) @@ -6966,7 +7021,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, @@ -7007,7 +7062,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 @@ -7027,22 +7082,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( @@ -7051,26 +7110,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> { @@ -7081,6 +7144,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 { @@ -7165,6 +7229,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 { @@ -7193,7 +7258,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 @@ -7218,7 +7283,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 @@ -7274,7 +7339,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 @@ -7318,30 +7383,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 { @@ -7373,6 +7443,7 @@ impl Repository { let id = self.id; let flag = delete_branch_flag(is_remote, force); self.send_job( + "delete_branch", Some(format!("git branch {flag} {branch_name}").into()), move |repo, _cx| async move { match repo { @@ -7407,6 +7478,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 { @@ -7432,30 +7504,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 @@ -7481,7 +7557,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 @@ -7603,7 +7679,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 @@ -7629,7 +7705,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 @@ -7684,6 +7760,7 @@ impl Repository { ) { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "schedule_scan", Some(GitJobKey::ReloadGitState), None, |state, mut cx| async move { @@ -7715,7 +7792,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)) @@ -7739,6 +7816,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; @@ -7761,7 +7846,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 { @@ -7775,6 +7860,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; @@ -7797,7 +7890,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) @@ -7822,7 +7915,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; @@ -7865,19 +7958,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 @@ -7909,6 +8006,7 @@ impl Repository { let this = cx.weak_entity(); let _ = self.send_keyed_job( + "paths_changed", Some(GitJobKey::RefreshStatuses), None, |state, mut cx| async move { @@ -8015,8 +8113,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( @@ -8086,7 +8188,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/` @@ -8108,6 +8210,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 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/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 { From 147524879e328513b0883017733462b03b27cf02 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Thu, 7 May 2026 18:14:47 +0200 Subject: [PATCH 74/98] editor: Extract `fold` and `selection` out of `editor.rs` (#56070) cc @SomeoneToIgnore ## Summary Follow-up to #56030 This mechanically extracts two editor topics into focused sibling modules: - `crates/editor/src/fold.rs` - `crates/editor/src/selection.rs` One odd boundary remains: several selection state types still live in `editor.rs`. I didn't move them because those caused that "huge 11k diff" in the previous PR, so I propose to move them later. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/editor.rs | 1972 +------------------------------- crates/editor/src/fold.rs | 1095 ++++++++++++++++++ crates/editor/src/selection.rs | 899 +++++++++++++++ 3 files changed, 1996 insertions(+), 1970 deletions(-) create mode 100644 crates/editor/src/fold.rs create mode 100644 crates/editor/src/selection.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 175b430ff01..608895da9c9 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; @@ -62,6 +63,7 @@ mod completions; mod config; mod diagnostics; mod rewrap; +mod selection; pub(crate) use actions::*; pub use code_actions::CodeActionProvider; @@ -1444,13 +1446,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 { @@ -2784,30 +2779,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) } @@ -3621,449 +3592,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)>, @@ -4118,480 +3646,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; @@ -6398,78 +5452,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() { @@ -18271,32 +17253,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, @@ -18367,35 +17323,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, @@ -18408,705 +17335,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); @@ -19581,24 +17809,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)) @@ -23650,103 +21860,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() } @@ -25215,87 +23328,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) 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/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()?, + )) + } +} From 10afe2ff281feccc1150753ea91951fe860dc0c8 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 7 May 2026 13:26:06 -0400 Subject: [PATCH 75/98] git: Make `git::Commit` do an amend when amending is pending (#54472) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - The `git::Commit` action (cmd-enter or ctrl-enter) will now commit a pending amend. --- crates/git_ui/src/commit_modal.rs | 18 ++++++----- crates/git_ui/src/git_panel.rs | 52 ++++++++++++------------------- 2 files changed, 30 insertions(+), 40 deletions(-) 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/git_panel.rs b/crates/git_ui/src/git_panel.rs index 61423e39b78..4262d8bf398 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, @@ -2125,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( @@ -2140,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, }, @@ -2161,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, @@ -2181,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 { @@ -4740,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 { "" }, From 8624bf66893c46e60b909b3c4958dc5e02ee9795 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 7 May 2026 13:40:28 -0400 Subject: [PATCH 76/98] git: Fix diff hunks not being removed on restore in remote projects (#54823) Closes #48032 When restoring a diff hunk, we first unstage it unconditionally. That unstaging operation is a no-op in terms of the index text if the hunk was already not staged, but previously we would still always do `spawn_set_index_text_job` and bump the `hunk_staging_operation_count_as_of_write`. Bumping that count in turn causes us to skip a diff recalculation in response to the change in the buffer's text. That works out fine in the local case, because when the worktree picks up the write to `.git/index` we kick off another diff recalculation which is not skipped. But in the remote case, we don't get an `UpdateDiffBases` proto message if the index text didn't actually change, so there is no subsequent diff calculation to do the cleanup, and we end up with a stale no-op hunk. This PR fixes the issue by skipping the write to the index and the `hunk_staging_operation_count_as_of_write` bump if the new and old index texts are the same. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - Fixed a bug where restoring diff hunks in remote projects would leave stale no-op hunks in the UI. --- crates/editor/src/folding_ranges.rs | 8 +- crates/editor/src/semantic_tokens.rs | 8 +- crates/project/src/git_store.rs | 6 +- .../remote_server/src/remote_editing_tests.rs | 109 ++++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) 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/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/project/src/git_store.rs b/crates/project/src/git_store.rs index 52c16e8a2ba..9f97f829b0c 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1848,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 @@ -1857,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, ) 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, From 1b88528b8627b4ddaa48f8a08da8e1321819336e Mon Sep 17 00:00:00 2001 From: Neel Date: Thu, 7 May 2026 19:07:12 +0100 Subject: [PATCH 77/98] agent_ui: Handle Cut for selection mentions (#54694) Following on from https://github.com/zed-industries/zed/pull/54031, implement the same but for `Cut`. Release Notes: - N/A --- crates/agent_ui/src/mention_set.rs | 4 + crates/agent_ui/src/message_editor.rs | 222 ++++++++++++++++++++++---- 2 files changed, 193 insertions(+), 33 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index fc2cc6523c8..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() } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index ec966f2af54..67be4804d54 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -15,7 +15,7 @@ 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, @@ -35,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; @@ -1180,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; }; @@ -1189,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| { @@ -1689,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; } @@ -1715,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)) } } @@ -1775,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() @@ -1991,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; @@ -4029,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!( @@ -4094,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( @@ -4119,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 @@ -4174,6 +4211,8 @@ mod tests { ); }); } + + snapshot.len() }); ( @@ -4181,7 +4220,9 @@ mod tests { message_editor, first_uri, first_range, + second_uri, second_range, + buffer_len, }, cx, ) @@ -4209,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())); @@ -4241,7 +4284,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, None); @@ -4297,6 +4342,117 @@ mod tests { } } + #[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, From 8bdcce86b69252bf9a1e65a52c2a8268af7e0106 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 7 May 2026 17:43:07 -0300 Subject: [PATCH 78/98] settings_ui: Stop reading the clipboard on every frame (#56075) `render_settings_item_link` was calling `cx.read_from_clipboard()` during render so it could show a check icon next to the copy-link button when the matching link was on the clipboard. This had two problems: - A clipboard read per visible setting per frame is too expensive. - On Windows, reading the clipboard pumps the system message queue. If a queued message handler updates `App` while we're still rendering, GPUI panics with `RefCell already borrowed` (many occurrences observed). Track the `json_path` of the most recently copied setting locally instead. The check icon now reflects what was copied in this session via this UI rather than whatever is on the system clipboard. While this removes the most common offender, the underlying `gpui_windows` reentrancy bug still exists: `on_close` / `on_request_frame` callbacks can be invoked while `App` is already borrowed on Windows, and can be triggered by any other clipboard-touching code path. We should consider a follow-up PR that handles this at the platform layer -- either by deferring callbacks that re-borrow `App`, or by guarding individual handlers in `gpui_windows::events` against reentrant `borrow_mut` calls. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a crash on Windows that could occur when closing the settings window - Improved the overall performance of the settings window --- crates/settings_ui/src/settings_ui.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f17caafce5e..7b88c2affe9 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -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(); From ebc46d7e0665f701e7b6b1332e17c2d9f9d2bb8d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 7 May 2026 23:48:12 +0200 Subject: [PATCH 79/98] Update rmcp and rpassword (#56096) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f505b58b5f7..f4626a83d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14934,9 +14934,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", @@ -14956,9 +14956,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", @@ -15031,13 +15031,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]] From dccea211edfed189db0704ef1247e446aca81150 Mon Sep 17 00:00:00 2001 From: Karol Broda <122811026+karol-broda@users.noreply.github.com> Date: Fri, 8 May 2026 00:55:36 +0200 Subject: [PATCH 80/98] auto_update: Add NixOS rsync install hint (#56097) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable NixOS users who are missing rsync get a generic "Please install rsync using your package manager" message. Release Notes: - Improved auto update error message for NixOS users missing rsync --- crates/auto_update/src/auto_update.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index c1b15aa3b6c..c14de5a801c 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -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 }; From 6766514599c6f8ce6530ccc685db5e0d68c44f32 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 8 May 2026 01:45:12 -0400 Subject: [PATCH 81/98] Improve auto watch (#56126) This PR fixes a few bugs, updates some UI, and improves testing of auto watch. It'll likely be easier to review commit by commit: - Swapped the Copy Channel Link and Auto Watch buttons so Auto Watch appears in a better position. The UI is still not great, but I think this tweak will improve it until someone on design can help. Before: 589131021-c967dfe1-9026-4a1d-a399-b735303f2de0 After: 589131282-607e15a5-e50c-4a8e-b22c-327f2e7b8ab5 - Disable Auto Watch when following another collaborator, with test coverage for that behavior. We currently disable following when engaging auto watch, and now we disable auto watch when following. They are mutually exclusive and I think the feels correct. - Refactored Auto Watch integration tests to use channels API instead of room API. - Improved test robustness by using assertions to identify `SharedScreen` items by type and `peer_id` instead of tab title text. - Fixed Auto Watch for returning channel participants by emitting `RemoteVideoTracksChanged` when removing a participant with active video tracks, with regression coverage for leave/rejoin/share. Self-Review Checklist: - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [X] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Closes Release Notes: - N/A --- crates/call/src/call_impl/room.rs | 5 + .../tests/integration/auto_watch_tests.rs | 312 +++++++++++++++--- crates/collab_ui/src/collab_panel.rs | 14 +- crates/workspace/src/workspace.rs | 1 + 4 files changed, 275 insertions(+), 57 deletions(-) diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 658c2b62064..21b40822cf0 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -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/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_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index cea3806edb3..5fe6d839569 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 { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 9cc1fa30865..79b7e28ffb3 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5742,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 { From e1a46f9256354971248b0696ed34641b48953907 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 8 May 2026 10:06:44 +0200 Subject: [PATCH 82/98] gpui: Use `SharedString::new_static` within `From` impls for `ElementId` where possible (#56139) Horror of a PR title but could not think of anything better here. Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- crates/gpui/src/window.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 46b1ab64a18..659a34dec9b 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5764,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)) } } @@ -5776,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) } } @@ -5794,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) } } @@ -5806,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)) } } From e727080af232cec481bafb2d080585091c3f5db7 Mon Sep 17 00:00:00 2001 From: Gabriel Linder Date: Fri, 8 May 2026 10:09:42 +0200 Subject: [PATCH 83/98] Update Mistral provider docs following #55443 (#56133) Update Mistral provider docs following #55443 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests **and docs** cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... Signed-off-by: Gabriel Linder --- docs/src/ai/llm-providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index e1b5a50779f..cf130c35326 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -362,7 +362,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)): From c8f002686780fdfcdb9301bf234d0c4bf4563800 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 8 May 2026 11:30:34 +0200 Subject: [PATCH 84/98] gpui: Remove unsound await_on_background helper (#56132) The function is unsound due to the classic fact that one can leak tasks, sidestepping the blocking drop behavior resulting in a use after free. Release Notes: - N/A or Added/Fixed/Improved ... --- crates/diagnostics/src/diagnostics.rs | 70 ++-- crates/gpui/src/executor.rs | 54 --- crates/language/src/language.rs | 30 +- crates/languages/src/bash.rs | 91 ++-- crates/languages/src/c.rs | 146 +++---- crates/languages/src/css.rs | 83 ++-- crates/languages/src/eslint.rs | 90 ++-- crates/languages/src/go.rs | 129 +++--- crates/languages/src/json.rs | 159 +++---- crates/languages/src/python.rs | 579 ++++++++++++++------------ crates/languages/src/rust.rs | 157 +++---- crates/languages/src/tailwind.rs | 83 ++-- crates/languages/src/tailwindcss.rs | 83 ++-- crates/languages/src/typescript.rs | 111 ++--- crates/languages/src/vtsls.rs | 85 ++-- crates/languages/src/yaml.rs | 84 ++-- crates/project/src/lsp_store.rs | 8 +- 17 files changed, 1052 insertions(+), 990 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 4ee8259dd69..642d16b6e25 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -1050,47 +1050,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; } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 07f1667b620..c1afce81073 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -115,60 +115,6 @@ impl BackgroundExecutor { } } - /// 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) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 8bfc4efb1ff..a6e05fb586c 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -623,8 +623,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 } } @@ -632,8 +632,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, @@ -686,11 +686,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); @@ -701,11 +697,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); @@ -1421,13 +1413,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( diff --git a/crates/languages/src/bash.rs b/crates/languages/src/bash.rs index a002968fa40..438090e2aa9 100644 --- a/crates/languages/src/bash.rs +++ b/crates/languages/src/bash.rs @@ -6,7 +6,7 @@ use lsp::LanguageServerBinary; use node_runtime::{NodeRuntime, VersionStrategy}; use project::ContextProviderWithTasks; use semver::Version; -use std::{path::PathBuf, vec}; +use std::{future::Future, path::PathBuf, sync::Arc, vec}; use task::{TaskTemplate, TaskTemplates, VariableName}; use util::{ResultExt, maybe}; @@ -90,35 +90,41 @@ impl LspInstaller for BashLspAdapter { }) } - async fn check_if_version_installed( + fn check_if_version_installed( &self, version: &Self::BinaryVersion, container_dir: &PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Option { - let server_path = container_dir - .join("node_modules") - .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + 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(); - 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("node_modules") + .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); - if should_install_language_server { - None - } else { - let env = delegate.shell_env().await; - Some(LanguageServerBinary { - path: self.node.binary_path().await.ok()?, - env: Some(env), - arguments: vec![server_path.into(), "start".into()], - }) + 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()], + }) + } } } @@ -133,29 +139,34 @@ impl LspInstaller for BashLspAdapter { .await } - async fn fetch_server_binary( + fn fetch_server_binary( &self, latest_version: Self::BinaryVersion, container_dir: std::path::PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let server_path = container_dir - .join("node_modules") - .join(Self::NODE_MODULE_RELATIVE_SERVER_PATH); + delegate: &Arc, + ) -> impl Send + Future> + use<> { + let node = self.node.clone(); + let delegate = delegate.clone(); - self.node - .npm_install_packages( + 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.to_string())], + &[(Self::PACKAGE_NAME, 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(), "start".into()], - }) + let env = delegate.shell_env().await; + Ok(LanguageServerBinary { + path: node.binary_path().await?, + env: Some(env), + arguments: vec![server_path.into(), "start".into()], + }) + } } } 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 e9b94380191..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( 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/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/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 85229cfdcde..55bf8c66c2a 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -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") } } } From b270b1d63d7b8ecb6897b2d95d10dcb8d256dc2e Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 8 May 2026 13:23:56 +0300 Subject: [PATCH 85/98] Fix resolved lens causing flickers (#56047) Based on https://github.com/zed-industries/zed/pull/54100#issuecomment-4394534078 * Adjusts the code lens display closer to what VSCode does: have blank placeholders for the code lens need resolving. Zed will remove them if resolve returns nothing, so some small amount of jitter is still there. * Also reworks LspStore layer to provide a simple resolve method, without any ranges involved, grouping that logic in the editor itself. This allows to process each resolve request separately, updating editor blocks as soon as possible. Before: https://github.com/user-attachments/assets/d6759a90-0087-4658-abf8-8e2767bc63a2 After: https://github.com/user-attachments/assets/cb8f976c-b3fc-4f66-bb9f-812108255c90 Release Notes: - Fixed resolved lens causing flickers --- .../collab/tests/integration/editor_tests.rs | 7 +- crates/editor/src/code_lens.rs | 516 ++++++++++++++---- crates/project/src/lsp_store.rs | 2 +- crates/project/src/lsp_store/code_lens.rs | 289 +++++----- 4 files changed, 595 insertions(+), 219 deletions(-) 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/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs index c78620e25fd..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 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/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 55bf8c66c2a..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; 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())) }) } } From c049193fd93b2c27d3e4a8a40f5e1739db4640b1 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 8 May 2026 13:36:03 +0300 Subject: [PATCH 86/98] Make all status bar tools able to hide its button via UI (#54971) Closes https://github.com/zed-industries/zed/discussions/53471 Adds a requirement on status bar items to provide a way to hide themselves. image image Release Notes: - Added a way to hide sidebar buttons --- .../src/activity_indicator.rs | 5 + crates/agent_ui/src/agent_panel.rs | 6 ++ crates/collab_ui/src/collab_panel.rs | 6 ++ crates/debugger_ui/src/debugger_panel.rs | 6 ++ crates/diagnostics/src/items.rs | 12 ++- .../src/edit_prediction_button.rs | 9 +- .../src/active_buffer_encoding.rs | 15 ++- crates/git_ui/src/conflict_view.rs | 11 ++- crates/git_ui/src/git_panel.rs | 6 ++ crates/go_to_line/src/cursor_position.rs | 11 ++- crates/image_viewer/src/image_info.rs | 9 +- .../src/active_buffer_language.rs | 15 ++- crates/language_tools/src/lsp_button.rs | 8 +- .../src/line_ending_indicator.rs | 15 ++- crates/outline_panel/src/outline_panel.rs | 6 ++ crates/project_panel/src/project_panel.rs | 6 ++ crates/search/src/search_status_button.rs | 10 +- crates/terminal_view/src/terminal_panel.rs | 6 ++ .../src/active_toolchain.rs | 12 ++- crates/vim/src/mode_indicator.rs | 11 ++- crates/workspace/src/active_file_name.rs | 12 ++- crates/workspace/src/dock.rs | 25 +++++ crates/workspace/src/status_bar.rs | 99 +++++++++++++++++-- crates/workspace/src/workspace.rs | 6 +- 24 files changed, 292 insertions(+), 35 deletions(-) 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_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index b17c52818be..10fe51feb73 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3039,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 } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 5fe6d839569..13208fe5ae1 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -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/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 36327d7695c..c034363bcd9 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -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/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_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 9f2b7a5f1fc..43edcfb8910 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -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/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/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 4262d8bf398..7b8898b9ab8 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6214,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/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/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/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_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 63529ea0cf3..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, TaskExt, 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/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/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 9e179c97a7d..7c5bb7bcf62 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -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/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4f9bc801d63..780d8c9274e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7291,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/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/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 34ec1eddcc8..25e4ad9d999 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1666,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/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/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/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 461726757d7..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 _; @@ -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 { @@ -1251,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 { @@ -1322,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 }) }) @@ -1388,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/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/workspace.rs b/crates/workspace/src/workspace.rs index 79b7e28ffb3..267420cc15f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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}; From e78ddcac8d7bb36c3f68835c506f38f4850adb7d Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 8 May 2026 13:26:03 +0200 Subject: [PATCH 87/98] agent: Improve UX when agent tries to edit unsaved buffer (#55655) Before: 1. Agent tries to edit unsaved file 2. Tool call fails with error telling the agent to ask the user to save or discard edits 3. User types save/restore 4. Agent uses save/restore tool https://github.com/user-attachments/assets/c94dd361-e8e0-48ee-be31-da8afe594419 After: 1. Agent tries to edit unsaved file 2. User is prompted to save/restore file 3. User accepts/rejects or saves/discards file manually https://github.com/user-attachments/assets/1d98a0c4-4420-4426-94f2-42355de230be Release Notes: - agent: Improved UX when agent tries to edit unsaved buffer --------- Co-authored-by: Ben Brandt --- assets/settings/default.json | 2 - crates/acp_thread/src/acp_thread.rs | 43 +- crates/acp_thread/src/connection.rs | 3 + crates/agent/src/agent.rs | 5 +- crates/agent/src/tests/mod.rs | 106 --- crates/agent/src/thread.rs | 60 +- crates/agent/src/tools.rs | 6 - crates/agent/src/tools/edit_file_tool.rs | 258 ++++-- crates/agent/src/tools/edit_session.rs | 149 +++- .../src/tools/restore_file_from_disk_tool.rs | 673 ---------------- crates/agent/src/tools/save_file_tool.rs | 756 ------------------ crates/agent/src/tools/tool_permissions.rs | 86 ++ crates/agent/src/tools/write_file_tool.rs | 206 +++++ crates/agent_servers/src/acp.rs | 1 + crates/agent_ui/src/conversation_view.rs | 1 + crates/settings_ui/src/pages.rs | 3 +- .../src/pages/tool_permissions_setup.rs | 19 - crates/sidebar/src/sidebar_tests.rs | 1 + 18 files changed, 713 insertions(+), 1665 deletions(-) delete mode 100644 crates/agent/src/tools/restore_file_from_disk_tool.rs delete mode 100644 crates/agent/src/tools/save_file_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index d6d6feac644..7c74715b122 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1122,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, diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 769131a8e0d..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); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 41ca3e4c6a6..41cdd1250b3 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -641,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. @@ -915,6 +917,7 @@ mod test_support { thread.request_tool_call_authorization( tool_call.clone().into(), options.clone(), + AuthorizationKind::PermissionGrant, cx, ) })?? diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 95b79a0cc15..cd45c97f582 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -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 e4dd5a24257..511986ff004 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -6391,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); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ef03f47a8d3..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, WriteFileTool, + SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, + ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; @@ -825,7 +825,6 @@ impl ToolPermissionContext { || tool_name == WriteFileTool::NAME || tool_name == DeletePathTool::NAME || tool_name == CreateDirectoryTool::NAME - || tool_name == SaveFileTool::NAME { ( extract_path_pattern(value), @@ -925,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)] @@ -1571,8 +1571,6 @@ 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); @@ -3878,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 @@ -3925,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 c52e0e4745e..77b840a47ad 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -20,8 +20,6 @@ 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; @@ -79,8 +77,6 @@ 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::*; @@ -176,8 +172,6 @@ tools! { OpenTool, ReadFileTool, RenameTool, - RestoreFileFromDiskTool, - SaveFileTool, SpawnAgentTool, TerminalTool, UpdatePlanTool, diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 31eb788dfa3..d439791970b 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1871,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 (edit_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(), @@ -1881,7 +1884,6 @@ mod tests { true, )); - // Read the file first cx.update(|cx| { read_tool.clone().run( ToolInput::resolved(crate::ReadFileToolInput { @@ -1896,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) @@ -1909,54 +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| { - edit_tool.clone().run( - ToolInput::resolved(EditFileToolInput { - path: "root/test.txt".into(), - edits: 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] diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index 1be22a579a0..7955144f8ee 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -2,17 +2,16 @@ mod reindent; mod streaming_fuzzy_matcher; mod streaming_parser; -use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; -use super::save_file_tool::SaveFileTool; -use crate::{AgentTool, Thread, ToolCallEventStream}; +use crate::{Thread, ToolCallEventStream}; use acp_thread::Diff; use action_log::ActionLog; -use agent_client_protocol::schema::{ToolCallLocation, ToolCallUpdateFields}; +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, LanguageRegistry}; +use language::{Buffer, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; @@ -665,7 +664,8 @@ impl EditSession { .await .map_err(|e| e.to_string())?; - let file_changed_since_last_read = ensure_buffer_saved(&buffer, &abs_path, &context, cx)?; + 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()); @@ -932,53 +932,25 @@ fn agent_edit_buffer( }); } -fn ensure_buffer_saved( +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 check_result = context.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 (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) }); - 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()); + resolve_dirty_buffer(buffer, mode, context, event_stream, cx).await?; } if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) @@ -990,6 +962,99 @@ fn ensure_buffer_saved( 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, 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/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 index c9cd548f316..bfd454aba97 100644 --- a/crates/agent/src/tools/write_file_tool.rs +++ b/crates/agent/src/tools/write_file_tool.rs @@ -1091,6 +1091,212 @@ mod tests { ); } + /// 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, diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index cc467eb8cd0..4ea43aceb68 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -3359,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/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 773507e2af1..20f5212dbce 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -7120,6 +7120,7 @@ pub(crate) mod tests { "Allow", acp::PermissionOptionKind::AllowOnce, )]), + acp_thread::AuthorizationKind::PermissionGrant, cx, ) .unwrap() diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 63c3965d095..4a69069148e 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -15,7 +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/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index 05cd51e3f92..550b9a3adc6 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -62,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", @@ -80,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 { @@ -314,10 +302,8 @@ fn get_tool_render_fn( "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 } } @@ -1395,13 +1381,8 @@ 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/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index dc806009a9f..1d553d7d5ad 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -444,6 +444,7 @@ fn request_test_tool_authorization( "Allow", acp::PermissionOptionKind::AllowOnce, )]), + acp_thread::AuthorizationKind::PermissionGrant, cx, ) .unwrap() From 6aca6364fefd5334f9b0b7ca8143ce5380480c95 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Fri, 8 May 2026 14:01:43 +0200 Subject: [PATCH 88/98] editor: Extract `git` and `input` out of `editor.rs` (#56155) cc @SomeoneToIgnore ## Summary Follow-up to https://github.com/zed-industries/zed/discussions/55352, where the conclusion was to split `editor.rs` incrementally by topic instead of all at once. This mechanically extracts two editor topics into focused sibling modules: - `crates/editor/src/input.rs` - `crates/editor/src/git.rs` The git extraction is intentionally partial for now. I left a lot of related parts because otherwise the diff was super huge (over 9K lines) in the Github, so we can move those parts later in the follow-up PRs Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/editor/src/editor.rs | 2918 +---------------------------------- crates/editor/src/git.rs | 709 +++++++++ crates/editor/src/input.rs | 2221 ++++++++++++++++++++++++++ 3 files changed, 2936 insertions(+), 2912 deletions(-) create mode 100644 crates/editor/src/input.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 608895da9c9..7147677d91e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -62,6 +62,7 @@ mod code_actions; mod completions; mod config; mod diagnostics; +mod input; mod rewrap; mod selection; @@ -3451,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, @@ -3479,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 @@ -3503,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( @@ -3708,1225 +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( - 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(()) - } - - /// 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 open_transaction_for_hidden_buffers( workspace: Entity, transaction: ProjectTransaction, @@ -8835,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, ); @@ -17378,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; @@ -19140,360 +17550,6 @@ impl Editor { 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)); @@ -21118,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, @@ -21324,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) } @@ -21456,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, @@ -22248,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, @@ -23524,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( 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/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 + } +} From 367db0706b9c28095fbca52588a93375c440f0fd Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 May 2026 08:28:22 -0400 Subject: [PATCH 89/98] collab: Remove unused `api_token` field from `Config` (#56098) This PR removes the `api_token` field from Collab's `Config`, as it is no longer used. Release Notes: - N/A --- crates/collab/.env.toml | 1 - crates/collab/k8s/collab.template.yml | 5 ----- crates/collab/src/auth.rs | 3 +-- crates/collab/src/lib.rs | 2 -- crates/collab/tests/integration/test_server.rs | 1 - 5 files changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index 8a4c74067a7..1d20a14e2d5 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -2,7 +2,6 @@ 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" LIVEKIT_SERVER = "http://localhost:7880" LIVEKIT_KEY = "devkey" diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index bad3d290d79..f2c2c6ebb48 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -87,11 +87,6 @@ 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: 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/lib.rs b/crates/collab/src/lib.rs index 91259b4ce40..041e7461725 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -124,7 +124,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, @@ -171,7 +170,6 @@ impl Config { http_port: 0, database_url: "".into(), database_max_connections: 0, - api_token: "".into(), livekit_server: None, livekit_key: None, livekit_secret: None, diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 820bcbd3376..9bfddbca754 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -586,7 +586,6 @@ impl TestServer { http_port: 0, database_url: "".into(), database_max_connections: 0, - api_token: "".into(), livekit_server: None, livekit_key: None, livekit_secret: None, From 7940ded92a16bb7355f55f227465583f672a813e Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Fri, 8 May 2026 14:13:14 +0100 Subject: [PATCH 90/98] sidebar: Better search (#56166) Makes the sidebar search case insensitive, and also require contiguous matches. Also removes the duplicate logic for the sidebar and thread history view Release Notes: - N/A or Added/Fixed/Improved ... --- crates/agent_ui/src/threads_archive_view.rs | 36 ++++++++++++--------- crates/sidebar/src/sidebar.rs | 23 +------------ 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 5da5526b3df..ebd8c3d94f0 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -102,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/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index a25fbac1513..597bb21811d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -10,6 +10,7 @@ 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, AgentPanelTerminalInfo, @@ -377,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 From f87f0c0e1dc0c07e086989dcffc6984bc5ef71b0 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 8 May 2026 08:19:07 -0500 Subject: [PATCH 91/98] fs: Don't treat watcher errors as reason to do rescans (#56165) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes https://github.com/zed-industries/zed/issues/56064 This behavior regressed in https://github.com/zed-industries/zed/pull/54481 Release Notes: - Fixed an issue where broken symlinks, permission errors, or other fs errors in watched directories could cause excessive CPU usage --- crates/fs/src/fs_watcher.rs | 45 ++++++++----------------------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index f99d5d0b70e..fec8b03bfe1 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -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); @@ -483,13 +458,11 @@ fn handle_event(mode: WatcherMode, event: Result) match event { Ok(event) => { 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}"); } } } From 6bc4b4b7e444d8a0379caecbf629aab50bb20ce2 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 8 May 2026 08:19:33 -0500 Subject: [PATCH 92/98] Fix zeta2 prompt format selection (#55338) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes https://github.com/zed-industries/zed/issues/52585 Release Notes: - Fixed local zeta2 edit predictions using the wrong prompt format. --- crates/edit_prediction/src/edit_prediction.rs | 8 +-- crates/edit_prediction/src/fim.rs | 3 +- crates/edit_prediction/src/zeta.rs | 44 ++++++++---- crates/language/src/language.rs | 5 +- crates/language/src/language_settings.rs | 46 ++++++++++++- crates/settings_content/src/language.rs | 7 +- crates/settings_ui/src/settings_ui.rs | 2 +- .../zed/src/zed/edit_prediction_registry.rs | 16 +++-- crates/zeta_prompt/src/zeta_prompt.rs | 2 + docs/src/ai/edit-prediction.md | 68 +++++++++++++++++-- 10 files changed, 163 insertions(+), 38 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index e61cafa6adc..7f835dfbdf1 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -33,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/zeta.rs b/crates/edit_prediction/src/zeta.rs index 33b347be175..a5637ca3cec 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -12,11 +12,10 @@ use cloud_llm_client::{ use edit_prediction_types::PredictedCursorPosition; 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}; @@ -101,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( @@ -119,7 +138,7 @@ pub fn request_prediction_with_zeta( repo_url, ); - 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 @@ -139,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( @@ -176,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( @@ -188,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(), @@ -204,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/language/src/language.rs b/crates/language/src/language.rs index a6e05fb586c..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}; 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/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_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 7b88c2affe9..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) 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/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/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
 {

From 4b23564f36230c88549ec7aa7ca369eec797b997 Mon Sep 17 00:00:00 2001
From: Marshall Bowers 
Date: Fri, 8 May 2026 09:34:46 -0400
Subject: [PATCH 93/98] collab: Route `get_users_by_ids` through Cloud (#56105)

This PR makes it so we route the `UserService::get_users_by_ids` call
through Cloud instead of hitting the database.

We've introduced a new `CloudUserService` that will fetch the users from
Cloud using the internal API. Note that we've only implemented the
`get_users_by_ids` method on this service, as the endpoints for the
other methods don't yet exist.

We have also introduced a `TransitionalUserService` for the purposes of
gradually transitioning these calls over to Cloud. Right now it uses the
`CloudUserService` for the `get_users_by_ids` implementation, but then
uses the `DatabaseUserService` for the other methods.

Closes CLO-740.

Release Notes:

- N/A
---
 crates/cloud_api_types/src/cloud_api_types.rs |   1 +
 crates/cloud_api_types/src/internal_api.rs    |  22 +++
 crates/collab/.env.toml                       |   1 +
 crates/collab/k8s/collab.template.yml         |   5 +
 crates/collab/src/lib.rs                      |  22 ++-
 crates/collab/src/services/user_service.rs    | 147 ++++++++++++++++++
 .../collab/tests/integration/test_server.rs   |   1 +
 7 files changed, 196 insertions(+), 3 deletions(-)
 create mode 100644 crates/cloud_api_types/src/internal_api.rs

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 1d20a14e2d5..fc0d899822d 100644
--- a/crates/collab/.env.toml
+++ b/crates/collab/.env.toml
@@ -3,6 +3,7 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
 DATABASE_MAX_CONNECTIONS = 5
 HTTP_PORT = 8080
 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 f2c2c6ebb48..d37840218c2 100644
--- a/crates/collab/k8s/collab.template.yml
+++ b/crates/collab/k8s/collab.template.yml
@@ -92,6 +92,11 @@ spec:
                 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/src/lib.rs b/crates/collab/src/lib.rs
index 041e7461725..d1948d15749 100644
--- a/crates/collab/src/lib.rs
+++ b/crates/collab/src/lib.rs
@@ -20,7 +20,9 @@ use serde::Deserialize;
 use std::{path::PathBuf, sync::Arc};
 use util::ResultExt;
 
-use crate::services::{DatabaseUserService, UserService};
+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");
@@ -139,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,
 }
 
@@ -176,6 +179,7 @@ impl Config {
             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,
@@ -252,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,
@@ -261,7 +265,19 @@ impl AppState {
             } else {
                 None
             },
-            user_service: Arc::new(DatabaseUserService::new(db)),
+            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/services/user_service.rs b/crates/collab/src/services/user_service.rs
index e2696e99ff2..f2fb968b231 100644
--- a/crates/collab/src/services/user_service.rs
+++ b/crates/collab/src/services/user_service.rs
@@ -1,6 +1,10 @@
 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;
@@ -36,6 +40,149 @@ pub trait UserService: Send + Sync + 'static {
     }
 }
 
+/// 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,
diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs
index 9bfddbca754..d177f63ef8e 100644
--- a/crates/collab/tests/integration/test_server.rs
+++ b/crates/collab/tests/integration/test_server.rs
@@ -592,6 +592,7 @@ impl TestServer {
                 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,

From 954ac0f3cac79178e76d498a7a1456b25df48aeb Mon Sep 17 00:00:00 2001
From: Teslim Olunlade <10700460+ogtega@users.noreply.github.com>
Date: Fri, 8 May 2026 09:17:59 -0500
Subject: [PATCH 94/98] Add conditional check for `auto_discover` in Ollama
 provider (#55999)

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #55998

Release Notes:

- ollama: Fixed issue where specifying `auto_discover: false` would
still auto discover models
---
 crates/language_models/src/provider/ollama.rs | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs
index df9f8f383e0..ea3a6f65035 100644
--- a/crates/language_models/src/provider/ollama.rs
+++ b/crates/language_models/src/provider/ollama.rs
@@ -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

From b8c3167a9de7dca14f4b3a2943dcfadd0ec5fe7a Mon Sep 17 00:00:00 2001
From: Lukas Wirth 
Date: Fri, 8 May 2026 16:18:44 +0200
Subject: [PATCH 95/98] diagnostics: Always expand at least
 `multibuffer_context_lines` per diagnostic (#56172)

Otherwise we can sometimes end up with single line excerpts which looks
very off



Release Notes:

- Improved the minimum size of diagnostics pane excerpts
---
 Cargo.lock                            |  1 +
 crates/diagnostics/Cargo.toml         |  1 +
 crates/diagnostics/src/diagnostics.rs | 27 +++++++++++++++------------
 3 files changed, 17 insertions(+), 12 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index f4626a83d5c..ea52978db69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4941,6 +4941,7 @@ dependencies = [
  "component",
  "ctor",
  "editor",
+ "futures-lite 1.13.0",
  "gpui",
  "indoc",
  "itertools 0.14.0",
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/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs
index 642d16b6e25..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)
 }
@@ -1133,6 +1135,7 @@ async fn heuristic_syntactic_expand(
             return None;
         };
         node = parent;
+        futures_lite::future::yield_now().await;
     }
 }
 

From 6151889ddfc70758f4e45bf52e858601d39f2165 Mon Sep 17 00:00:00 2001
From: Smit Barmase 
Date: Fri, 8 May 2026 20:54:45 +0530
Subject: [PATCH 96/98] markdown: Fix table header alignment and apply
 alignment to cell content (#56179)

Follow up: https://github.com/zed-industries/zed/pull/53465

For Markdown tables, headers are now always centered (ignoring column
alignment), matching standard Markdown rendering behavior. For HTML
tables, headers default to center but respect explicit `align`
attributes.

This also propagates alignment to paragraphs and headings inside table
cells, not just the cell container itself.

Release Notes:

- N/A
---
 crates/markdown/src/html/html_parser.rs | 45 ++++++++++++++
 crates/markdown/src/markdown.rs         | 78 ++++++++++++++++++++++---
 2 files changed, 114 insertions(+), 9 deletions(-)

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/markdown.rs b/crates/markdown/src/markdown.rs index 937e38c3950..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,13 +2013,10 @@ 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.alignments.get(col_index).copied(); - let text_align = match alignment { - Some(Alignment::Left) => TextAlign::Left, - Some(Alignment::Center) => TextAlign::Center, - Some(Alignment::Right) => TextAlign::Right, - _ => self.style.base_text_style.text_align, - }; + 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() @@ -2445,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 { @@ -3474,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| [ ] |"; From 392961ed0dd431b1d66403e16d1ccc6a5a5f5aa9 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 8 May 2026 10:32:59 -0500 Subject: [PATCH 97/98] Fix flaky terminal kill task test (#56194) Failed CI run: https://github.com/zed-industries/zed/actions/runs/25559568951/job/75027378760?pr=56181 test_kill_active_task_on_completed_task_is_noop was flaking on Linux. It's sibling was just waiting 200ms for PTY events to be handled by alacritty. This PR polls the PTY instead of waiting an arbitrary amount of time Release Notes: - N/A --- crates/terminal/src/terminal.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) 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(); From b3c65f94102b830660b8c7c6882262de85096819 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 8 May 2026 17:43:52 +0200 Subject: [PATCH 98/98] bedrock: Always use 1M context window for anthropic models (#56195) Closes #49617 Release Notes: - bedrock: Always use 1M context windows for Anthropic models --- crates/bedrock/src/bedrock.rs | 10 ------- crates/bedrock/src/models.rs | 30 +++++-------------- .../language_models/src/provider/bedrock.rs | 24 ++------------- crates/language_models/src/settings.rs | 1 - crates/settings_content/src/language_model.rs | 2 -- docs/src/ai/llm-providers.md | 19 ------------ 6 files changed, 10 insertions(+), 76 deletions(-) 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/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 97eb5456e5d..b145669d460 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -113,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)] @@ -386,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 { @@ -718,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, @@ -736,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, @@ -746,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(), @@ -839,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(); @@ -1144,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/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/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/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index cf130c35326..1f8104bde24 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -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.