mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Merge remote-tracking branch 'upstream/main' into feat/global-menu
This commit is contained in:
commit
bd25a6c67d
400 changed files with 25183 additions and 16195 deletions
207
Cargo.lock
generated
207
Cargo.lock
generated
|
|
@ -3571,7 +3571,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
"async-process",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"collections",
|
||||
|
|
@ -3960,36 +3959,36 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-assembler-x64"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb1ffe339f197d6645b4d3037edf67c13cd3aa8871f29c2c9c046c729c1b9a17"
|
||||
checksum = "44f81cede359311706057b689b91b59f464926de0316f389898a2b028cb494fa"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-assembler-x64-meta"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e81a21df73d1b12ed19eba481c08de8891e179e1870ed28d6e397f7746108f5"
|
||||
checksum = "fa6ca11305de425ea08884097b913ebe1a83875253b3c0063ce28411e226bfdc"
|
||||
dependencies = [
|
||||
"cranelift-srcgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bforest"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cf917d0180c15c945c13c8dde615d32a015769513b29158f728311d85a8f80d"
|
||||
checksum = "7537341a9a4ba9812141927be733e7254bf2318aab6597d567af9cad90609f27"
|
||||
dependencies = [
|
||||
"cranelift-entity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bitset"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6f4e1af2df00798c2895d228bb53d65c5aa09acace8525096f0b53830ffe42c"
|
||||
checksum = "d28a4ca5faf25ff821fcc768f26e68ffef505e9f71bb06e608862d941fa65086"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
|
|
@ -3997,9 +3996,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-codegen"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e3a5d7300e4b44933dcf2947399945abe3f30f92c789b496ad72949e3ee15a6"
|
||||
checksum = "d891057fe1b73910c41e73b32a70fa8454092fce65942b5fa6f72aa6d5487f8a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"cranelift-assembler-x64",
|
||||
|
|
@ -4027,9 +4026,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-meta"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "becdb5c3111800d7f8e666fe5f35693bfc77de4401bfcaea19815caf7c482fb9"
|
||||
checksum = "c29a66028a78eedc534b3a94e5ebfbaeb4e1f6b09038afe41bb24afd614faa4b"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
"cranelift-codegen-shared",
|
||||
|
|
@ -4040,24 +4039,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-shared"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8fa77efffa12934971f757e154b16dd5e369a7f388a0f3adff74aadfd4c5a1d"
|
||||
checksum = "95809ad251fe9422087b4a72d61e584d6ab6eff44dee1335f93cfaea0bedc9ac"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-control"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62441d3aae3372381e03a121880482158ce90ca3bc2a56607cc122ee07536fe4"
|
||||
checksum = "f79d0cacf063c297e5e8d5b73cb355b41b87f6d248e252d1b284e7a7b73673c2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-entity"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bdc9832a010e0d411439aa016e1664dd23ca5c8953bf26b90fe34ad4b76822d"
|
||||
checksum = "b2d73297a195ce3be55997c6307142c4b1e58dd0c2f18ceaa0179444024e312a"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"serde",
|
||||
|
|
@ -4066,9 +4065,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-frontend"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9530b689b7c3accdbb32263ca318e19ab3bcf616d3a160c8456537c99b4c565b"
|
||||
checksum = "3be38d1ae29ef7c5d611fc6cb694f698dc4ca44152dcaa112ec0fef8d4d34858"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"log",
|
||||
|
|
@ -4078,15 +4077,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-isle"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fcd3258a4d87376f2681c72269a42009286a3d3707b2af4024ba5b3750ad477"
|
||||
checksum = "6761926f6636209de7ac568be28b206890f2181761375b9722e0a1e7a7e1637a"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-native"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "642c5703a22b58abccbf46f46c0dae65f0535bbe725beec70527a1ffcbbc1d34"
|
||||
checksum = "0893472f73f0d530a28e9a573ada6d1f93b9659bb6734dfe17061ac967bd1830"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"libc",
|
||||
|
|
@ -4095,9 +4094,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cranelift-srcgen"
|
||||
version = "0.123.8"
|
||||
version = "0.123.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d200dcd5a37de108ec1329e0ba924e2badd2c0ef2343c338310135159ae454e2"
|
||||
checksum = "c1daccebabb1ccd034dbab0eacc0722af27d3cccc7929dea27a3546cb3562e40"
|
||||
|
||||
[[package]]
|
||||
name = "crash-context"
|
||||
|
|
@ -4942,6 +4941,7 @@ dependencies = [
|
|||
"component",
|
||||
"ctor",
|
||||
"editor",
|
||||
"futures-lite 1.13.0",
|
||||
"gpui",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
|
|
@ -5021,7 +5021,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5397,8 +5397,8 @@ dependencies = [
|
|||
name = "edit_prediction_metrics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"imara-diff",
|
||||
"indoc",
|
||||
"language",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -5549,6 +5549,24 @@ dependencies = [
|
|||
"ztracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "editor_benchmarks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"editor",
|
||||
"gpui",
|
||||
"gpui_platform",
|
||||
"language",
|
||||
"multi_buffer",
|
||||
"project",
|
||||
"release_channel",
|
||||
"semver",
|
||||
"settings",
|
||||
"theme",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
|
|
@ -5811,7 +5829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7269,7 +7287,7 @@ dependencies = [
|
|||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8599,7 +8617,7 @@ dependencies = [
|
|||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -8617,7 +8635,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.56.0",
|
||||
"windows-core 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -11266,7 +11284,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -13881,9 +13899,9 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
|
|||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35eaba3163b9faf1d707f0704a7370bfdbe73622c766acdaf1fa4addb87510de"
|
||||
checksum = "8b78fdec962b639b921badfcfe77db7d18aa3c0c1e292ac2aa268c0efe8fe683"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"log",
|
||||
|
|
@ -13893,9 +13911,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pulley-macros"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac294897a29ce07919714f9f25c11a819d75759d47eb9f3273845ffea5a5760d"
|
||||
checksum = "f718f4e8cd5fdfa08b3b1d2d25fe288350051be330544305f0a9b93a937b3d42"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -13998,7 +14016,7 @@ dependencies = [
|
|||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls 0.23.40",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -14035,9 +14053,9 @@ dependencies = [
|
|||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -14919,9 +14937,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.3.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
|
||||
checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -14941,9 +14959,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "1.3.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43"
|
||||
checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
|
|
@ -15016,13 +15034,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
|||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.4.0"
|
||||
version = "7.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
|
||||
checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -15253,7 +15271,7 @@ dependencies = [
|
|||
"errno 0.3.14",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -16314,6 +16332,7 @@ dependencies = [
|
|||
"git",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"language_model",
|
||||
"log",
|
||||
|
|
@ -17741,7 +17760,7 @@ dependencies = [
|
|||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.2",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19936,9 +19955,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2060d93be880840d764ab537464b916e22c07758ac5d43e5f07cc86fec6d1bec"
|
||||
checksum = "b10306ead921db2c4645ff99867b7539b65e18afd8816d471547f5e6f3b09492"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"anyhow",
|
||||
|
|
@ -19997,9 +20016,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-environ"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "902f991ca8c2e5abc03119eb5d7f7f57da1b7c2123addb8214b49c188737711e"
|
||||
checksum = "e7fb2c37ca263d444f33871bf0221e7de0707b2b2bb88165df6db6d58c73375f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cpp_demangle",
|
||||
|
|
@ -20024,9 +20043,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-asm-macros"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b02cec619b54ce7652d1d7676718a42ccf5f16b2fb23c27cd6e3c307bc93907a"
|
||||
checksum = "19c6c0d3c8d2db554a3af8e8d413ff2815362ebce0911808ecfdaaa257438f93"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
|
@ -20043,9 +20062,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-component-macro"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fad82a87bc24b6014c5271e1558e466fd029dcc80896f143b3693394a162f3be"
|
||||
checksum = "c3e3f3752466eb0e1f97149e53bf15c0e18ff520fc0a98b4bee1680e6de1c6f0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
|
@ -20058,15 +20077,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-component-util"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bc24aba0bfd3d39fa8f0012835bc4d4efc75b1350b5e519181319eb8bb306b2"
|
||||
checksum = "7f54018baf62f4e9c616c31f2aeadcf0c202ff691a390ad53e291ae7160b169e"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-cranelift"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54eb7fc20c8692dc96148365d7a00a1b79fee810833c75bdf8ec073a46e4721a"
|
||||
checksum = "5a2412f2afb0a5db2a4ac1cfff73247e240aeaa90bf41497ad0a5084b6a24eca"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
|
|
@ -20091,9 +20110,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-fiber"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30708e122dcc1e175c66345c209c01752ca0cd20c9021721b6f56968342e9dbe"
|
||||
checksum = "ecfdc460dd5d343d88ff1ffaf65ae019feeb6124ddcfd3f39d28331068d25b1f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
|
|
@ -20107,9 +20126,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-jit-debug"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1eeaab071a646d9ae205266adf186c63fa6d077d36b0b33628dd6c3d321d3195"
|
||||
checksum = "b5abb428a71827b7f90fc64406749883ccc6e58addf6d36974d5e06942011707"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"wasmtime-internal-versioned-export-macros",
|
||||
|
|
@ -20117,9 +20136,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-jit-icache-coherence"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09979561e6e4a17bf55722463b066ccb968f010ac6ec5d647e4dff19eddbb19e"
|
||||
checksum = "ba6cc13f14c3fb83fb877cb1d5c605e93f7ec1bf7fc1a5e8b361209d2f8ca028"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
|
|
@ -20129,24 +20148,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-math"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9193eb852e5c68aeb95a5ea7538c2bec503023169a0b24430224b4f1ded24988"
|
||||
checksum = "1cb209473a09f4dbd9c87bb9f18b8dcb0c9da30d12a260e3eacf7a1a53b41480"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-slab"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "289bfa4fbb43f406f36166737f1f25522c215ef2ef11f98423089a6a7590a3d1"
|
||||
checksum = "aab4df5a04752106e1ecef9d40145ef28fa033b0d5dd3c839c9b208b2d522183"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-unwinder"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e748c970993865d9bf474465c3f10f96e541c472bc8f7ec0b031779f4ac29c6"
|
||||
checksum = "5359875d29bddb6f7e65e698157714d8d35ebd8ea2a92893d05d6b062147b639"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
|
|
@ -20157,9 +20176,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-versioned-export-macros"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e97e07438cb8b50df3bc9659c56757830a15235c94268dbbd54186524fd4ed84"
|
||||
checksum = "2e247bcdd69701743ba386c933b26ebad2ce912ff9cb68b5b71fdb29d39ba04a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -20168,9 +20187,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-winch"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "107aa0c3f71cc590c786d6d6e09893558b383f4d78107b864a9fd978929d0244"
|
||||
checksum = "d0298dfd9f57588222b5a92dcffe75894f1ead4e519850f176bde7fcfd105d54"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cranelift-codegen",
|
||||
|
|
@ -20185,9 +20204,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-internal-wit-bindgen"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeb3d8e4efdaae10aa01264e9946bba507e53707125dd0aa8584b5e13229a3c0"
|
||||
checksum = "1706803e83b9bae726a0f55e7c1bbf78a7421cf2da68c940c70978e91dfc0339"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.10.0",
|
||||
|
|
@ -20198,9 +20217,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-wasi"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86fffc455304d2750ea2456394cdf6513d8771eb5b256876685b8bb9413bfb0e"
|
||||
checksum = "1a430602ec54d0e32fbb61d2d8c7e5885eaa9dbc1664b6ed57fb57df439810a0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -20229,9 +20248,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasmtime-wasi-io"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5666a220e8318309225b54a55b270e1b506385adcce10bf5698380441afa0df3"
|
||||
checksum = "8b2ba5dd68962de394cf15c7fb185f138cdd685ced631a7ed8e056de3e071029"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -20696,9 +20715,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wiggle"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e176546937d1311c7608276c8511d3ea9b8e7b916e89b720e12c4d4bbae067c"
|
||||
checksum = "1979d3ed3ffc017538e518da6faa66b129f9229492981fc51004f28cb86db792"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -20711,9 +20730,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wiggle-generate"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3f012ad76133d9ac70633c7f954e289fb4c21986059f324fec3c476664ab643"
|
||||
checksum = "25d92ae7a084d8543aa7ccef0fac52c86481a7278d0533f7fdeaf89bd7b7e29f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
|
|
@ -20725,9 +20744,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wiggle-macro"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4301e6203d3d13eef139fa3aca5f04e9156b4a5f7636ca965b2c10bce410b3d2"
|
||||
checksum = "36a1b1b93fd9ce569bb40c1eadf5c56533cebfc04ba545c8bc1e74464cff0735"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -20757,7 +20776,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -20768,9 +20787,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
|||
|
||||
[[package]]
|
||||
name = "winch-codegen"
|
||||
version = "36.0.8"
|
||||
version = "36.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "646e2d01f59d7006e24a370762abfb63d5918696ff02197e027efd15252a1f79"
|
||||
checksum = "2e2d7ea2137be52644d9c42ca5a4899bba07c2ed2db1e66c4c1994adfe35d39e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cranelift-assembler-x64",
|
||||
|
|
@ -22379,7 +22398,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"acp_thread",
|
||||
"acp_tools",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1247,6 +1247,12 @@
|
|||
"ctrl->": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel && Terminal",
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "ZedPredictModal",
|
||||
"bindings": {
|
||||
|
|
@ -1499,6 +1505,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,13 @@
|
|||
"cmd->": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "RatePredictionsModal",
|
||||
"use_key_equivalents": true,
|
||||
|
|
@ -1552,6 +1559,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"cmd-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1262,6 +1262,13 @@
|
|||
"ctrl-shift-.": "agent::AddSelectionToThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "Terminal && selection",
|
||||
"bindings": {
|
||||
|
|
@ -1479,6 +1486,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,7 @@
|
|||
"diagnostics": true,
|
||||
"apply_code_action": true,
|
||||
"edit_file": true,
|
||||
"write_file": true,
|
||||
"fetch": true,
|
||||
"find_path": true,
|
||||
"find_references": true,
|
||||
|
|
@ -1121,8 +1122,6 @@
|
|||
"now": true,
|
||||
"rename_symbol": true,
|
||||
"read_file": true,
|
||||
"restore_file_from_disk": true,
|
||||
"save_file": true,
|
||||
"open": true,
|
||||
"grep": true,
|
||||
"spawn_agent": true,
|
||||
|
|
@ -2514,6 +2513,9 @@
|
|||
"gdefault": false,
|
||||
"highlight_on_yank_duration": 200,
|
||||
"custom_digraphs": {},
|
||||
// When enabled, edit predictions are shown in Vim normal mode.
|
||||
// By default, edit predictions are only shown in insert and replace modes.
|
||||
"show_edit_predictions_in_normal_mode": false,
|
||||
// Cursor shape for each mode.
|
||||
// The shape can be one of the following: "block", "bar", "underline", "hollow".
|
||||
"cursor_shape": {
|
||||
|
|
|
|||
|
|
@ -570,6 +570,22 @@ impl From<RequestPermissionOutcome> 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<SelectedPermissionOutcome>,
|
||||
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<Self>,
|
||||
) -> Result<Task<RequestPermissionOutcome>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
|
@ -2087,6 +2105,7 @@ impl AcpThread {
|
|||
let status = ToolCallStatus::WaitingForConfirmation {
|
||||
options,
|
||||
respond_tx: tx,
|
||||
kind,
|
||||
};
|
||||
|
||||
let tool_call_id = tool_call.tool_call_id.clone();
|
||||
|
|
@ -2118,15 +2137,25 @@ impl AcpThread {
|
|||
return;
|
||||
};
|
||||
|
||||
let new_status = match outcome.option_kind {
|
||||
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
|
||||
ToolCallStatus::Rejected
|
||||
let is_action_choice = matches!(
|
||||
call.status,
|
||||
ToolCallStatus::WaitingForConfirmation {
|
||||
kind: AuthorizationKind::ActionChoice,
|
||||
..
|
||||
}
|
||||
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => {
|
||||
);
|
||||
let new_status =
|
||||
if is_action_choice {
|
||||
ToolCallStatus::InProgress
|
||||
}
|
||||
_ => ToolCallStatus::InProgress,
|
||||
};
|
||||
} else {
|
||||
match outcome.option_kind {
|
||||
acp::PermissionOptionKind::RejectOnce
|
||||
| acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected,
|
||||
acp::PermissionOptionKind::AllowOnce
|
||||
| acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress,
|
||||
_ => ToolCallStatus::InProgress,
|
||||
}
|
||||
};
|
||||
|
||||
let curr_status = mem::replace(&mut call.status, new_status);
|
||||
|
||||
|
|
@ -2294,10 +2323,6 @@ impl AcpThread {
|
|||
this.project
|
||||
.update(cx, |project, cx| project.set_agent_location(None, cx));
|
||||
}
|
||||
let Ok(response) = response else {
|
||||
// tx dropped, just return
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let is_same_turn = this
|
||||
.running_turn
|
||||
|
|
@ -2306,11 +2331,18 @@ impl AcpThread {
|
|||
|
||||
// If the user submitted a follow up message, running_turn might
|
||||
// already point to a different turn. Therefore we only want to
|
||||
// take the task if it's the same turn.
|
||||
// take the task if it's the same turn. We do this before the
|
||||
// dropped-tx guard below so the panel exits its generating
|
||||
// state even when the send_task is cancelled before tx.send().
|
||||
if is_same_turn {
|
||||
this.running_turn.take();
|
||||
}
|
||||
|
||||
let Ok(response) = response else {
|
||||
// tx dropped, just return
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match response {
|
||||
Ok(r) => {
|
||||
Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
|
||||
|
|
@ -5517,4 +5549,63 @@ mod tests {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression test: if the inner send_task is cancelled before it can
|
||||
/// fire `tx.send(...)` (e.g. because the underlying future was dropped),
|
||||
/// the outer task observes `rx.await` returning `Err(Cancelled)` and
|
||||
/// must still clear `running_turn` so the panel transitions out of
|
||||
/// `Generating`. Without this, the agent thread is wedged in the
|
||||
/// loading state until Zed restarts.
|
||||
#[gpui::test]
|
||||
async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
|
||||
// Handler hangs forever so the spawn at run_turn is parked inside
|
||||
// `f(this, cx).await` with `tx` still alive but unsent.
|
||||
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
|
||||
|_params, _thread, _cx| {
|
||||
async move { futures::future::pending::<Result<acp::PromptResponse>>().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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ pub trait AgentConnection {
|
|||
|
||||
fn telemetry_id(&self) -> SharedString;
|
||||
|
||||
fn agent_version(&self) -> Option<SharedString> {
|
||||
None
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
@ -637,6 +641,8 @@ mod test_support {
|
|||
use gpui::{AppContext as _, WeakEntity};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::AuthorizationKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Creates a PNG image encoded as base64 for testing.
|
||||
|
|
@ -911,6 +917,7 @@ mod test_support {
|
|||
thread.request_tool_call_authorization(
|
||||
tool_call.clone().into(),
|
||||
options.clone(),
|
||||
AuthorizationKind::PermissionGrant,
|
||||
cx,
|
||||
)
|
||||
})??
|
||||
|
|
|
|||
|
|
@ -817,4 +817,9 @@ impl StatusItemView for ActivityIndicator {
|
|||
_: &mut Context<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
fn hide_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
|
||||
// Activity indicator auto-hides when there's no work to display.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ use futures::future::Shared;
|
|||
use futures::{FutureExt as _, StreamExt as _, future};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
TaskExt, WeakEntity,
|
||||
};
|
||||
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
|
||||
use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree};
|
||||
|
|
@ -1298,9 +1298,12 @@ impl NativeAgentConnection {
|
|||
options,
|
||||
response,
|
||||
context: _,
|
||||
kind,
|
||||
}) => {
|
||||
let outcome_task = acp_thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(tool_call, options, cx)
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call, options, kind, cx,
|
||||
)
|
||||
})??;
|
||||
cx.background_spawn(async move {
|
||||
if let acp_thread::RequestPermissionOutcome::Selected(outcome) =
|
||||
|
|
|
|||
|
|
@ -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<dyn LanguageModel>),
|
||||
cx,
|
||||
);
|
||||
thread.add_default_tools(environment, cx);
|
||||
thread
|
||||
});
|
||||
|
||||
let lsp_tool_names = [
|
||||
FindReferencesTool::NAME,
|
||||
GetCodeActionsTool::NAME,
|
||||
ApplyCodeActionTool::NAME,
|
||||
GoToDefinitionTool::NAME,
|
||||
RenameTool::NAME,
|
||||
];
|
||||
|
||||
// All LSP tools should be registered on the thread regardless of the flag,
|
||||
// since the feature flag now only controls exposure to the model rather
|
||||
// than registration.
|
||||
thread.read_with(cx, |thread, _| {
|
||||
for name in &lsp_tool_names {
|
||||
assert!(
|
||||
thread.has_registered_tool(name),
|
||||
"expected LSP tool {name} to be registered"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Without the `lsp-tool` flag, sending a message should produce a
|
||||
// completion request whose tool list excludes the LSP tools.
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["hello"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
let completion = model.pending_completions().pop().unwrap();
|
||||
let tool_names = tool_names_for_completion(&completion);
|
||||
for name in &lsp_tool_names {
|
||||
assert!(
|
||||
!tool_names.iter().any(|t| t == name),
|
||||
"expected LSP tool {name} to be hidden without the lsp-tool flag, \
|
||||
but completion tools were: {tool_names:?}"
|
||||
);
|
||||
}
|
||||
// Sanity check: a non-LSP default tool should still be exposed.
|
||||
assert!(
|
||||
tool_names.iter().any(|t| t == ReadFileTool::NAME),
|
||||
"expected non-LSP tools to still be exposed, got: {tool_names:?}"
|
||||
);
|
||||
model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Enable the `lsp-tool` flag and send another message; the LSP tools
|
||||
// should now appear in the completion request.
|
||||
cx.update(|cx| {
|
||||
cx.update_flags(false, vec!["lsp-tool".to_string()]);
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["hello again"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
let completion = model.pending_completions().pop().unwrap();
|
||||
let tool_names = tool_names_for_completion(&completion);
|
||||
for name in &lsp_tool_names {
|
||||
assert!(
|
||||
tool_names.iter().any(|t| t == name),
|
||||
"expected LSP tool {name} to be exposed when lsp-tool flag is on, \
|
||||
but completion tools were: {tool_names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_parent_cancel_stops_subagent(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -6062,9 +6161,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/sensitive_config.txt".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -6294,112 +6391,6 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"normal.txt": "normal content",
|
||||
"readonly": {
|
||||
"config.txt": "readonly content"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
SaveFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::SaveFileTool::new(project));
|
||||
let (event_stream, _rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(crate::SaveFileToolInput {
|
||||
paths: vec![
|
||||
std::path::PathBuf::from("root/normal.txt"),
|
||||
std::path::PathBuf::from("root/readonly/config.txt"),
|
||||
],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let result = task.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected save to be blocked due to denied path"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("blocked"),
|
||||
"error should mention the save was blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/root", json!({"config.secret": "secret config"}))
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
SaveFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::SaveFileTool::new(project));
|
||||
let (event_stream, _rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(crate::SaveFileToolInput {
|
||||
paths: vec![std::path::PathBuf::from("root/config.secret")],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let result = task.await;
|
||||
assert!(result.is_err(), "expected save to be blocked");
|
||||
assert!(
|
||||
result.unwrap_err().contains("blocked"),
|
||||
"error should mention the save was blocked"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -6496,9 +6487,7 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/README.md".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -6568,9 +6557,7 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/.zed/settings.json".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use crate::{
|
|||
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
|
||||
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
|
||||
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
|
||||
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
|
||||
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool,
|
||||
SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool,
|
||||
ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
|
||||
decide_permission_from_settings,
|
||||
};
|
||||
use acp_thread::{MentionUri, UserMessageId};
|
||||
|
|
@ -822,9 +822,9 @@ impl ToolPermissionContext {
|
|||
} else if tool_name == CopyPathTool::NAME
|
||||
|| tool_name == MovePathTool::NAME
|
||||
|| tool_name == EditFileTool::NAME
|
||||
|| tool_name == WriteFileTool::NAME
|
||||
|| tool_name == DeletePathTool::NAME
|
||||
|| tool_name == CreateDirectoryTool::NAME
|
||||
|| tool_name == SaveFileTool::NAME
|
||||
{
|
||||
(
|
||||
extract_path_pattern(value),
|
||||
|
|
@ -924,6 +924,7 @@ pub struct ToolCallAuthorization {
|
|||
pub options: acp_thread::PermissionOptions,
|
||||
pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
|
||||
pub context: Option<ToolPermissionContext>,
|
||||
pub kind: acp_thread::AuthorizationKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -1544,6 +1545,12 @@ impl Thread {
|
|||
self.action_log.clone(),
|
||||
));
|
||||
self.add_tool(EditFileTool::new(
|
||||
self.project.clone(),
|
||||
cx.weak_entity(),
|
||||
self.action_log.clone(),
|
||||
language_registry.clone(),
|
||||
));
|
||||
self.add_tool(WriteFileTool::new(
|
||||
self.project.clone(),
|
||||
cx.weak_entity(),
|
||||
self.action_log.clone(),
|
||||
|
|
@ -1564,26 +1571,23 @@ impl Thread {
|
|||
self.action_log.clone(),
|
||||
update_agent_location,
|
||||
));
|
||||
self.add_tool(SaveFileTool::new(self.project.clone()));
|
||||
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
|
||||
self.add_tool(TerminalTool::new(self.project.clone(), environment.clone()));
|
||||
self.add_tool(WebSearchTool);
|
||||
|
||||
self.add_tool(DiagnosticsTool::new(self.project.clone()));
|
||||
if cx.has_flag::<LspToolFeatureFlag>() {
|
||||
let code_action_store: CodeActionStore = cx.new(|_cx| None);
|
||||
self.add_tool(FindReferencesTool::new(self.project.clone()));
|
||||
self.add_tool(GetCodeActionsTool::new(
|
||||
self.project.clone(),
|
||||
code_action_store.clone(),
|
||||
));
|
||||
self.add_tool(ApplyCodeActionTool::new(
|
||||
self.project.clone(),
|
||||
code_action_store,
|
||||
));
|
||||
self.add_tool(GoToDefinitionTool::new(self.project.clone()));
|
||||
self.add_tool(RenameTool::new(self.project.clone()));
|
||||
}
|
||||
|
||||
let code_action_store: CodeActionStore = cx.new(|_cx| None);
|
||||
self.add_tool(FindReferencesTool::new(self.project.clone()));
|
||||
self.add_tool(GetCodeActionsTool::new(
|
||||
self.project.clone(),
|
||||
code_action_store.clone(),
|
||||
));
|
||||
self.add_tool(ApplyCodeActionTool::new(
|
||||
self.project.clone(),
|
||||
code_action_store,
|
||||
));
|
||||
self.add_tool(GoToDefinitionTool::new(self.project.clone()));
|
||||
self.add_tool(RenameTool::new(self.project.clone()));
|
||||
|
||||
if self.depth() < MAX_SUBAGENT_DEPTH {
|
||||
self.add_tool(SpawnAgentTool::new(environment));
|
||||
|
|
@ -2887,6 +2891,17 @@ impl Thread {
|
|||
None
|
||||
}
|
||||
})
|
||||
.filter(|(tool_name, _)| {
|
||||
cx.has_flag::<LspToolFeatureFlag>()
|
||||
|| !matches!(
|
||||
tool_name.as_ref(),
|
||||
FindReferencesTool::NAME
|
||||
| GetCodeActionsTool::NAME
|
||||
| ApplyCodeActionTool::NAME
|
||||
| GoToDefinitionTool::NAME
|
||||
| RenameTool::NAME
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let mut context_server_tools = Vec::new();
|
||||
|
|
@ -2950,10 +2965,6 @@ impl Thread {
|
|||
self.tools.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn registered_tool_names(&self) -> Vec<SharedString> {
|
||||
self.tools.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
|
||||
self.running_subagents.push(subagent);
|
||||
}
|
||||
|
|
@ -3865,6 +3876,57 @@ impl ToolCallEventStream {
|
|||
self.run_authorization_loop(title, options, Some(context), None, cx)
|
||||
}
|
||||
|
||||
/// Prompts the user to choose between an explicit set of actions and
|
||||
/// returns the chosen `option_id`.
|
||||
///
|
||||
/// Unlike [`Self::authorize`] / [`Self::authorize_always_prompt`], this
|
||||
/// does not interpret the user's choice as a permission grant — callers
|
||||
/// are responsible for handling each `option_id` explicitly. Use this
|
||||
/// when a tool needs the user to pick between several side-effecting
|
||||
/// actions (for example, "Save" vs "Discard" for a dirty buffer).
|
||||
pub fn prompt_for_decision(
|
||||
&self,
|
||||
title: Option<String>,
|
||||
message: Option<String>,
|
||||
options: Vec<acp::PermissionOption>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PermissionOptionId>> {
|
||||
let options = acp_thread::PermissionOptions::Flat(options);
|
||||
let stream = self.stream.clone();
|
||||
let tool_use_id = self.tool_use_id.clone();
|
||||
cx.spawn(async move |_cx| {
|
||||
let mut fields = acp::ToolCallUpdateFields::new();
|
||||
if let Some(title) = title {
|
||||
fields = fields.title(title);
|
||||
}
|
||||
if let Some(message) = message {
|
||||
fields = fields.content(vec![acp::ToolCallContent::from(message)]);
|
||||
}
|
||||
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if let Err(error) = stream
|
||||
.0
|
||||
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
||||
ToolCallAuthorization {
|
||||
tool_call: acp::ToolCallUpdate::new(tool_use_id.to_string(), fields),
|
||||
options,
|
||||
response: response_tx,
|
||||
context: None,
|
||||
kind: acp_thread::AuthorizationKind::ActionChoice,
|
||||
},
|
||||
)))
|
||||
{
|
||||
log::error!("Failed to send tool call decision prompt: {error}");
|
||||
return Err(anyhow!("Failed to send tool call decision prompt: {error}"));
|
||||
}
|
||||
|
||||
let outcome = response_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("authorization channel closed"))?;
|
||||
Ok(outcome.option_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Prompts the user for authorization.
|
||||
///
|
||||
/// When `check_settings` is `Some`, this gate is settings-driven: the
|
||||
|
|
@ -3912,6 +3974,7 @@ impl ToolCallEventStream {
|
|||
options,
|
||||
response: response_tx,
|
||||
context,
|
||||
kind: acp_thread::AuthorizationKind::PermissionGrant,
|
||||
},
|
||||
)))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod create_directory_tool;
|
|||
mod delete_path_tool;
|
||||
mod diagnostics_tool;
|
||||
mod edit_file_tool;
|
||||
mod edit_session;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod evals;
|
||||
mod fetch_tool;
|
||||
|
|
@ -19,14 +20,13 @@ mod now_tool;
|
|||
mod open_tool;
|
||||
mod read_file_tool;
|
||||
mod rename_tool;
|
||||
mod restore_file_from_disk_tool;
|
||||
mod save_file_tool;
|
||||
mod spawn_agent_tool;
|
||||
mod symbol_locator;
|
||||
mod terminal_tool;
|
||||
mod tool_permissions;
|
||||
mod update_plan_tool;
|
||||
mod web_search_tool;
|
||||
mod write_file_tool;
|
||||
|
||||
use crate::AgentTool;
|
||||
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
|
||||
|
|
@ -77,14 +77,13 @@ pub use now_tool::*;
|
|||
pub use open_tool::*;
|
||||
pub use read_file_tool::*;
|
||||
pub use rename_tool::*;
|
||||
pub use restore_file_from_disk_tool::*;
|
||||
pub use save_file_tool::*;
|
||||
pub use spawn_agent_tool::*;
|
||||
pub use symbol_locator::*;
|
||||
pub use terminal_tool::*;
|
||||
pub use tool_permissions::*;
|
||||
pub use update_plan_tool::*;
|
||||
pub use web_search_tool::*;
|
||||
pub use write_file_tool::*;
|
||||
|
||||
macro_rules! tools {
|
||||
($($tool:ty),* $(,)?) => {
|
||||
|
|
@ -173,10 +172,9 @@ tools! {
|
|||
OpenTool,
|
||||
ReadFileTool,
|
||||
RenameTool,
|
||||
RestoreFileFromDiskTool,
|
||||
SaveFileTool,
|
||||
SpawnAgentTool,
|
||||
TerminalTool,
|
||||
UpdatePlanTool,
|
||||
WebSearchTool,
|
||||
WriteFileTool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1132
crates/agent/src/tools/edit_session.rs
Normal file
1132
crates/agent/src/tools/edit_session.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{Edit, PartialEdit};
|
||||
use super::{Edit, PartialEdit};
|
||||
|
||||
/// Events emitted by `StreamingParser` for edit-mode input.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
|
@ -33,6 +33,8 @@ struct EditStreamState {
|
|||
old_text_done: bool,
|
||||
new_text_emitted_len: usize,
|
||||
new_text_done: bool,
|
||||
hold_until_complete: bool,
|
||||
buffer_new_text_until_old_text_done: bool,
|
||||
}
|
||||
|
||||
/// Converts incrementally-growing tool call JSON into a stream of chunk events.
|
||||
|
|
@ -68,7 +70,15 @@ impl StreamingParser {
|
|||
for (index, partial) in edits.iter().enumerate() {
|
||||
if index >= self.edit_states.len() {
|
||||
// A new edit appeared — finalize the previous one if there was one.
|
||||
if let Some(previous) = self.finalize_previous_edit(index) {
|
||||
if let Some(previous) = self.finalize_previous_edit(
|
||||
index,
|
||||
edits
|
||||
.get(index.saturating_sub(1))
|
||||
.and_then(|edit| edit.old_text.as_deref()),
|
||||
edits
|
||||
.get(index.saturating_sub(1))
|
||||
.and_then(|edit| edit.new_text.as_deref()),
|
||||
) {
|
||||
events.extend(previous);
|
||||
}
|
||||
self.edit_states.push(EditStreamState::default());
|
||||
|
|
@ -76,12 +86,33 @@ impl StreamingParser {
|
|||
|
||||
let state = &mut self.edit_states[index];
|
||||
|
||||
if state.old_text_emitted_len == 0
|
||||
&& state.new_text_emitted_len == 0
|
||||
&& !state.old_text_done
|
||||
&& partial.new_text.is_some()
|
||||
&& !state.buffer_new_text_until_old_text_done
|
||||
{
|
||||
if partial
|
||||
.old_text
|
||||
.as_ref()
|
||||
.is_some_and(|old_text| !old_text.is_empty())
|
||||
{
|
||||
state.hold_until_complete = true;
|
||||
} else {
|
||||
state.buffer_new_text_until_old_text_done = true;
|
||||
}
|
||||
}
|
||||
|
||||
if state.hold_until_complete {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process old_text changes.
|
||||
if let Some(old_text) = &partial.old_text
|
||||
&& !state.old_text_done
|
||||
{
|
||||
if partial.new_text.is_some() {
|
||||
// new_text appeared, so old_text is done — emit everything.
|
||||
if partial.new_text.is_some() && !state.buffer_new_text_until_old_text_done {
|
||||
// new_text appeared after old_text, so old_text is done — emit everything.
|
||||
let start = state.old_text_emitted_len.min(old_text.len());
|
||||
let chunk = normalize_done_chunk(old_text[start..].to_string());
|
||||
state.old_text_done = true;
|
||||
|
|
@ -108,6 +139,7 @@ impl StreamingParser {
|
|||
|
||||
// Process new_text changes.
|
||||
if let Some(new_text) = &partial.new_text
|
||||
&& state.old_text_done
|
||||
&& !state.new_text_done
|
||||
{
|
||||
let safe_end = safe_emit_end_for_edit_text(new_text);
|
||||
|
|
@ -157,7 +189,15 @@ impl StreamingParser {
|
|||
for (index, edit) in edits.iter().enumerate() {
|
||||
if index >= self.edit_states.len() {
|
||||
// This edit was never seen in partials — emit it fully.
|
||||
if let Some(previous) = self.finalize_previous_edit(index) {
|
||||
if let Some(previous) = self.finalize_previous_edit(
|
||||
index,
|
||||
edits
|
||||
.get(index.saturating_sub(1))
|
||||
.map(|edit| edit.old_text.as_str()),
|
||||
edits
|
||||
.get(index.saturating_sub(1))
|
||||
.map(|edit| edit.new_text.as_str()),
|
||||
) {
|
||||
events.extend(previous);
|
||||
}
|
||||
self.edit_states.push(EditStreamState::default());
|
||||
|
|
@ -165,6 +205,26 @@ impl StreamingParser {
|
|||
|
||||
let state = &mut self.edit_states[index];
|
||||
|
||||
if state.hold_until_complete {
|
||||
state.old_text_done = true;
|
||||
state.old_text_emitted_len = edit.old_text.len();
|
||||
state.new_text_done = true;
|
||||
state.new_text_emitted_len = edit.new_text.len();
|
||||
state.hold_until_complete = false;
|
||||
state.buffer_new_text_until_old_text_done = false;
|
||||
events.push(EditEvent::OldTextChunk {
|
||||
edit_index: index,
|
||||
chunk: normalize_done_chunk(edit.old_text.clone()),
|
||||
done: true,
|
||||
});
|
||||
events.push(EditEvent::NewTextChunk {
|
||||
edit_index: index,
|
||||
chunk: normalize_done_chunk(edit.new_text.clone()),
|
||||
done: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if !state.old_text_done {
|
||||
let start = state.old_text_emitted_len.min(edit.old_text.len());
|
||||
let chunk = normalize_done_chunk(edit.old_text[start..].to_string());
|
||||
|
|
@ -209,7 +269,12 @@ impl StreamingParser {
|
|||
|
||||
/// When a new edit appears at `index`, finalize the edit at `index - 1`
|
||||
/// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized.
|
||||
fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[EditEvent; 2]>> {
|
||||
fn finalize_previous_edit(
|
||||
&mut self,
|
||||
new_index: usize,
|
||||
old_text: Option<&str>,
|
||||
new_text: Option<&str>,
|
||||
) -> Option<SmallVec<[EditEvent; 2]>> {
|
||||
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());
|
||||
}
|
||||
|
|
@ -1,2 +1,52 @@
|
|||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
use futures::future::LocalBoxFuture;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
use gpui::TestAppContext;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod edit_file;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod terminal_tool;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod write_file;
|
||||
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
fn run_gpui_eval<T>(
|
||||
eval: impl for<'a> FnOnce(&'a mut TestAppContext) -> LocalBoxFuture<'a, anyhow::Result<T>>,
|
||||
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: (),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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<impl Into<String>>) -> Self {
|
||||
let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect();
|
||||
Self::new(async move |sample, _judge, _cx| {
|
||||
|
|
@ -562,33 +547,25 @@ impl EditToolTest {
|
|||
}
|
||||
|
||||
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
|
||||
let dispatcher = gpui::TestDispatcher::new(rand::random());
|
||||
let mut cx = TestAppContext::build(dispatcher, None);
|
||||
let foreground_executor = cx.foreground_executor().clone();
|
||||
let result = foreground_executor.block_test(async {
|
||||
let test = EditToolTest::new(&mut cx).await;
|
||||
let result = test.eval(eval, &mut cx).await;
|
||||
drop(test);
|
||||
cx.run_until_parked();
|
||||
result
|
||||
});
|
||||
cx.quit();
|
||||
match result {
|
||||
Ok(output) => eval_utils::EvalOutput {
|
||||
data: output.to_string(),
|
||||
outcome: if output.assertion.score < 80 {
|
||||
super::run_gpui_eval(
|
||||
|cx| {
|
||||
async move {
|
||||
let test = EditToolTest::new(cx).await;
|
||||
let result = test.eval(eval, cx).await;
|
||||
drop(test);
|
||||
cx.run_until_parked();
|
||||
result
|
||||
}
|
||||
.boxed_local()
|
||||
},
|
||||
|output| {
|
||||
if output.assertion.score < 80 {
|
||||
eval_utils::OutcomeKind::Failed
|
||||
} else {
|
||||
eval_utils::OutcomeKind::Passed
|
||||
},
|
||||
metadata: (),
|
||||
}
|
||||
},
|
||||
Err(err) => eval_utils::EvalOutput {
|
||||
data: format!("{err:?}"),
|
||||
outcome: eval_utils::OutcomeKind::Error,
|
||||
metadata: (),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn message(
|
||||
|
|
@ -1499,46 +1476,3 @@ fn eval_add_overwrite_test() {
|
|||
))
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(not(feature = "unit-eval"), ignore)]
|
||||
fn eval_create_empty_file() {
|
||||
let input_file_path = "root/TODO3";
|
||||
let input_file_content = None;
|
||||
let expected_output_content = String::new();
|
||||
|
||||
eval_utils::eval(100, 0.99, eval_utils::NoProcessor, move || {
|
||||
run_eval(EvalInput::new(
|
||||
vec![
|
||||
message(User, [text("Create a second empty todo file ")]),
|
||||
message(
|
||||
Assistant,
|
||||
[
|
||||
text(indoc::formatdoc! {"
|
||||
I'll help you create a second empty todo file.
|
||||
First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
|
||||
"}),
|
||||
tool_use(
|
||||
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
|
||||
ListDirectoryTool::NAME,
|
||||
ListDirectoryToolInput {
|
||||
path: "root".to_string(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
message(
|
||||
User,
|
||||
[tool_result(
|
||||
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
|
||||
ListDirectoryTool::NAME,
|
||||
"root/TODO\nroot/TODO2\nroot/new.txt\n",
|
||||
)],
|
||||
),
|
||||
],
|
||||
input_file_path,
|
||||
input_file_content.clone(),
|
||||
EvalAssertion::assert_eq(expected_output_content.clone()),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
520
crates/agent/src/tools/evals/terminal_tool.rs
Normal file
520
crates/agent/src/tools/evals/terminal_tool.rs
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
use crate::{AgentTool, Template, Templates, TerminalTool, TerminalToolInput};
|
||||
use Role::*;
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::{Client, RefreshLlmTokenListener, UserStore};
|
||||
use futures::{FutureExt as _, StreamExt};
|
||||
use gpui::{AppContext as _, AsyncApp, TestAppContext};
|
||||
use http_client::StatusCode;
|
||||
use language_model::{
|
||||
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role,
|
||||
SelectedModel,
|
||||
};
|
||||
use prompt_store::{ProjectContext, WorktreeContext};
|
||||
use rand::prelude::*;
|
||||
use reqwest_client::ReqwestClient;
|
||||
use settings::SettingsStore;
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
path::Path,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EvalInput {
|
||||
conversation: Vec<LanguageModelRequestMessage>,
|
||||
assertion: CommandAssertion,
|
||||
}
|
||||
|
||||
impl EvalInput {
|
||||
fn new(conversation: Vec<LanguageModelRequestMessage>, assertion: CommandAssertion) -> Self {
|
||||
Self {
|
||||
conversation,
|
||||
assertion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
struct EvalAssertionOutcome {
|
||||
score: usize,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
type AssertionFn = Arc<dyn Fn(&TerminalToolInput) -> 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<dyn LanguageModel>,
|
||||
model_thinking_effort: Option<String>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
|
||||
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<EvalOutput> {
|
||||
eval.conversation
|
||||
.last_mut()
|
||||
.context("Conversation must not be empty")?
|
||||
.cache = true;
|
||||
|
||||
let tools = crate::built_in_tools().collect::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
|
||||
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<Arc<dyn LanguageModel>> {
|
||||
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<dyn LanguageModel>,
|
||||
request: LanguageModelRequest,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<TerminalToolInput> {
|
||||
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<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
|
||||
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::<LanguageModelCompletionError>() {
|
||||
Some(err) => match &err {
|
||||
LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
|
||||
| LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
|
||||
Some(retry_after.unwrap_or(Duration::from_secs(5)))
|
||||
}
|
||||
LanguageModelCompletionError::UpstreamProviderError {
|
||||
status,
|
||||
retry_after,
|
||||
..
|
||||
} => {
|
||||
let should_retry = matches!(
|
||||
*status,
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
|
||||
) || status.as_u16() == 529;
|
||||
|
||||
if should_retry {
|
||||
Some(retry_after.unwrap_or(Duration::from_secs(5)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
LanguageModelCompletionError::ApiReadResponseError { .. }
|
||||
| LanguageModelCompletionError::ApiInternalServerError { .. }
|
||||
| LanguageModelCompletionError::HttpSend { .. } => {
|
||||
Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(retry_after) = retry_delay {
|
||||
let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0));
|
||||
eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
async_io::Timer::after(retry_after + jitter).await;
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
|
||||
super::run_gpui_eval(
|
||||
|cx| {
|
||||
async move {
|
||||
let test = TerminalToolTest::new(cx).await;
|
||||
let result = test.eval(eval, cx).await;
|
||||
drop(test);
|
||||
cx.run_until_parked();
|
||||
result
|
||||
}
|
||||
.boxed_local()
|
||||
},
|
||||
|output| {
|
||||
if output.assertion.score < 80 {
|
||||
eval_utils::OutcomeKind::Failed
|
||||
} else {
|
||||
eval_utils::OutcomeKind::Passed
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn message(
|
||||
role: Role,
|
||||
contents: impl IntoIterator<Item = MessageContent>,
|
||||
) -> LanguageModelRequestMessage {
|
||||
LanguageModelRequestMessage {
|
||||
role,
|
||||
content: contents.into_iter().collect(),
|
||||
cache: false,
|
||||
reasoning_details: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(text: impl Into<String>) -> 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"),
|
||||
))
|
||||
});
|
||||
}
|
||||
551
crates/agent/src/tools/evals/write_file.rs
Normal file
551
crates/agent/src/tools/evals/write_file.rs
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
use crate::{
|
||||
AgentTool, ContextServerRegistry, ListDirectoryTool, ListDirectoryToolInput, Template,
|
||||
Templates, Thread, ToolCallEventStream, ToolInput, WriteFileTool, WriteFileToolInput,
|
||||
};
|
||||
use Role::*;
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::{Client, RefreshLlmTokenListener, UserStore};
|
||||
use fs::FakeFs;
|
||||
use futures::{FutureExt as _, StreamExt};
|
||||
use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _};
|
||||
use http_client::StatusCode;
|
||||
use language::language_settings::FormatOnSave;
|
||||
use language_model::{
|
||||
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
|
||||
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
|
||||
LanguageModelToolUseId, MessageContent, Role, SelectedModel,
|
||||
};
|
||||
use project::Project;
|
||||
use prompt_store::{ProjectContext, WorktreeContext};
|
||||
use rand::prelude::*;
|
||||
use reqwest_client::ReqwestClient;
|
||||
use serde::Serialize;
|
||||
use settings::SettingsStore;
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use util::path;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct EvalInput {
|
||||
conversation: Vec<LanguageModelRequestMessage>,
|
||||
input_file_path: PathBuf,
|
||||
input_content: Option<String>,
|
||||
expected_output_content: String,
|
||||
}
|
||||
|
||||
impl EvalInput {
|
||||
fn new(
|
||||
conversation: Vec<LanguageModelRequestMessage>,
|
||||
input_file_path: impl Into<PathBuf>,
|
||||
input_content: Option<String>,
|
||||
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<FakeFs>,
|
||||
project: Entity<Project>,
|
||||
model: Arc<dyn LanguageModel>,
|
||||
model_thinking_effort: Option<String>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
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<Arc<dyn LanguageModel>> {
|
||||
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<WriteEvalOutput> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<WriteFileToolInput> {
|
||||
let model = self.model.clone();
|
||||
let events = cx
|
||||
.update(|cx| {
|
||||
let async_cx = cx.to_async();
|
||||
cx.foreground_executor()
|
||||
.spawn(async move { model.stream_completion(request, &async_cx).await })
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("completion error: {}", err))?;
|
||||
|
||||
let mut streamed_text = String::new();
|
||||
let mut stop_reason = None;
|
||||
let mut parse_errors = Vec::new();
|
||||
|
||||
let mut events = events.fuse();
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
|
||||
if tool_use.is_input_complete
|
||||
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
|
||||
{
|
||||
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
|
||||
.context("Failed to parse tool input as WriteFileToolInput")?;
|
||||
return Ok(input);
|
||||
}
|
||||
Ok(LanguageModelCompletionEvent::Text(text)) => {
|
||||
if streamed_text.len() < 2_000 {
|
||||
streamed_text.push_str(&text);
|
||||
}
|
||||
}
|
||||
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
|
||||
stop_reason = Some(reason);
|
||||
}
|
||||
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
|
||||
tool_name,
|
||||
raw_input,
|
||||
json_parse_error,
|
||||
..
|
||||
}) if tool_name.as_ref() == WriteFileTool::NAME => {
|
||||
parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}"));
|
||||
}
|
||||
Err(err) => return Err(anyhow::anyhow!("completion error: {}", err)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let streamed_text = streamed_text.trim();
|
||||
let streamed_text_suffix = if streamed_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\nStreamed text:\n{streamed_text}")
|
||||
};
|
||||
let stop_reason_suffix = stop_reason
|
||||
.map(|reason| format!("\nStop reason: {reason:?}"))
|
||||
.unwrap_or_default();
|
||||
let parse_errors_suffix = if parse_errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\nTool parse errors:\n{}", parse_errors.join("\n"))
|
||||
};
|
||||
|
||||
anyhow::bail!(
|
||||
"Stream ended without a write_file tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
|
||||
super::run_gpui_eval(
|
||||
|cx| {
|
||||
async move {
|
||||
let test = WriteToolTest::new(cx).await;
|
||||
let result = test.eval(eval, cx).await;
|
||||
drop(test);
|
||||
cx.run_until_parked();
|
||||
result
|
||||
}
|
||||
.boxed_local()
|
||||
},
|
||||
|_| eval_utils::OutcomeKind::Passed,
|
||||
)
|
||||
}
|
||||
|
||||
fn message(
|
||||
role: Role,
|
||||
content: impl IntoIterator<Item = MessageContent>,
|
||||
) -> LanguageModelRequestMessage {
|
||||
LanguageModelRequestMessage {
|
||||
role,
|
||||
content: content.into_iter().collect(),
|
||||
cache: false,
|
||||
reasoning_details: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text(text: impl Into<String>) -> MessageContent {
|
||||
MessageContent::Text(text.into())
|
||||
}
|
||||
|
||||
fn tool_use(
|
||||
id: impl Into<Arc<str>>,
|
||||
name: impl Into<Arc<str>>,
|
||||
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<Arc<str>>,
|
||||
name: impl Into<Arc<str>>,
|
||||
result: impl Into<Arc<str>>,
|
||||
) -> 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<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
|
||||
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::<LanguageModelCompletionError>() {
|
||||
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(),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct RestoreFileFromDiskTool {
|
||||
project: Entity<Project>,
|
||||
}
|
||||
|
||||
impl RestoreFileFromDiskTool {
|
||||
pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>,
|
||||
_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<Self>,
|
||||
input: ToolInput<Self::Input>,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String, String>> {
|
||||
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<String> = 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<Entity<Buffer>> = FxHashSet::default();
|
||||
|
||||
let mut restored_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut clean_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut not_found_paths: Vec<PathBuf> = 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<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 {
|
||||
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<String> = 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct SaveFileTool {
|
||||
project: Entity<Project>,
|
||||
}
|
||||
|
||||
impl SaveFileTool {
|
||||
pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>,
|
||||
_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<Self>,
|
||||
input: ToolInput<Self::Input>,
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String, String>> {
|
||||
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<String> = 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<Entity<Buffer>> = FxHashSet::default();
|
||||
|
||||
let mut dirty_count: usize = 0;
|
||||
let mut clean_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut not_found_paths: Vec<PathBuf> = 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(|| "<unknown>".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<String> = 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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Result<DirtyBufferDecision>> {
|
||||
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::*;
|
||||
|
|
|
|||
1396
crates/agent/src/tools/write_file_tool.rs
Normal file
1396
crates/agent/src/tools/write_file_tool.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -413,6 +413,7 @@ fn enqueue_notification<Notif>(
|
|||
pub struct AcpConnection {
|
||||
id: AgentId,
|
||||
telemetry_id: SharedString,
|
||||
agent_version: Option<SharedString>,
|
||||
connection: ConnectionTo<Agent>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
pending_sessions: Rc<RefCell<HashMap<acp::SessionId, PendingAcpSession>>>,
|
||||
|
|
@ -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<SharedString> {
|
||||
self.agent_version.clone()
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
@ -1984,6 +1994,10 @@ pub mod test_support {
|
|||
self.inner.telemetry_id()
|
||||
}
|
||||
|
||||
fn agent_version(&self) -> Option<SharedString> {
|
||||
self.inner.agent_version()
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
@ -3345,6 +3359,7 @@ fn handle_request_permission(
|
|||
thread.request_tool_call_authorization(
|
||||
args.tool_call,
|
||||
acp_thread::PermissionOptions::Flat(args.options),
|
||||
acp_thread::AuthorizationKind::PermissionGrant,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use extension_host::ExtensionStore;
|
|||
use fs::Fs;
|
||||
use gpui::{
|
||||
Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
ScrollHandle, Subscription, Task, WeakEntity,
|
||||
ScrollHandle, Subscription, Task, TaskExt, WeakEntity,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::LanguageRegistry;
|
||||
|
|
@ -1135,10 +1135,13 @@ impl AgentConfiguration {
|
|||
id: agent_server_name.clone(),
|
||||
};
|
||||
|
||||
let connection_status = self
|
||||
.agent_connection_store
|
||||
.read(cx)
|
||||
.connection_status(&agent, cx);
|
||||
let (connection_status, running_version) = {
|
||||
let connection_store = self.agent_connection_store.read(cx);
|
||||
(
|
||||
connection_store.connection_status(&agent, cx),
|
||||
connection_store.agent_version(&agent, cx),
|
||||
)
|
||||
};
|
||||
|
||||
let restart_button = matches!(
|
||||
connection_status,
|
||||
|
|
@ -1252,6 +1255,7 @@ impl AgentConfiguration {
|
|||
|
||||
AiSettingItem::new(id, display_name, status, source_kind)
|
||||
.icon(icon)
|
||||
.when_some(running_version, |this, version| this.detail_label(version))
|
||||
.when_some(restart_button, |this, button| this.action(button))
|
||||
.when_some(uninstall_button, |this, button| this.action(button))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ impl ManageProfilesModal {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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| {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ impl AgentConnectionStore {
|
|||
.unwrap_or(AgentConnectionStatus::Disconnected)
|
||||
}
|
||||
|
||||
pub fn agent_version(&self, key: &Agent, cx: &App) -> Option<SharedString> {
|
||||
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<ActiveAcpConnection> {
|
||||
self.entries
|
||||
.values()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Buffer>, Range<text::Anchor>)>),
|
||||
Terminal(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum AgentContextSource {
|
||||
Editor(WeakEntity<Editor>),
|
||||
TerminalView(WeakEntity<TerminalView>),
|
||||
TerminalPanel,
|
||||
}
|
||||
|
||||
impl AgentContextSource {
|
||||
pub(crate) fn read_selection(
|
||||
&self,
|
||||
workspace: &Workspace,
|
||||
include_current_line: bool,
|
||||
cx: &mut App,
|
||||
) -> Option<AgentContextSelection> {
|
||||
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::<TerminalPanel>(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<Self> {
|
||||
if let Some(agent_panel) = workspace.panel::<AgentPanel>(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::<Editor>(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::<TerminalView>(cx)
|
||||
&& terminal_view.focus_handle(cx).is_focused(window)
|
||||
{
|
||||
return Some(Self::TerminalView(terminal_view.downgrade()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(panel) = workspace.panel::<TerminalPanel>(cx)
|
||||
&& panel.focus_handle(cx).contains_focused(window, cx)
|
||||
{
|
||||
return Some(Self::TerminalPanel);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn from_active(workspace: &Workspace, cx: &App) -> Option<Self> {
|
||||
if let Some(active_item) = workspace.active_item(cx) {
|
||||
if let Some(editor) = active_item.act_as::<Editor>(cx) {
|
||||
return Some(Self::Editor(editor.downgrade()));
|
||||
} else if let Some(terminal_view) = active_item.act_as::<TerminalView>(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::<TerminalPanel>(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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
|
|||
// 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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
|
|||
source_range: Range<Anchor>,
|
||||
editor: WeakEntity<Editor>,
|
||||
mention_set: WeakEntity<MentionSet>,
|
||||
workspace: &Entity<Workspace>,
|
||||
cx: &mut App,
|
||||
selection: Option<AgentContextSelection>,
|
||||
) -> Option<Completion> {
|
||||
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<String> = 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::<Vec<_>>();
|
||||
|
||||
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<usize>)> = 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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
|
|||
entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
|
||||
}
|
||||
|
||||
let has_editor_selection = workspace
|
||||
.read(cx)
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.downcast::<Editor>())
|
||||
.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<Workspace>, cx: &App) -> Vec<String> {
|
||||
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<TerminalView>, cx: &App) -> Option<String> {
|
||||
terminal_view
|
||||
.read(cx)
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<TerminalView>(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::<TerminalPanel>(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<Workspace>,
|
||||
fn editor_selection_ranges(
|
||||
editor: &Entity<Editor>,
|
||||
include_current_line: bool,
|
||||
cx: &mut App,
|
||||
) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
|
||||
let Some(editor) = workspace
|
||||
.read(cx)
|
||||
.active_item(cx)
|
||||
.and_then(|item| item.act_as::<Editor>(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<u32> = 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::<Vec<_>>()
|
||||
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<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync>;
|
||||
|
||||
fn completion_text_for_editor_selections(
|
||||
source_range: Range<Anchor>,
|
||||
editor: WeakEntity<Editor>,
|
||||
mention_set: WeakEntity<MentionSet>,
|
||||
editor_selections: Vec<(Entity<Buffer>, Range<text::Anchor>)>,
|
||||
) -> (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::<Vec<_>>();
|
||||
|
||||
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<Anchor>,
|
||||
editor: WeakEntity<Editor>,
|
||||
mention_set: WeakEntity<MentionSet>,
|
||||
terminal_selections: Vec<String>,
|
||||
) -> (String, ConfirmCallback) {
|
||||
const TERMINAL_PLACEHOLDER: &str = "terminal ";
|
||||
|
||||
let mut new_text = String::new();
|
||||
let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ use futures::FutureExt as _;
|
|||
use gpui::{
|
||||
Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
|
||||
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
|
||||
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle,
|
||||
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TaskExt, TextStyle,
|
||||
WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
|
||||
list, point, pulsating_between,
|
||||
};
|
||||
|
|
@ -80,6 +80,7 @@ use crate::agent_connection_store::{
|
|||
AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore,
|
||||
};
|
||||
use crate::agent_diff::AgentDiff;
|
||||
use crate::completion_provider::AgentContextSelection;
|
||||
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
|
||||
use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent};
|
||||
use crate::profile_selector::{ProfileProvider, ProfileSelector};
|
||||
|
|
@ -2707,10 +2708,10 @@ impl ConversationView {
|
|||
&panel,
|
||||
window,
|
||||
move |this, _, event: &AgentPanelEvent, window, cx| match event {
|
||||
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => {
|
||||
AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => {
|
||||
dismiss_if_visible(this, window, cx);
|
||||
}
|
||||
AgentPanelEvent::RetainedThreadChanged
|
||||
AgentPanelEvent::EntryChanged
|
||||
| AgentPanelEvent::ThreadInteracted { .. } => {}
|
||||
},
|
||||
));
|
||||
|
|
@ -2760,11 +2761,16 @@ impl ConversationView {
|
|||
|
||||
/// Inserts the selected text into the message editor or the message being
|
||||
/// edited, if any.
|
||||
pub(crate) fn insert_selections(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(crate) fn insert_selection(
|
||||
&self,
|
||||
selection: AgentContextSelection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(active_thread) = self.active_thread() {
|
||||
active_thread.update(cx, |thread, cx| {
|
||||
thread.active_editor(cx).update(cx, |editor, cx| {
|
||||
editor.insert_selections(window, cx);
|
||||
editor.insert_selections(selection, window, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
@ -2974,6 +2980,7 @@ pub(crate) mod tests {
|
|||
use workspace::{Item, MultiWorkspace};
|
||||
|
||||
use crate::agent_panel;
|
||||
use crate::completion_provider::AgentContextSource;
|
||||
use crate::thread_metadata_store::ThreadMetadataStore;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -5903,7 +5910,14 @@ pub(crate) mod tests {
|
|||
.and_then(|active| active.read(cx).editing_message),
|
||||
Some(0)
|
||||
);
|
||||
view.insert_selections(window, cx);
|
||||
let workspace = workspace.upgrade().unwrap();
|
||||
let selection = workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
AgentContextSource::from_active(workspace, cx)?
|
||||
.read_selection(workspace, false, cx)
|
||||
})
|
||||
.unwrap();
|
||||
view.insert_selection(selection, window, cx);
|
||||
});
|
||||
|
||||
user_message_editor.read_with(cx, |editor, cx| {
|
||||
|
|
@ -5966,7 +5980,14 @@ pub(crate) mod tests {
|
|||
.and_then(|active| active.read(cx).editing_message),
|
||||
None
|
||||
);
|
||||
view.insert_selections(window, cx);
|
||||
let workspace = view.workspace.upgrade().unwrap();
|
||||
let selection = workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
AgentContextSource::from_active(workspace, cx)?
|
||||
.read_selection(workspace, false, cx)
|
||||
})
|
||||
.unwrap();
|
||||
view.insert_selection(selection, window, cx);
|
||||
});
|
||||
|
||||
message_editor.read_with(cx, |editor, cx| {
|
||||
|
|
@ -7099,6 +7120,7 @@ pub(crate) mod tests {
|
|||
"Allow",
|
||||
acp::PermissionOptionKind::AllowOnce,
|
||||
)]),
|
||||
acp_thread::AuthorizationKind::PermissionGrant,
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<MentionUri> {
|
||||
self.mentions.values().map(|(uri, _)| uri.clone()).collect()
|
||||
}
|
||||
|
|
@ -178,6 +182,16 @@ impl MentionSet {
|
|||
self.mentions.get(crease_id).map(|(uri, _)| uri.clone())
|
||||
}
|
||||
|
||||
/// Returns the resolved mention for a crease, if any.
|
||||
pub fn resolved_mention_for_crease(
|
||||
&self,
|
||||
crease_id: &CreaseId,
|
||||
) -> Option<(MentionUri, Option<Mention>)> {
|
||||
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<CreaseId, (MentionUri, MentionTask)>) {
|
||||
self.mentions = mentions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use crate::SendImmediately;
|
|||
use crate::{
|
||||
ChatWithFollow,
|
||||
completion_provider::{
|
||||
PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
|
||||
PromptContextType, SlashCommandCompletion,
|
||||
AgentContextSelection, PromptCompletionProvider, PromptCompletionProviderDelegate,
|
||||
PromptContextAction, PromptContextType, SlashCommandCompletion,
|
||||
},
|
||||
mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
|
||||
};
|
||||
|
|
@ -15,14 +15,16 @@ use anyhow::{Result, anyhow};
|
|||
use editor::{
|
||||
Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
|
||||
EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
|
||||
actions::{Copy, Paste},
|
||||
actions::{Copy, Cut, Paste},
|
||||
code_context_menus::CodeContextMenu,
|
||||
display_map::{CreaseId, CreaseSnapshot},
|
||||
scroll::Autoscroll,
|
||||
};
|
||||
use futures::{FutureExt as _, future::join_all};
|
||||
use gpui::{
|
||||
AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
|
||||
Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, TextStyle,
|
||||
WeakEntity,
|
||||
};
|
||||
use language::{Buffer, language_settings::InlayHintKind};
|
||||
use parking_lot::RwLock;
|
||||
|
|
@ -33,7 +35,7 @@ use project::{
|
|||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc};
|
||||
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
|
||||
use theme_settings::ThemeSettings;
|
||||
use ui::{ContextMenu, prelude::*};
|
||||
use util::paths::PathStyle;
|
||||
|
|
@ -676,7 +678,7 @@ impl MessageEditor {
|
|||
}
|
||||
|
||||
pub fn is_empty(&self, cx: &App) -> bool {
|
||||
self.editor.read(cx).is_empty(cx)
|
||||
self.editor.read(cx).text(cx).trim().is_empty()
|
||||
}
|
||||
|
||||
pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
|
||||
|
|
@ -767,90 +769,46 @@ impl MessageEditor {
|
|||
self.session_capabilities.read().supports_embedded_context();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
let mut all_tracked_buffers = Vec::new();
|
||||
|
||||
let result = editor.update(cx, |editor, cx| {
|
||||
let mut contents = contents.await?;
|
||||
Ok(editor.update(cx, |editor, cx| {
|
||||
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
|
||||
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let text = editor.text(cx);
|
||||
let (mut ix, _) = text
|
||||
.char_indices()
|
||||
.find(|(_, c)| !c.is_whitespace())
|
||||
.unwrap_or((0, '\0'));
|
||||
let mut chunks: Vec<acp::ContentBlock> = 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<acp::ContentBlock> {
|
||||
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>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.clear(window, cx);
|
||||
|
|
@ -1222,7 +1180,7 @@ impl MessageEditor {
|
|||
}
|
||||
|
||||
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(text) = self.serialized_copy_text(cx) else {
|
||||
let Some((text, _)) = self.serialize_selection_with_mentions(false, cx) else {
|
||||
cx.propagate();
|
||||
return;
|
||||
};
|
||||
|
|
@ -1231,6 +1189,24 @@ impl MessageEditor {
|
|||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
}
|
||||
|
||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
let editor = self.editor.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
|
|
@ -1407,7 +1383,12 @@ impl MessageEditor {
|
|||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(crate) fn insert_selections(
|
||||
&mut self,
|
||||
selection: AgentContextSelection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let editor = self.editor.read(cx);
|
||||
let editor_buffer = editor.buffer().read(cx);
|
||||
let Some(buffer) = editor_buffer.as_singleton() else {
|
||||
|
|
@ -1418,17 +1399,13 @@ impl MessageEditor {
|
|||
let anchor = buffer.update(cx, |buffer, _cx| {
|
||||
buffer.anchor_before(cursor_offset.0.min(buffer.len()))
|
||||
});
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(completion) =
|
||||
PromptCompletionProvider::<MessageEditorCompletionDelegate>::completion_for_action(
|
||||
PromptContextAction::AddSelections,
|
||||
anchor..anchor,
|
||||
self.editor.downgrade(),
|
||||
self.mention_set.downgrade(),
|
||||
&workspace,
|
||||
cx,
|
||||
Some(selection),
|
||||
)
|
||||
else {
|
||||
return;
|
||||
|
|
@ -1730,12 +1707,20 @@ impl MessageEditor {
|
|||
});
|
||||
}
|
||||
|
||||
fn serialized_copy_text(&self, cx: &mut App) -> Option<String> {
|
||||
fn serialize_selection_with_mentions(
|
||||
&self,
|
||||
expand_empty_to_line: bool,
|
||||
cx: &mut App,
|
||||
) -> Option<(String, Vec<Range<MultiBufferOffset>>)> {
|
||||
if self.mention_set.read(cx).is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let display_snapshot = self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.display_snapshot(cx));
|
||||
let editor = self.editor.read(cx);
|
||||
if !editor.has_non_empty_selection(&display_snapshot) {
|
||||
if !expand_empty_to_line && !editor.has_non_empty_selection(&display_snapshot) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -1756,48 +1741,55 @@ impl MessageEditor {
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let line_mode = editor.selections.line_mode();
|
||||
let max_point = snapshot.max_point();
|
||||
let point_selections = editor.selections.all::<Point>(&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::<MultiBufferOffset>(&display_snapshot)
|
||||
{
|
||||
if is_first {
|
||||
is_first = false;
|
||||
} else {
|
||||
} else if !prev_was_entire_line {
|
||||
text.push('\n');
|
||||
}
|
||||
prev_was_entire_line = is_entire_line;
|
||||
|
||||
let mut overlapping_mentions = mention_ranges
|
||||
let mut cursor = range.start;
|
||||
for (start, end, uri) in mention_ranges
|
||||
.iter()
|
||||
.filter(|(start, end, _)| *start < selection.end && selection.start < *end)
|
||||
.peekable();
|
||||
|
||||
if overlapping_mentions.peek().is_none() {
|
||||
text.extend(snapshot.text_for_range(selection.start..selection.end));
|
||||
continue;
|
||||
}
|
||||
|
||||
has_mentions = true;
|
||||
|
||||
let mut cursor = selection.start;
|
||||
for (start, end, uri) in overlapping_mentions {
|
||||
.filter(|(start, end, _)| *start < range.end && range.start < *end)
|
||||
{
|
||||
if cursor < *start {
|
||||
text.extend(snapshot.text_for_range(cursor..*start));
|
||||
}
|
||||
|
||||
write!(text, "{}", uri.as_link()).unwrap();
|
||||
cursor = *end;
|
||||
has_mentions = true;
|
||||
}
|
||||
if cursor < range.end {
|
||||
text.extend(snapshot.text_for_range(cursor..range.end));
|
||||
}
|
||||
|
||||
if cursor < selection.end {
|
||||
text.extend(snapshot.text_for_range(cursor..selection.end));
|
||||
}
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
has_mentions.then_some(text)
|
||||
has_mentions.then_some((text, ranges))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1816,6 +1808,7 @@ impl Render for MessageEditor {
|
|||
.on_action(cx.listener(Self::chat_with_follow))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.capture_action(cx.listener(Self::copy))
|
||||
.capture_action(cx.listener(Self::cut))
|
||||
.on_action(cx.listener(Self::paste_raw))
|
||||
.capture_action(cx.listener(Self::paste))
|
||||
.flex_1()
|
||||
|
|
@ -1873,6 +1866,92 @@ impl Addon for MessageEditorAddon {
|
|||
}
|
||||
}
|
||||
|
||||
/// Walks the editor's creases in order, interleaving plain-text chunks from
|
||||
/// `text` with mention blocks produced from `resolve`.
|
||||
fn build_chunks_from_creases(
|
||||
text: &str,
|
||||
crease_snapshot: &CreaseSnapshot,
|
||||
buffer_snapshot: &MultiBufferSnapshot,
|
||||
supports_embedded_context: bool,
|
||||
mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option<Mention>)>,
|
||||
) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
|
||||
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<Entity<Buffer>>,
|
||||
) -> 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<usize>, MentionUri)> {
|
||||
|
|
@ -1946,7 +2025,7 @@ mod tests {
|
|||
use base64::Engine as _;
|
||||
use editor::{
|
||||
AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
|
||||
actions::Paste,
|
||||
actions::{Cut, Paste},
|
||||
};
|
||||
|
||||
use fs::FakeFs;
|
||||
|
|
@ -1966,7 +2045,7 @@ mod tests {
|
|||
use util::{path, paths::PathStyle, rel_path::rel_path};
|
||||
use workspace::{AppState, Item, MultiWorkspace};
|
||||
|
||||
use crate::completion_provider::PromptContextType;
|
||||
use crate::completion_provider::{AgentContextSelection, PromptContextType};
|
||||
use crate::{
|
||||
conversation_view::tests::init_test,
|
||||
mention_set::insert_crease_for_mention,
|
||||
|
|
@ -3687,11 +3766,17 @@ mod tests {
|
|||
})
|
||||
});
|
||||
|
||||
// Now let's insert the selection in the Agent Panel's editor and
|
||||
// confirm that, after the insertion, the cursor is now in the visible
|
||||
// range.
|
||||
let text_editor_selection = editor.update(&mut cx, |editor, cx| {
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
let buffer = multibuffer.as_singleton().unwrap();
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let start = buffer_snapshot.anchor_before(0);
|
||||
let end = buffer_snapshot.anchor_after(5);
|
||||
AgentContextSelection::Editor(vec![(buffer, start..end)])
|
||||
});
|
||||
|
||||
message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
||||
message_editor.insert_selections(window, cx);
|
||||
message_editor.insert_selections(text_editor_selection, window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
|
@ -3978,7 +4063,8 @@ mod tests {
|
|||
|
||||
let copied_text = source_message_editor.update(&mut cx, |message_editor, cx| {
|
||||
message_editor
|
||||
.serialized_copy_text(cx)
|
||||
.serialize_selection_with_mentions(false, cx)
|
||||
.map(|(text, _)| text)
|
||||
.expect("selection mentions should serialize")
|
||||
});
|
||||
let expected_text = format!(
|
||||
|
|
@ -4043,7 +4129,9 @@ mod tests {
|
|||
message_editor: Entity<MessageEditor>,
|
||||
first_uri: MentionUri,
|
||||
first_range: Range<usize>,
|
||||
second_uri: MentionUri,
|
||||
second_range: Range<usize>,
|
||||
buffer_len: MultiBufferOffset,
|
||||
}
|
||||
|
||||
async fn setup_selection_mention_fixture(
|
||||
|
|
@ -4068,7 +4156,7 @@ mod tests {
|
|||
line_range: 2..=3,
|
||||
};
|
||||
|
||||
message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
||||
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
||||
message_editor.set_text(source_text, window, cx);
|
||||
|
||||
let snapshot = message_editor
|
||||
|
|
@ -4123,6 +4211,8 @@ mod tests {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
snapshot.len()
|
||||
});
|
||||
|
||||
(
|
||||
|
|
@ -4130,7 +4220,9 @@ mod tests {
|
|||
message_editor,
|
||||
first_uri,
|
||||
first_range,
|
||||
second_uri,
|
||||
second_range,
|
||||
buffer_len,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
|
|
@ -4158,7 +4250,9 @@ mod tests {
|
|||
let copied = fixture
|
||||
.message_editor
|
||||
.update(&mut cx, |message_editor, cx| {
|
||||
message_editor.serialized_copy_text(cx)
|
||||
message_editor
|
||||
.serialize_selection_with_mentions(false, cx)
|
||||
.map(|(text, _)| text)
|
||||
});
|
||||
|
||||
assert_eq!(copied, Some(fixture.first_uri.as_link().to_string()));
|
||||
|
|
@ -4190,12 +4284,175 @@ mod tests {
|
|||
let copied = fixture
|
||||
.message_editor
|
||||
.update(&mut cx, |message_editor, cx| {
|
||||
message_editor.serialized_copy_text(cx)
|
||||
message_editor
|
||||
.serialize_selection_with_mentions(false, cx)
|
||||
.map(|(text, _)| text)
|
||||
});
|
||||
|
||||
assert_eq!(copied, None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_draft_content_blocks_snapshot_preserves_selection_mentions(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
|
||||
|
||||
let blocks = fixture.message_editor.update(&mut cx, |editor, cx| {
|
||||
editor
|
||||
.session_capabilities
|
||||
.write()
|
||||
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
|
||||
editor.draft_content_blocks_snapshot(cx)
|
||||
});
|
||||
|
||||
// Each selection mention must round-trip as a `Resource` block carrying
|
||||
// its URI and content, not as a `Text` block containing the fold
|
||||
// placeholder string.
|
||||
let resource_uris: Vec<&str> =
|
||||
blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource {
|
||||
resource:
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents { uri, .. },
|
||||
),
|
||||
..
|
||||
}) => Some(uri.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
resource_uris.len(),
|
||||
2,
|
||||
"snapshot should emit one Resource block per selection mention; got {blocks:#?}"
|
||||
);
|
||||
assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str()));
|
||||
for block in &blocks {
|
||||
if let acp::ContentBlock::Text(text) = block {
|
||||
assert!(
|
||||
!text.text.split_whitespace().any(|word| word == "selection"),
|
||||
"text block must not contain bare fold placeholder: {:?}",
|
||||
text.text
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_cut_with_selection_mentions_serializes_and_removes(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
|
||||
|
||||
let buffer_len = fixture.buffer_len;
|
||||
fixture
|
||||
.message_editor
|
||||
.update_in(&mut cx, |message_editor, window, cx| {
|
||||
message_editor.editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges([MultiBufferOffset(0)..buffer_len]);
|
||||
});
|
||||
});
|
||||
message_editor.cut(&Cut, window, cx);
|
||||
});
|
||||
|
||||
let expected_text = format!(
|
||||
"{} needs work\n{} looks fine",
|
||||
fixture.first_uri.as_link(),
|
||||
fixture.second_uri.as_link()
|
||||
);
|
||||
|
||||
let clipboard_text = cx
|
||||
.read_from_clipboard()
|
||||
.and_then(|item| match item.entries().first().cloned() {
|
||||
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("cut should write serialized text to clipboard");
|
||||
assert_eq!(clipboard_text, expected_text);
|
||||
|
||||
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
|
||||
message_editor.editor.read(cx).text(cx)
|
||||
});
|
||||
assert_eq!(remaining_text, "");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_cut_with_empty_cursor_on_mention_line_removes_whole_line(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
|
||||
|
||||
let cursor_offset = MultiBufferOffset(fixture.first_range.end + 4);
|
||||
fixture
|
||||
.message_editor
|
||||
.update_in(&mut cx, |message_editor, window, cx| {
|
||||
message_editor.editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges([cursor_offset..cursor_offset]);
|
||||
});
|
||||
});
|
||||
message_editor.cut(&Cut, window, cx);
|
||||
});
|
||||
|
||||
let clipboard_text = cx
|
||||
.read_from_clipboard()
|
||||
.and_then(|item| match item.entries().first().cloned() {
|
||||
Some(ClipboardEntry::String(entry)) => Some(entry.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("cut should write serialized text to clipboard");
|
||||
assert_eq!(
|
||||
clipboard_text,
|
||||
format!("{} needs work\n", fixture.first_uri.as_link())
|
||||
);
|
||||
|
||||
let remaining_text = fixture.message_editor.read_with(&cx, |message_editor, cx| {
|
||||
message_editor.editor.read(cx).text(cx)
|
||||
});
|
||||
assert_eq!(remaining_text, "selection looks fine");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_serialized_cut_text_returns_none_when_mentions_outside_selection(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
|
||||
|
||||
let between_start = fixture.first_range.end;
|
||||
let between_end = fixture.second_range.start - 1;
|
||||
fixture
|
||||
.message_editor
|
||||
.update_in(&mut cx, |message_editor, window, cx| {
|
||||
message_editor.editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges([
|
||||
MultiBufferOffset(between_start)..MultiBufferOffset(between_end)
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let result = fixture
|
||||
.message_editor
|
||||
.update(&mut cx, |message_editor, cx| {
|
||||
message_editor.serialize_selection_with_mentions(true, cx)
|
||||
});
|
||||
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"serialize_selection_with_mentions should return None so the default editor cut runs"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
|
||||
cx: &mut TestAppContext,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<PathBuf> {
|
||||
fn worktrees_base_for_repo(
|
||||
main_repo_path: &Path,
|
||||
path_style: PathStyle,
|
||||
cx: &App,
|
||||
) -> Option<PathBuf> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ use fs::Fs;
|
|||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
|
||||
ListState, Render, SharedString, Subscription, Task, TaskExt, WeakEntity, Window, list,
|
||||
prelude::*, px,
|
||||
};
|
||||
use itertools::Itertools as _;
|
||||
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
||||
|
|
@ -101,24 +102,30 @@ impl TimeBucket {
|
|||
}
|
||||
}
|
||||
|
||||
fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
|
||||
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<Vec<usize>> {
|
||||
let query_chars: Vec<char> = 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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub use young_account_banner::YoungAccountBanner;
|
|||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement, TaskExt};
|
||||
use ui::{Divider, RegisterComponent, Tooltip, Vector, VectorName, prelude::*};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
@ -156,11 +156,11 @@ impl ZedAiOnboarding {
|
|||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed AI"))
|
||||
.child(
|
||||
Label::new("Sign in to try Zed Pro for 14 days, no credit card required.")
|
||||
Label::new("Sign in to try Zed Pro free for 14 days.")
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan())
|
||||
.child(PlanDefinitions.sign_in_upsell())
|
||||
.child(
|
||||
Button::new("sign_in", "Try Zed Pro for Free")
|
||||
.disabled(signing_in)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use client::Client;
|
|||
use db::kvp::KeyValueStore;
|
||||
use futures_lite::StreamExt;
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, Window,
|
||||
actions,
|
||||
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, TaskExt,
|
||||
Window, actions,
|
||||
};
|
||||
use http_client::{HttpClient, HttpClientWithUrl};
|
||||
use paths::remote_servers_dir;
|
||||
|
|
@ -81,6 +81,11 @@ fn linux_rsync_install_hint() -> &'static str {
|
|||
|| distribution_id == "almalinux"
|
||||
}) {
|
||||
Some("Install it with: sudo dnf install rsync")
|
||||
} else if distribution_ids
|
||||
.iter()
|
||||
.any(|distribution_id| distribution_id == "nixos")
|
||||
{
|
||||
Some("Install pkgs.rsync from nixpkgs")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<f32>,
|
||||
pub top_k: Option<u32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub allow_extended_context: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use fs::Fs;
|
|||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FutureExt as _,
|
||||
ScreenCaptureSource, ScreenCaptureStream, Task, Timeout, WeakEntity,
|
||||
ScreenCaptureSource, ScreenCaptureStream, Task, TaskExt, Timeout, WeakEntity,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
|
|
@ -935,6 +935,11 @@ impl Room {
|
|||
for sid in participant.video_tracks.keys() {
|
||||
cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() });
|
||||
}
|
||||
if !participant.video_tracks.is_empty() {
|
||||
cx.emit(Event::RemoteVideoTracksChanged {
|
||||
participant_id: participant.peer_id,
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod extension;
|
||||
pub mod internal_api;
|
||||
mod known_or_unknown;
|
||||
mod plan;
|
||||
mod timestamp;
|
||||
|
|
|
|||
22
crates/cloud_api_types/src/internal_api.rs
Normal file
22
crates/cloud_api_types/src/internal_api.rs
Normal file
|
|
@ -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<String>,
|
||||
pub admin: bool,
|
||||
pub connected_once: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LookUpUsersByLegacyIdBody {
|
||||
pub legacy_user_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LookUpUsersByLegacyIdResponse {
|
||||
pub users: Vec<User>,
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
|
|||
# DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc"
|
||||
DATABASE_MAX_CONNECTIONS = 5
|
||||
HTTP_PORT = 8080
|
||||
API_TOKEN = "secret"
|
||||
ZED_ENVIRONMENT = "development"
|
||||
ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret"
|
||||
LIVEKIT_SERVER = "http://localhost:7880"
|
||||
LIVEKIT_KEY = "devkey"
|
||||
LIVEKIT_SECRET = "secret"
|
||||
|
|
|
|||
|
|
@ -87,16 +87,16 @@ spec:
|
|||
key: url
|
||||
- name: DATABASE_MAX_CONNECTIONS
|
||||
value: "${DATABASE_MAX_CONNECTIONS}"
|
||||
- name: API_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api
|
||||
key: token
|
||||
- name: ZED_CLIENT_CHECKSUM_SEED
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: zed-client
|
||||
key: checksum-seed
|
||||
- name: ZED_CLOUD_INTERNAL_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: zed-cloud
|
||||
key: internal-api-key
|
||||
- name: LIVEKIT_SERVER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ use std::sync::Arc;
|
|||
|
||||
/// Validates the authorization header and adds an Extension<Principal> to the request.
|
||||
/// Authorization: <user-id> <token>
|
||||
/// <token> can be an access_token attached to that user, or an access token of an admin
|
||||
/// or (in development) the string ADMIN:<config.api_token>.
|
||||
/// <token> is the access_token attached to that user.
|
||||
/// Authorization: "dev-server-token" <token>
|
||||
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
|
||||
let mut auth_header = req
|
||||
|
|
|
|||
|
|
@ -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<UserId>,
|
||||
) -> Result<Vec<channel_member::Model>> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ impl From<Model> 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub admin: bool,
|
||||
pub connected_once: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ pub mod env;
|
|||
pub mod executor;
|
||||
pub mod rpc;
|
||||
pub mod seed;
|
||||
pub mod services;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use aws_config::{BehaviorVersion, Region};
|
||||
|
|
@ -19,6 +20,10 @@ use serde::Deserialize;
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::services::{
|
||||
CloudUserService, DatabaseUserService, TransitionalUserService, UserService,
|
||||
};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
|
||||
|
||||
|
|
@ -121,7 +126,6 @@ pub struct Config {
|
|||
pub database_url: String,
|
||||
pub seed_path: Option<PathBuf>,
|
||||
pub database_max_connections: u32,
|
||||
pub api_token: String,
|
||||
pub livekit_server: Option<String>,
|
||||
pub livekit_key: Option<String>,
|
||||
pub livekit_secret: Option<String>,
|
||||
|
|
@ -137,6 +141,7 @@ pub struct Config {
|
|||
pub kinesis_access_key: Option<String>,
|
||||
pub kinesis_secret_key: Option<String>,
|
||||
pub zed_environment: Arc<str>,
|
||||
pub zed_cloud_internal_api_key: String,
|
||||
pub zed_client_checksum_seed: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -168,13 +173,13 @@ impl Config {
|
|||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
database_max_connections: 0,
|
||||
api_token: "".into(),
|
||||
livekit_server: None,
|
||||
livekit_key: None,
|
||||
livekit_secret: None,
|
||||
rust_log: None,
|
||||
log_json: None,
|
||||
zed_environment: "test".into(),
|
||||
zed_cloud_internal_api_key: "test-internal-api-key".into(),
|
||||
blob_store_url: None,
|
||||
blob_store_region: None,
|
||||
blob_store_access_key: None,
|
||||
|
|
@ -216,6 +221,7 @@ pub struct AppState {
|
|||
pub blob_store_client: Option<aws_sdk_s3::Client>,
|
||||
pub executor: Executor,
|
||||
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
|
||||
pub user_service: Arc<dyn UserService>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +256,7 @@ impl AppState {
|
|||
let db = Arc::new(db);
|
||||
let this = Self {
|
||||
db: db.clone(),
|
||||
http_client: Some(http_client),
|
||||
http_client: Some(http_client.clone()),
|
||||
livekit_client,
|
||||
blob_store_client: build_blob_store_client(&config).await.log_err(),
|
||||
executor,
|
||||
|
|
@ -259,6 +265,19 @@ impl AppState {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
user_service: {
|
||||
let database_user_service = DatabaseUserService::new(db);
|
||||
let cloud_user_service = CloudUserService::new(
|
||||
http_client,
|
||||
config.zed_cloud_url().to_string(),
|
||||
config.zed_cloud_internal_api_key.clone(),
|
||||
);
|
||||
|
||||
Arc::new(TransitionalUserService::new(
|
||||
cloud_user_service,
|
||||
database_user_service,
|
||||
))
|
||||
},
|
||||
config,
|
||||
};
|
||||
Ok(Arc::new(this))
|
||||
|
|
|
|||
|
|
@ -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<User> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
crates/collab/src/services.rs
Normal file
3
crates/collab/src/services.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod user_service;
|
||||
|
||||
pub use user_service::*;
|
||||
390
crates/collab/src/services/user_service.rs
Normal file
390
crates/collab/src/services/user_service.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use cloud_api_types::internal_api::{
|
||||
self, LookUpUsersByLegacyIdBody, LookUpUsersByLegacyIdResponse,
|
||||
};
|
||||
use rpc::proto;
|
||||
|
||||
use crate::Result;
|
||||
use crate::db::{Channel, Database, UserId};
|
||||
use crate::entities::User;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use self::fake_user_service::*;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserService: Send + Sync + 'static {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
|
||||
|
||||
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
|
||||
|
||||
// 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<proto::ChannelMember>, Vec<User>)>;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
fn as_fake(&self) -> Arc<FakeUserService> {
|
||||
panic!("called as_fake on a real `UserService`");
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`UserService`] implementation for transitioning from reading from the database to reading from Cloud.
|
||||
pub struct TransitionalUserService {
|
||||
cloud_user_service: CloudUserService,
|
||||
database_user_service: DatabaseUserService,
|
||||
}
|
||||
|
||||
impl TransitionalUserService {
|
||||
pub fn new(
|
||||
cloud_user_service: CloudUserService,
|
||||
database_user_service: DatabaseUserService,
|
||||
) -> Self {
|
||||
Self {
|
||||
cloud_user_service,
|
||||
database_user_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserService for TransitionalUserService {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
|
||||
self.cloud_user_service.get_users_by_ids(ids).await
|
||||
}
|
||||
|
||||
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
|
||||
self.database_user_service
|
||||
.get_user_by_github_login(github_login)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
|
||||
self.database_user_service
|
||||
.fuzzy_search_users(query, limit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn search_channel_members(
|
||||
&self,
|
||||
channel: &Channel,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
|
||||
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<UserId>) -> Result<Vec<User>> {
|
||||
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<Option<User>> {
|
||||
let _ = github_login;
|
||||
|
||||
unimplemented!("not yet implemented in Cloud")
|
||||
}
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
|
||||
let _ = query;
|
||||
let _ = limit;
|
||||
|
||||
unimplemented!("not yet implemented in Cloud")
|
||||
}
|
||||
|
||||
async fn search_channel_members(
|
||||
&self,
|
||||
channel: &Channel,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
|
||||
let _ = channel;
|
||||
let _ = query;
|
||||
let _ = limit;
|
||||
|
||||
unimplemented!("not yet implemented in Cloud")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<internal_api::User> 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<Database>,
|
||||
}
|
||||
|
||||
impl DatabaseUserService {
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserService for DatabaseUserService {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
|
||||
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<Option<User>> {
|
||||
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<Vec<User>> {
|
||||
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<proto::ChannelMember>, Vec<User>)> {
|
||||
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<Self>,
|
||||
state: Arc<Mutex<FakeUserServiceState>>,
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
struct FakeUserServiceState {
|
||||
next_user_id: UserId,
|
||||
users: HashMap<UserId, User>,
|
||||
}
|
||||
|
||||
impl Default for FakeUserServiceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next_user_id: UserId(1),
|
||||
users: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeUserService {
|
||||
pub fn new(database: Arc<Database>) -> Arc<Self> {
|
||||
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<Option<User>> {
|
||||
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<UserId>) -> Result<Vec<User>> {
|
||||
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<Option<User>> {
|
||||
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<Vec<User>> {
|
||||
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<proto::ChannelMember>, Vec<User>)> {
|
||||
let state = self.state.lock().await;
|
||||
|
||||
let users = state
|
||||
.users
|
||||
.values()
|
||||
.filter(|user| user.github_login.contains(query))
|
||||
.take(limit as usize)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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<FakeUserService> {
|
||||
self.this.upgrade().unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Project>,
|
||||
client_b: TestClient,
|
||||
client_c: TestClient,
|
||||
channel_id: ChannelId,
|
||||
user_a_project: Entity<Project>,
|
||||
user_b_project: Entity<Project>,
|
||||
}
|
||||
|
||||
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::<SharedScreen>().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::<SharedScreen>()
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<Workspace>, cx: &mut VisualTestContext| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_item(cx)
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap()
|
||||
.update(cx, |editor, cx| editor.text(cx))
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
active_text(&workspace_a, cx_a),
|
||||
active_text(&workspace_b, cx_b)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ use client::{
|
|||
proto::PeerId,
|
||||
};
|
||||
use clock::FakeSystemClock;
|
||||
use collab::services::{FakeUserService, NewUserParams};
|
||||
use collab::{
|
||||
AppState, Config,
|
||||
db::{NewUserParams, UserId},
|
||||
db::UserId,
|
||||
executor::Executor,
|
||||
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
|
||||
};
|
||||
|
|
@ -179,14 +180,19 @@ impl TestServer {
|
|||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
|
||||
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await
|
||||
let user_id = if let Ok(Some(user)) = self
|
||||
.app_state
|
||||
.user_service
|
||||
.get_user_by_github_login(name)
|
||||
.await
|
||||
{
|
||||
user.id
|
||||
} else {
|
||||
let github_user_id = self.next_github_user_id;
|
||||
self.next_github_user_id += 1;
|
||||
self.app_state
|
||||
.db
|
||||
.user_service
|
||||
.as_fake()
|
||||
.create_user(
|
||||
&format!("{name}@example.com"),
|
||||
None,
|
||||
|
|
@ -197,8 +203,6 @@ impl TestServer {
|
|||
},
|
||||
)
|
||||
.await
|
||||
.expect("creating user failed")
|
||||
.user_id
|
||||
};
|
||||
|
||||
let http = FakeHttpClient::create({
|
||||
|
|
@ -244,7 +248,7 @@ impl TestServer {
|
|||
let client_name = name.to_string();
|
||||
let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
|
||||
let server = self.server.clone();
|
||||
let db = self.app_state.db.clone();
|
||||
let user_service = self.app_state.user_service.clone();
|
||||
let connection_killers = self.connection_killers.clone();
|
||||
let forbid_connections = self.forbid_connections.clone();
|
||||
|
||||
|
|
@ -268,7 +272,7 @@ impl TestServer {
|
|||
);
|
||||
|
||||
let server = server.clone();
|
||||
let db = db.clone();
|
||||
let user_service = user_service.clone();
|
||||
let connection_killers = connection_killers.clone();
|
||||
let forbid_connections = forbid_connections.clone();
|
||||
let client_name = client_name.clone();
|
||||
|
|
@ -281,7 +285,8 @@ impl TestServer {
|
|||
let (client_conn, server_conn, killed) =
|
||||
Connection::in_memory(cx.background_executor().clone());
|
||||
let (connection_id_tx, connection_id_rx) = oneshot::channel();
|
||||
let user = db
|
||||
let user = user_service
|
||||
.as_fake()
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
|
@ -294,7 +299,7 @@ impl TestServer {
|
|||
cx.background_spawn(server.handle_connection(
|
||||
server_conn,
|
||||
client_name,
|
||||
Principal::User(user.into()),
|
||||
Principal::User(user),
|
||||
ZedVersion(semver::Version::new(1, 0, 0)),
|
||||
Some("test".to_string()),
|
||||
None,
|
||||
|
|
@ -576,17 +581,18 @@ impl TestServer {
|
|||
blob_store_client: None,
|
||||
executor,
|
||||
kinesis_client: None,
|
||||
user_service: FakeUserService::new(test_db.db().clone()),
|
||||
config: Config {
|
||||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
database_max_connections: 0,
|
||||
api_token: "".into(),
|
||||
livekit_server: None,
|
||||
livekit_key: None,
|
||||
livekit_secret: None,
|
||||
rust_log: None,
|
||||
log_json: None,
|
||||
zed_environment: "test".into(),
|
||||
zed_cloud_internal_api_key: "test-internal-api-key".into(),
|
||||
blob_store_url: None,
|
||||
blob_store_region: None,
|
||||
blob_store_access_key: None,
|
||||
|
|
|
|||
|
|
@ -2913,6 +2913,13 @@ impl CollabPanel {
|
|||
if show_auto_watch || show_copy {
|
||||
Some(
|
||||
h_flex()
|
||||
.when_some(channel_link, |this, channel_link| {
|
||||
this.child(
|
||||
CopyButton::new("copy-channel-link", channel_link)
|
||||
.visible_on_hover("section-header")
|
||||
.tooltip_label("Copy Channel Link"),
|
||||
)
|
||||
})
|
||||
.when(has_auto_watch_flag, |this| {
|
||||
this.child(
|
||||
IconButton::new(
|
||||
|
|
@ -2952,13 +2959,6 @@ impl CollabPanel {
|
|||
)),
|
||||
)
|
||||
})
|
||||
.when_some(channel_link, |this, channel_link| {
|
||||
this.child(
|
||||
CopyButton::new("copy-channel-link", channel_link)
|
||||
.visible_on_hover("section-header")
|
||||
.tooltip_label("Copy Channel Link"),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
} else {
|
||||
|
|
@ -3830,6 +3830,12 @@ impl Panel for CollabPanel {
|
|||
fn activation_priority(&self) -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
|
||||
Some(workspace::HideStatusItem::new(|settings| {
|
||||
settings.collaboration_panel.get_or_insert_default().button = Some(false);
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for CollabPanel {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::*};
|
||||
|
|
|
|||
|
|
@ -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::*};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 _;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_
|
|||
use gpui::{
|
||||
Action, Anchor, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity,
|
||||
EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point,
|
||||
Subscription, Task, WeakEntity, anchored, deferred,
|
||||
Subscription, Task, TaskExt, WeakEntity, anchored, deferred,
|
||||
};
|
||||
|
||||
use itertools::Itertools as _;
|
||||
|
|
@ -1606,6 +1606,12 @@ impl Panel for DebugPanel {
|
|||
7
|
||||
}
|
||||
|
||||
fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
|
||||
Some(workspace::HideStatusItem::new(|settings| {
|
||||
settings.debugger.get_or_insert_default().button = Some(false);
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {}
|
||||
|
||||
fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue