Compare commits
30 Commits
additional
...
tool-call-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003e2c064f | ||
|
|
3ad2c1d120 | ||
|
|
23cf50f097 | ||
|
|
cd9aca69f6 | ||
|
|
93ecfa7555 | ||
|
|
3a013d8090 | ||
|
|
ab4cd95e9c | ||
|
|
78cd106b64 | ||
|
|
eba811a127 | ||
|
|
301d7fbc61 | ||
|
|
7972baafe9 | ||
|
|
abcf5a1273 | ||
|
|
d16619a654 | ||
|
|
0c91f061c3 | ||
|
|
91a976bf7b | ||
|
|
e4029c13c9 | ||
|
|
7098952a1c | ||
|
|
bd5569b338 | ||
|
|
be1f824a35 | ||
|
|
f21cec7cb1 | ||
|
|
93d79f3862 | ||
|
|
4896f477e2 | ||
|
|
d07818b20f | ||
|
|
c1317baebe | ||
|
|
3f11cbd62c | ||
|
|
bcebe76e53 | ||
|
|
0466db66cd | ||
|
|
420254cff1 | ||
|
|
8b9fa1581c | ||
|
|
914b0117fb |
@@ -15,15 +15,17 @@ with the community to improve the product in ways we haven't thought of (or had
|
||||
|
||||
In particular we love PRs that are:
|
||||
|
||||
- Fixes to existing bugs and issues.
|
||||
- Small enhancements to existing features, particularly to make them work for more people.
|
||||
- Fixing or extending the docs.
|
||||
- Fixing bugs.
|
||||
- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever).
|
||||
- Small extra features, like keybindings or actions you miss from other editors or extensions.
|
||||
- Work towards shipping larger features on our roadmap.
|
||||
- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541).
|
||||
|
||||
If you're looking for concrete ideas:
|
||||
|
||||
- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community.
|
||||
- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed.
|
||||
- [Curated board of issues](https://github.com/orgs/zed-industries/projects/69) suitable for everyone from first-time contributors to seasoned community champions.
|
||||
- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible).
|
||||
- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search).
|
||||
|
||||
## Sending changes
|
||||
|
||||
@@ -37,9 +39,17 @@ like, sorry).
|
||||
Although we will take a look, we tend to only merge about half the PRs that are
|
||||
submitted. If you'd like your PR to have the best chance of being merged:
|
||||
|
||||
- Include a clear description of what you're solving, and why it's important to you.
|
||||
- Include tests.
|
||||
- If it changes the UI, attach screenshots or screen recordings.
|
||||
- Make sure the change is **desired**: we're always happy to accept bugfixes,
|
||||
but features should be confirmed with us first if you aim to avoid wasted
|
||||
effort. If there isn't already a GitHub issue for your feature with staff
|
||||
confirmation that we want it, start with a GitHub discussion rather than a PR.
|
||||
- Include a clear description of **what you're solving**, and why it's important.
|
||||
- Include **tests**.
|
||||
- If it changes the UI, attach **screenshots** or screen recordings.
|
||||
- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two
|
||||
features and a refactoring on top of that.
|
||||
- Keep AI assistance under your judgement and responsibility: it's unlikely
|
||||
we'll merge a vibe-coded PR that the author doesn't understand.
|
||||
|
||||
The internal advice for reviewers is as follows:
|
||||
|
||||
@@ -50,10 +60,9 @@ The internal advice for reviewers is as follows:
|
||||
If you need more feedback from us: the best way is to be responsive to
|
||||
Github comments, or to offer up time to pair with us.
|
||||
|
||||
If you are making a larger change, or need advice on how to finish the change
|
||||
you're making, please open the PR early. We would love to help you get
|
||||
things right, and it's often easier to see how to solve a problem before the
|
||||
diff gets too big.
|
||||
If you need help deciding how to fix a bug, or finish implementing a feature
|
||||
that we've agreed we want, please open a PR early so we can discuss how to make
|
||||
the change with code in hand.
|
||||
|
||||
## Things we will (probably) not merge
|
||||
|
||||
@@ -61,11 +70,11 @@ Although there are few hard and fast rules, typically we don't merge:
|
||||
|
||||
- Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions).
|
||||
- New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs.
|
||||
- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
|
||||
- Giant refactorings.
|
||||
- Non-trivial changes with no tests.
|
||||
- Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much.
|
||||
- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit.
|
||||
- Anything that seems completely AI generated.
|
||||
- Anything that seems AI-generated without understanding the output.
|
||||
|
||||
## Bird's-eye view of Zed
|
||||
|
||||
|
||||
329
Cargo.lock
generated
329
Cargo.lock
generated
@@ -111,6 +111,15 @@ dependencies = [
|
||||
"workspace",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
|
||||
dependencies = [
|
||||
"gimli 0.31.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.25.1"
|
||||
@@ -292,6 +301,7 @@ dependencies = [
|
||||
name = "agent_settings"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"cloud_llm_client",
|
||||
"collections",
|
||||
@@ -1997,7 +2007,7 @@ version = "0.3.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"addr2line 0.25.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide",
|
||||
@@ -2656,9 +2666,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-fs-ext"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e41cc18551193fe8fa6f15c1e3c799bc5ec9e2cfbfaa8ed46f37013e3e6c173c"
|
||||
checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"cap-std",
|
||||
@@ -2668,9 +2678,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-net-ext"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
|
||||
checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"cap-std",
|
||||
@@ -2680,9 +2690,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-primitives"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a"
|
||||
checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"fs-set-times",
|
||||
@@ -2698,9 +2708,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-rand"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40"
|
||||
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"rand 0.8.5",
|
||||
@@ -2708,9 +2718,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-std"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189"
|
||||
checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
|
||||
dependencies = [
|
||||
"cap-primitives",
|
||||
"io-extras",
|
||||
@@ -2720,9 +2730,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cap-time-ext"
|
||||
version = "3.4.4"
|
||||
version = "3.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "491af520b8770085daa0466978c75db90368c71896523f2464214e38359b1a5b"
|
||||
checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
|
||||
dependencies = [
|
||||
"ambient-authority",
|
||||
"cap-primitives",
|
||||
@@ -3613,6 +3623,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"slotmap",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
@@ -3925,19 +3936,37 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bforest"
|
||||
version = "0.116.1"
|
||||
name = "cranelift-assembler-x64"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4"
|
||||
checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-assembler-x64-meta"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65"
|
||||
dependencies = [
|
||||
"cranelift-srcgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bforest"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895"
|
||||
dependencies = [
|
||||
"cranelift-entity",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-bitset"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34"
|
||||
checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -3945,11 +3974,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e"
|
||||
checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"cranelift-assembler-x64",
|
||||
"cranelift-bforest",
|
||||
"cranelift-bitset",
|
||||
"cranelift-codegen-meta",
|
||||
@@ -3958,9 +3988,10 @@ dependencies = [
|
||||
"cranelift-entity",
|
||||
"cranelift-isle",
|
||||
"gimli 0.31.1",
|
||||
"hashbrown 0.14.5",
|
||||
"hashbrown 0.15.5",
|
||||
"log",
|
||||
"postcard",
|
||||
"pulley-interpreter",
|
||||
"regalloc2",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
@@ -3972,33 +4003,36 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-meta"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8"
|
||||
checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15"
|
||||
dependencies = [
|
||||
"cranelift-assembler-x64-meta",
|
||||
"cranelift-codegen-shared",
|
||||
"cranelift-srcgen",
|
||||
"pulley-interpreter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-codegen-shared"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb"
|
||||
checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-control"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef"
|
||||
checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-entity"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323"
|
||||
checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"serde",
|
||||
@@ -4007,9 +4041,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-frontend"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57"
|
||||
checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"log",
|
||||
@@ -4019,21 +4053,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-isle"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d"
|
||||
checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285"
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-native"
|
||||
version = "0.116.1"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7"
|
||||
checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f"
|
||||
dependencies = [
|
||||
"cranelift-codegen",
|
||||
"libc",
|
||||
"target-lexicon 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cranelift-srcgen"
|
||||
version = "0.120.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b"
|
||||
|
||||
[[package]]
|
||||
name = "crash-context"
|
||||
version = "0.6.3"
|
||||
@@ -12421,6 +12461,7 @@ dependencies = [
|
||||
"context_server",
|
||||
"dap",
|
||||
"dap_adapters",
|
||||
"db",
|
||||
"extension",
|
||||
"fancy-regex",
|
||||
"fs",
|
||||
@@ -12794,13 +12835,12 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
|
||||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d"
|
||||
checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71"
|
||||
dependencies = [
|
||||
"cranelift-bitset",
|
||||
"log",
|
||||
"sptr",
|
||||
"wasmtime-math",
|
||||
]
|
||||
|
||||
@@ -13299,9 +13339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.11.2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a"
|
||||
checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"bumpalo",
|
||||
@@ -17310,9 +17350,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.10"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
|
||||
checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
@@ -18399,6 +18439,16 @@ dependencies = [
|
||||
"wasmparser 0.227.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.229.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.229.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.201.0"
|
||||
@@ -18484,22 +18534,36 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmprinter"
|
||||
version = "0.221.3"
|
||||
name = "wasmparser"
|
||||
version = "0.229.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283"
|
||||
checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmprinter"
|
||||
version = "0.229.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"termcolor",
|
||||
"wasmparser 0.221.3",
|
||||
"wasmparser 0.229.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69"
|
||||
checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c"
|
||||
dependencies = [
|
||||
"addr2line 0.24.2",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bitflags 2.9.4",
|
||||
@@ -18507,7 +18571,7 @@ dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"hashbrown 0.14.5",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -18515,12 +18579,11 @@ dependencies = [
|
||||
"memfd",
|
||||
"object 0.36.7",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"postcard",
|
||||
"psm",
|
||||
"pulley-interpreter",
|
||||
"rayon",
|
||||
"rustix 0.38.44",
|
||||
"rustix 1.1.2",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -18528,7 +18591,7 @@ dependencies = [
|
||||
"sptr",
|
||||
"target-lexicon 0.13.3",
|
||||
"trait-variant",
|
||||
"wasmparser 0.221.3",
|
||||
"wasmparser 0.229.0",
|
||||
"wasmtime-asm-macros",
|
||||
"wasmtime-component-macro",
|
||||
"wasmtime-component-util",
|
||||
@@ -18545,18 +18608,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-asm-macros"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2"
|
||||
checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-c-api-impl"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c"
|
||||
checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -18567,9 +18630,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-c-api-macros"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b"
|
||||
checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -18577,9 +18640,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-component-macro"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf"
|
||||
checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
@@ -18587,20 +18650,20 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
"wasmtime-component-util",
|
||||
"wasmtime-wit-bindgen",
|
||||
"wit-parser 0.221.3",
|
||||
"wit-parser 0.229.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-component-util"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e"
|
||||
checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-cranelift"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87"
|
||||
checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
@@ -18610,22 +18673,23 @@ dependencies = [
|
||||
"cranelift-frontend",
|
||||
"cranelift-native",
|
||||
"gimli 0.31.1",
|
||||
"itertools 0.12.1",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"object 0.36.7",
|
||||
"pulley-interpreter",
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.3",
|
||||
"thiserror 1.0.69",
|
||||
"wasmparser 0.221.3",
|
||||
"thiserror 2.0.17",
|
||||
"wasmparser 0.229.0",
|
||||
"wasmtime-environ",
|
||||
"wasmtime-versioned-export-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-environ"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad"
|
||||
checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cpp_demangle",
|
||||
@@ -18642,22 +18706,22 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.3",
|
||||
"wasm-encoder 0.221.3",
|
||||
"wasmparser 0.221.3",
|
||||
"wasm-encoder 0.229.0",
|
||||
"wasmparser 0.229.0",
|
||||
"wasmprinter",
|
||||
"wasmtime-component-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-fiber"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117"
|
||||
checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"rustix 0.38.44",
|
||||
"rustix 1.1.2",
|
||||
"wasmtime-asm-macros",
|
||||
"wasmtime-versioned-export-macros",
|
||||
"windows-sys 0.59.0",
|
||||
@@ -18665,9 +18729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-jit-icache-coherence"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1"
|
||||
checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cfg-if",
|
||||
@@ -18677,24 +18741,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-math"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17"
|
||||
checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-slab"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf"
|
||||
checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65"
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-versioned-export-macros"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b"
|
||||
checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -18703,9 +18767,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-wasi"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4"
|
||||
checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -18720,30 +18784,43 @@ dependencies = [
|
||||
"futures 0.3.31",
|
||||
"io-extras",
|
||||
"io-lifetimes",
|
||||
"rustix 0.38.44",
|
||||
"rustix 1.1.2",
|
||||
"system-interface",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"trait-variant",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi-io",
|
||||
"wiggle",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-winch"
|
||||
version = "29.0.1"
|
||||
name = "wasmtime-wasi-io"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f"
|
||||
checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes 1.10.1",
|
||||
"futures 0.3.31",
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-winch"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cranelift-codegen",
|
||||
"gimli 0.31.1",
|
||||
"object 0.36.7",
|
||||
"target-lexicon 0.13.3",
|
||||
"wasmparser 0.221.3",
|
||||
"wasmparser 0.229.0",
|
||||
"wasmtime-cranelift",
|
||||
"wasmtime-environ",
|
||||
"winch-codegen",
|
||||
@@ -18751,14 +18828,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmtime-wit-bindgen"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6"
|
||||
checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap",
|
||||
"wit-parser 0.221.3",
|
||||
"wit-parser 0.229.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -19052,14 +19129,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wiggle"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d"
|
||||
checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bitflags 2.9.4",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wiggle-macro",
|
||||
@@ -19067,24 +19144,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wiggle-generate"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cf267dd05673912c8138f4b54acabe6bd53407d9d1536f0fadb6520dd16e101"
|
||||
checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"shellexpand 2.1.2",
|
||||
"syn 2.0.106",
|
||||
"witx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiggle-macro"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c"
|
||||
checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -19125,18 +19201,19 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "winch-codegen"
|
||||
version = "29.0.1"
|
||||
version = "33.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c"
|
||||
checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cranelift-assembler-x64",
|
||||
"cranelift-codegen",
|
||||
"gimli 0.31.1",
|
||||
"regalloc2",
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.3",
|
||||
"thiserror 1.0.69",
|
||||
"wasmparser 0.221.3",
|
||||
"thiserror 2.0.17",
|
||||
"wasmparser 0.229.0",
|
||||
"wasmtime-cranelift",
|
||||
"wasmtime-environ",
|
||||
]
|
||||
@@ -20032,24 +20109,6 @@ dependencies = [
|
||||
"wasmparser 0.201.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.221.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser 0.221.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.227.1"
|
||||
@@ -20068,6 +20127,24 @@ dependencies = [
|
||||
"wasmparser 0.227.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.229.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser 0.229.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "witx"
|
||||
version = "0.9.1"
|
||||
@@ -20507,7 +20584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.218.0"
|
||||
version = "0.219.0"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
|
||||
@@ -663,7 +663,7 @@ tokio-socks = { version = "0.5.2", default-features = false, features = ["future
|
||||
toml = "0.8"
|
||||
toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] }
|
||||
tower-http = "0.4.4"
|
||||
tree-sitter = { version = "0.25.10", features = ["wasm"] }
|
||||
tree-sitter = { version = "0.26", features = ["wasm"] }
|
||||
tree-sitter-bash = "0.25.1"
|
||||
tree-sitter-c = "0.23"
|
||||
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
|
||||
@@ -697,7 +697,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
|
||||
walkdir = "2.5"
|
||||
wasm-encoder = "0.221"
|
||||
wasmparser = "0.221"
|
||||
wasmtime = { version = "29", default-features = false, features = [
|
||||
wasmtime = { version = "33", default-features = false, features = [
|
||||
"async",
|
||||
"demangle",
|
||||
"runtime",
|
||||
@@ -706,7 +706,7 @@ wasmtime = { version = "29", default-features = false, features = [
|
||||
"incremental-cache",
|
||||
"parallel-compilation",
|
||||
] }
|
||||
wasmtime-wasi = "29"
|
||||
wasmtime-wasi = "33"
|
||||
wax = "0.6"
|
||||
which = "6.0.0"
|
||||
windows-core = "0.61"
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"ctrl-alt-z": "edit_prediction::RatePredictions",
|
||||
"ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
|
||||
"ctrl-alt-l": "lsp_tool::ToggleMenu",
|
||||
"ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -251,6 +252,7 @@
|
||||
"ctrl-y": "agent::AllowOnce",
|
||||
"ctrl-alt-y": "agent::AllowAlways",
|
||||
"ctrl-alt-z": "agent::RejectOnce",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -345,6 +347,7 @@
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -900,6 +903,8 @@
|
||||
{
|
||||
"context": "GitPanel && ChangesList",
|
||||
"bindings": {
|
||||
"left": "git_panel::CollapseSelectedEntry",
|
||||
"right": "git_panel::ExpandSelectedEntry",
|
||||
"up": "menu::SelectPrevious",
|
||||
"down": "menu::SelectNext",
|
||||
"enter": "menu::Confirm",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"ctrl-cmd-i": "edit_prediction::ToggleMenu",
|
||||
"ctrl-cmd-l": "lsp_tool::ToggleMenu",
|
||||
"ctrl-cmd-c": "editor::DisplayCursorNames",
|
||||
"ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -265,6 +266,7 @@
|
||||
"cmd-g": "search::SelectNextMatch",
|
||||
"cmd-shift-g": "search::SelectPreviousMatch",
|
||||
"cmd-k l": "agent::OpenRulesLibrary",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -291,6 +293,7 @@
|
||||
"cmd-y": "agent::AllowOnce",
|
||||
"cmd-alt-y": "agent::AllowAlways",
|
||||
"cmd-alt-z": "agent::RejectOnce",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -385,6 +388,7 @@
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -396,6 +400,7 @@
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -879,6 +884,7 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-alt-/": "agent::ToggleModelSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
"ctrl-[": "agent::CyclePreviousInlineAssist",
|
||||
"ctrl-]": "agent::CycleNextInlineAssist",
|
||||
"cmd-shift-enter": "inline_assistant::ThumbsUpResult",
|
||||
@@ -975,6 +981,8 @@
|
||||
"context": "GitPanel && ChangesList",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"left": "git_panel::CollapseSelectedEntry",
|
||||
"right": "git_panel::ExpandSelectedEntry",
|
||||
"up": "menu::SelectPrevious",
|
||||
"down": "menu::SelectNext",
|
||||
"cmd-up": "menu::SelectFirst",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"ctrl-shift-i": "edit_prediction::ToggleMenu",
|
||||
"shift-alt-l": "lsp_tool::ToggleMenu",
|
||||
"ctrl-shift-alt-c": "editor::DisplayCursorNames",
|
||||
"ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -252,6 +253,7 @@
|
||||
"shift-alt-a": "agent::AllowOnce",
|
||||
"ctrl-alt-y": "agent::AllowAlways",
|
||||
"shift-alt-z": "agent::RejectOnce",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -341,6 +343,7 @@
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -352,6 +355,7 @@
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector",
|
||||
"alt-tab": "agent::CycleFavoriteModels",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -904,6 +908,8 @@
|
||||
"context": "GitPanel && ChangesList",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"left": "git_panel::CollapseSelectedEntry",
|
||||
"right": "git_panel::ExpandSelectedEntry",
|
||||
"up": "menu::SelectPrevious",
|
||||
"down": "menu::SelectNext",
|
||||
"enter": "menu::Confirm",
|
||||
|
||||
@@ -502,6 +502,11 @@
|
||||
"g p": "pane::ActivatePreviousItem",
|
||||
"shift-h": "pane::ActivatePreviousItem", // not a helix default
|
||||
"g .": "vim::HelixGotoLastModification",
|
||||
"g o": "editor::ToggleSelectedDiffHunks", // Zed specific
|
||||
"g shift-o": "git::ToggleStaged", // Zed specific
|
||||
"g shift-r": "git::Restore", // Zed specific
|
||||
"g u": "git::StageAndNext", // Zed specific
|
||||
"g shift-u": "git::UnstageAndNext", // Zed specific
|
||||
|
||||
// Window mode
|
||||
"space w v": "pane::SplitDown",
|
||||
|
||||
@@ -972,6 +972,8 @@
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
"read_file": true,
|
||||
"restore_file_from_disk": true,
|
||||
"save_file": true,
|
||||
"open": true,
|
||||
"grep": true,
|
||||
"terminal": true,
|
||||
@@ -2060,6 +2062,12 @@
|
||||
//
|
||||
// Default: true
|
||||
"restore_unsaved_buffers": true,
|
||||
// Whether or not to skip worktree trust checks.
|
||||
// When trusted, project settings are synchronized automatically,
|
||||
// language and MCP servers are downloaded and started automatically.
|
||||
//
|
||||
// Default: false
|
||||
"trust_all_worktrees": false,
|
||||
},
|
||||
// Zed's Prettier integration settings.
|
||||
// Allows to enable/disable formatting with Prettier
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct UserMessage {
|
||||
pub content: ContentBlock,
|
||||
pub chunks: Vec<acp::ContentBlock>,
|
||||
pub checkpoint: Option<Checkpoint>,
|
||||
pub indented: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -73,6 +74,7 @@ impl UserMessage {
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct AssistantMessage {
|
||||
pub chunks: Vec<AssistantMessageChunk>,
|
||||
pub indented: bool,
|
||||
}
|
||||
|
||||
impl AssistantMessage {
|
||||
@@ -123,6 +125,14 @@ pub enum AgentThreadEntry {
|
||||
}
|
||||
|
||||
impl AgentThreadEntry {
|
||||
pub fn is_indented(&self) -> bool {
|
||||
match self {
|
||||
Self::UserMessage(message) => message.indented,
|
||||
Self::AssistantMessage(message) => message.indented,
|
||||
Self::ToolCall(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self, cx: &App) -> String {
|
||||
match self {
|
||||
Self::UserMessage(message) => message.to_markdown(cx),
|
||||
@@ -1184,6 +1194,16 @@ impl AcpThread {
|
||||
message_id: Option<UserMessageId>,
|
||||
chunk: acp::ContentBlock,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.push_user_content_block_with_indent(message_id, chunk, false, cx)
|
||||
}
|
||||
|
||||
pub fn push_user_content_block_with_indent(
|
||||
&mut self,
|
||||
message_id: Option<UserMessageId>,
|
||||
chunk: acp::ContentBlock,
|
||||
indented: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let path_style = self.project.read(cx).path_style(cx);
|
||||
@@ -1194,8 +1214,10 @@ impl AcpThread {
|
||||
id,
|
||||
content,
|
||||
chunks,
|
||||
indented: existing_indented,
|
||||
..
|
||||
}) = last_entry
|
||||
&& *existing_indented == indented
|
||||
{
|
||||
*id = message_id.or(id.take());
|
||||
content.append(chunk.clone(), &language_registry, path_style, cx);
|
||||
@@ -1210,6 +1232,7 @@ impl AcpThread {
|
||||
content,
|
||||
chunks: vec![chunk],
|
||||
checkpoint: None,
|
||||
indented,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1221,12 +1244,26 @@ impl AcpThread {
|
||||
chunk: acp::ContentBlock,
|
||||
is_thought: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
|
||||
}
|
||||
|
||||
pub fn push_assistant_content_block_with_indent(
|
||||
&mut self,
|
||||
chunk: acp::ContentBlock,
|
||||
is_thought: bool,
|
||||
indented: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let path_style = self.project.read(cx).path_style(cx);
|
||||
let entries_len = self.entries.len();
|
||||
if let Some(last_entry) = self.entries.last_mut()
|
||||
&& let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
|
||||
&& let AgentThreadEntry::AssistantMessage(AssistantMessage {
|
||||
chunks,
|
||||
indented: existing_indented,
|
||||
}) = last_entry
|
||||
&& *existing_indented == indented
|
||||
{
|
||||
let idx = entries_len - 1;
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(idx));
|
||||
@@ -1255,6 +1292,7 @@ impl AcpThread {
|
||||
self.push_entry(
|
||||
AgentThreadEntry::AssistantMessage(AssistantMessage {
|
||||
chunks: vec![chunk],
|
||||
indented,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1704,6 +1742,7 @@ impl AcpThread {
|
||||
content: block,
|
||||
chunks: message,
|
||||
checkpoint: None,
|
||||
indented: false,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -202,6 +202,12 @@ pub trait AgentModelSelector: 'static {
|
||||
fn should_render_footer(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this selector supports the favorites feature.
|
||||
/// Only the native agent uses the model ID format that maps to settings.
|
||||
fn supports_favorites(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -239,6 +245,10 @@ impl AgentModelList {
|
||||
AgentModelList::Grouped(groups) => groups.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_flat(&self) -> bool {
|
||||
matches!(self, AgentModelList::Flat(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
|
||||
@@ -5,12 +5,12 @@ mod legacy_thread;
|
||||
mod native_agent_server;
|
||||
pub mod outline;
|
||||
mod templates;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod thread;
|
||||
mod tools;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use context_server::ContextServerId;
|
||||
pub use db::*;
|
||||
pub use history_store::*;
|
||||
pub use native_agent_server::NativeAgentServer;
|
||||
@@ -18,11 +18,11 @@ pub use templates::*;
|
||||
pub use thread::*;
|
||||
pub use tools::*;
|
||||
|
||||
use acp_thread::{AcpThread, AgentModelSelector};
|
||||
use acp_thread::{AcpThread, AgentModelSelector, UserMessageId};
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use collections::{HashSet, IndexMap};
|
||||
use collections::{HashMap, HashSet, IndexMap};
|
||||
use fs::Fs;
|
||||
use futures::channel::{mpsc, oneshot};
|
||||
use futures::future::Shared;
|
||||
@@ -39,7 +39,6 @@ use prompt_store::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{LanguageModelSelection, update_settings_file};
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -252,12 +251,24 @@ impl NativeAgent {
|
||||
.await;
|
||||
|
||||
cx.new(|cx| {
|
||||
let context_server_store = project.read(cx).context_server_store();
|
||||
let context_server_registry =
|
||||
cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
|
||||
|
||||
let mut subscriptions = vec![
|
||||
cx.subscribe(&project, Self::handle_project_event),
|
||||
cx.subscribe(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
Self::handle_models_updated_event,
|
||||
),
|
||||
cx.subscribe(
|
||||
&context_server_store,
|
||||
Self::handle_context_server_store_updated,
|
||||
),
|
||||
cx.subscribe(
|
||||
&context_server_registry,
|
||||
Self::handle_context_server_registry_event,
|
||||
),
|
||||
];
|
||||
if let Some(prompt_store) = prompt_store.as_ref() {
|
||||
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
|
||||
@@ -266,16 +277,14 @@ impl NativeAgent {
|
||||
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
|
||||
watch::channel(());
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
sessions: HashMap::default(),
|
||||
history,
|
||||
project_context: cx.new(|_| project_context),
|
||||
project_context_needs_refresh: project_context_needs_refresh_tx,
|
||||
_maintain_project_context: cx.spawn(async move |this, cx| {
|
||||
Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
|
||||
}),
|
||||
context_server_registry: cx.new(|cx| {
|
||||
ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
|
||||
}),
|
||||
context_server_registry,
|
||||
templates,
|
||||
models: LanguageModels::new(cx),
|
||||
project,
|
||||
@@ -344,6 +353,9 @@ impl NativeAgent {
|
||||
pending_save: Task::ready(()),
|
||||
},
|
||||
);
|
||||
|
||||
self.update_available_commands(cx);
|
||||
|
||||
acp_thread
|
||||
}
|
||||
|
||||
@@ -414,10 +426,7 @@ impl NativeAgent {
|
||||
.into_iter()
|
||||
.flat_map(|(contents, prompt_metadata)| match contents {
|
||||
Ok(contents) => Some(UserRulesContext {
|
||||
uuid: match prompt_metadata.id {
|
||||
prompt_store::PromptId::User { uuid } => uuid,
|
||||
prompt_store::PromptId::EditWorkflow => return None,
|
||||
},
|
||||
uuid: prompt_metadata.id.user_id()?,
|
||||
title: prompt_metadata.title.map(|title| title.to_string()),
|
||||
contents,
|
||||
}),
|
||||
@@ -611,6 +620,99 @@ impl NativeAgent {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_context_server_store_updated(
|
||||
&mut self,
|
||||
_store: Entity<project::context_server_store::ContextServerStore>,
|
||||
_event: &project::context_server_store::Event,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.update_available_commands(cx);
|
||||
}
|
||||
|
||||
fn handle_context_server_registry_event(
|
||||
&mut self,
|
||||
_registry: Entity<ContextServerRegistry>,
|
||||
event: &ContextServerRegistryEvent,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
ContextServerRegistryEvent::ToolsChanged => {}
|
||||
ContextServerRegistryEvent::PromptsChanged => {
|
||||
self.update_available_commands(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_available_commands(&self, cx: &mut Context<Self>) {
|
||||
let available_commands = self.build_available_commands(cx);
|
||||
for session in self.sessions.values() {
|
||||
if let Some(acp_thread) = session.acp_thread.upgrade() {
|
||||
acp_thread.update(cx, |thread, cx| {
|
||||
thread
|
||||
.handle_session_update(
|
||||
acp::SessionUpdate::AvailableCommandsUpdate(
|
||||
acp::AvailableCommandsUpdate::new(available_commands.clone()),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
|
||||
let registry = self.context_server_registry.read(cx);
|
||||
|
||||
let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
|
||||
for context_server_prompt in registry.prompts() {
|
||||
*prompt_name_counts
|
||||
.entry(context_server_prompt.prompt.name.as_str())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
registry
|
||||
.prompts()
|
||||
.flat_map(|context_server_prompt| {
|
||||
let prompt = &context_server_prompt.prompt;
|
||||
|
||||
let should_prefix = prompt_name_counts
|
||||
.get(prompt.name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
> 1;
|
||||
|
||||
let name = if should_prefix {
|
||||
format!("{}.{}", context_server_prompt.server_id, prompt.name)
|
||||
} else {
|
||||
prompt.name.clone()
|
||||
};
|
||||
|
||||
let mut command = acp::AvailableCommand::new(
|
||||
name,
|
||||
prompt.description.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
match prompt.arguments.as_deref() {
|
||||
Some([arg]) => {
|
||||
let hint = format!("<{}>", arg.name);
|
||||
|
||||
command = command.input(acp::AvailableCommandInput::Unstructured(
|
||||
acp::UnstructuredCommandInput::new(hint),
|
||||
));
|
||||
}
|
||||
Some([]) | None => {}
|
||||
Some(_) => {
|
||||
// skip >1 argument commands since we don't support them yet
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(command)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_thread(
|
||||
&mut self,
|
||||
id: acp::SessionId,
|
||||
@@ -709,6 +811,102 @@ impl NativeAgent {
|
||||
history.update(cx, |history, cx| history.reload(cx)).ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn send_mcp_prompt(
|
||||
&self,
|
||||
message_id: UserMessageId,
|
||||
session_id: agent_client_protocol::SessionId,
|
||||
prompt_name: String,
|
||||
server_id: ContextServerId,
|
||||
arguments: HashMap<String, String>,
|
||||
original_content: Vec<acp::ContentBlock>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
let server_store = self.context_server_registry.read(cx).server_store().clone();
|
||||
let path_style = self.project.read(cx).path_style(cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let prompt =
|
||||
crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
|
||||
|
||||
let (acp_thread, thread) = this.update(cx, |this, _cx| {
|
||||
let session = this
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.context("Failed to get session")?;
|
||||
anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
|
||||
})??;
|
||||
|
||||
let mut last_is_user = true;
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_acp_user_block(
|
||||
message_id,
|
||||
original_content.into_iter().skip(1),
|
||||
path_style,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
for message in prompt.messages {
|
||||
let context_server::types::PromptMessage { role, content } = message;
|
||||
let block = mcp_message_content_to_acp_content_block(content);
|
||||
|
||||
match role {
|
||||
context_server::types::Role::User => {
|
||||
let id = acp_thread::UserMessageId::new();
|
||||
|
||||
acp_thread.update(cx, |acp_thread, cx| {
|
||||
acp_thread.push_user_content_block_with_indent(
|
||||
Some(id.clone()),
|
||||
block.clone(),
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_acp_user_block(id, [block], path_style, cx);
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
}
|
||||
context_server::types::Role::Assistant => {
|
||||
acp_thread.update(cx, |acp_thread, cx| {
|
||||
acp_thread.push_assistant_content_block_with_indent(
|
||||
block.clone(),
|
||||
false,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_acp_agent_block(block, cx);
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
}
|
||||
}
|
||||
|
||||
last_is_user = role == context_server::types::Role::User;
|
||||
}
|
||||
|
||||
let response_stream = thread.update(cx, |thread, cx| {
|
||||
if last_is_user {
|
||||
thread.send_existing(cx)
|
||||
} else {
|
||||
// Resume if MCP prompt did not end with a user message
|
||||
thread.resume(cx)
|
||||
}
|
||||
})??;
|
||||
|
||||
cx.update(|cx| {
|
||||
NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
|
||||
})?
|
||||
.await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper struct that implements the AgentConnection trait
|
||||
@@ -843,6 +1041,39 @@ impl NativeAgentConnection {
|
||||
}
|
||||
}
|
||||
|
||||
struct Command<'a> {
|
||||
prompt_name: &'a str,
|
||||
arg_value: &'a str,
|
||||
explicit_server_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Command<'a> {
|
||||
fn parse(prompt: &'a [acp::ContentBlock]) -> Option<Self> {
|
||||
let acp::ContentBlock::Text(text_content) = prompt.first()? else {
|
||||
return None;
|
||||
};
|
||||
let text = text_content.text.trim();
|
||||
let command = text.strip_prefix('/')?;
|
||||
let (command, arg_value) = command
|
||||
.split_once(char::is_whitespace)
|
||||
.unwrap_or((command, ""));
|
||||
|
||||
if let Some((server_id, prompt_name)) = command.split_once('.') {
|
||||
Some(Self {
|
||||
prompt_name,
|
||||
arg_value,
|
||||
explicit_server_id: Some(server_id),
|
||||
})
|
||||
} else {
|
||||
Some(Self {
|
||||
prompt_name: command,
|
||||
arg_value,
|
||||
explicit_server_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeAgentModelSelector {
|
||||
session_id: acp::SessionId,
|
||||
connection: NativeAgentConnection,
|
||||
@@ -933,6 +1164,10 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
|
||||
fn should_render_footer(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn supports_favorites(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
@@ -1008,6 +1243,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
let session_id = params.session_id.clone();
|
||||
log::info!("Received prompt request for session: {}", session_id);
|
||||
log::debug!("Prompt blocks count: {}", params.prompt.len());
|
||||
|
||||
if let Some(parsed_command) = Command::parse(¶ms.prompt) {
|
||||
let registry = self.0.read(cx).context_server_registry.read(cx);
|
||||
|
||||
let explicit_server_id = parsed_command
|
||||
.explicit_server_id
|
||||
.map(|server_id| ContextServerId(server_id.into()));
|
||||
|
||||
if let Some(prompt) =
|
||||
registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
|
||||
{
|
||||
let arguments = if !parsed_command.arg_value.is_empty()
|
||||
&& let Some(arg_name) = prompt
|
||||
.prompt
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|args| args.first())
|
||||
.map(|arg| arg.name.clone())
|
||||
{
|
||||
HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
let prompt_name = prompt.prompt.name.clone();
|
||||
let server_id = prompt.server_id.clone();
|
||||
|
||||
return self.0.update(cx, |agent, cx| {
|
||||
agent.send_mcp_prompt(
|
||||
id,
|
||||
session_id.clone(),
|
||||
prompt_name,
|
||||
server_id,
|
||||
arguments,
|
||||
params.prompt,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
let path_style = self.0.read(cx).project.read(cx).path_style(cx);
|
||||
|
||||
self.run_turn(session_id, cx, move |thread, cx| {
|
||||
@@ -1604,3 +1880,35 @@ mod internal_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_message_content_to_acp_content_block(
|
||||
content: context_server::types::MessageContent,
|
||||
) -> acp::ContentBlock {
|
||||
match content {
|
||||
context_server::types::MessageContent::Text {
|
||||
text,
|
||||
annotations: _,
|
||||
} => text.into(),
|
||||
context_server::types::MessageContent::Image {
|
||||
data,
|
||||
mime_type,
|
||||
annotations: _,
|
||||
} => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
|
||||
context_server::types::MessageContent::Audio {
|
||||
data,
|
||||
mime_type,
|
||||
annotations: _,
|
||||
} => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
|
||||
context_server::types::MessageContent::Resource {
|
||||
resource,
|
||||
annotations: _,
|
||||
} => {
|
||||
let mut link =
|
||||
acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
|
||||
if let Some(mime_type) = resource.mime_type {
|
||||
link = link.mime_type(mime_type);
|
||||
}
|
||||
acp::ContentBlock::ResourceLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2809,3 +2809,181 @@ fn setup_context_server(
|
||||
cx.run_until_parked();
|
||||
mcp_tool_calls_rx
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tokens_before_message(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
// First message
|
||||
let message_1_id = UserMessageId::new();
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(message_1_id.clone(), ["First message"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Before any response, tokens_before_message should return None for first message
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_1_id),
|
||||
None,
|
||||
"First message should have no tokens before it"
|
||||
);
|
||||
});
|
||||
|
||||
// Complete first message with usage
|
||||
fake_model.send_last_completion_stream_text_chunk("Response 1");
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
|
||||
language_model::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// First message still has no tokens before it
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_1_id),
|
||||
None,
|
||||
"First message should still have no tokens before it after response"
|
||||
);
|
||||
});
|
||||
|
||||
// Second message
|
||||
let message_2_id = UserMessageId::new();
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(message_2_id.clone(), ["Second message"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Second message should have first message's input tokens before it
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_2_id),
|
||||
Some(100),
|
||||
"Second message should have 100 tokens before it (from first request)"
|
||||
);
|
||||
});
|
||||
|
||||
// Complete second message
|
||||
fake_model.send_last_completion_stream_text_chunk("Response 2");
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
|
||||
language_model::TokenUsage {
|
||||
input_tokens: 250, // Total for this request (includes previous context)
|
||||
output_tokens: 75,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Third message
|
||||
let message_3_id = UserMessageId::new();
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(message_3_id.clone(), ["Third message"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Third message should have second message's input tokens (250) before it
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_3_id),
|
||||
Some(250),
|
||||
"Third message should have 250 tokens before it (from second request)"
|
||||
);
|
||||
// Second message should still have 100
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_2_id),
|
||||
Some(100),
|
||||
"Second message should still have 100 tokens before it"
|
||||
);
|
||||
// First message still has none
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_1_id),
|
||||
None,
|
||||
"First message should still have no tokens before it"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
// Set up three messages with responses
|
||||
let message_1_id = UserMessageId::new();
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(message_1_id.clone(), ["Message 1"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
fake_model.send_last_completion_stream_text_chunk("Response 1");
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
|
||||
language_model::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
let message_2_id = UserMessageId::new();
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(message_2_id.clone(), ["Message 2"], cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
fake_model.send_last_completion_stream_text_chunk("Response 2");
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate(
|
||||
language_model::TokenUsage {
|
||||
input_tokens: 250,
|
||||
output_tokens: 75,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify initial state
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.tokens_before_message(&message_2_id), Some(100));
|
||||
});
|
||||
|
||||
// Truncate at message 2 (removes message 2 and everything after)
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx))
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// After truncation, message_2_id no longer exists, so lookup should return None
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_2_id),
|
||||
None,
|
||||
"After truncation, message 2 no longer exists"
|
||||
);
|
||||
// Message 1 still exists but has no tokens before it
|
||||
assert_eq!(
|
||||
thread.tokens_before_message(&message_1_id),
|
||||
None,
|
||||
"First message still has no tokens before it"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use crate::{
|
||||
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
|
||||
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
|
||||
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
|
||||
SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool,
|
||||
RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool,
|
||||
ThinkingTool, WebSearchTool,
|
||||
};
|
||||
use acp_thread::{MentionUri, UserMessageId};
|
||||
use action_log::ActionLog;
|
||||
@@ -107,7 +108,13 @@ impl Message {
|
||||
|
||||
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
|
||||
match self {
|
||||
Message::User(message) => vec![message.to_request()],
|
||||
Message::User(message) => {
|
||||
if message.content.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![message.to_request()]
|
||||
}
|
||||
}
|
||||
Message::Agent(message) => message.to_request(),
|
||||
Message::Resume => vec![LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
@@ -1002,6 +1009,8 @@ impl Thread {
|
||||
self.project.clone(),
|
||||
self.action_log.clone(),
|
||||
));
|
||||
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));
|
||||
self.add_tool(ThinkingTool);
|
||||
self.add_tool(WebSearchTool);
|
||||
@@ -1086,6 +1095,28 @@ impl Thread {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the total input token count as of the message before the given message.
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - `target_id` is the first message (no previous message)
|
||||
/// - The previous message hasn't received a response yet (no usage data)
|
||||
/// - `target_id` is not found in the messages
|
||||
pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option<u64> {
|
||||
let mut previous_user_message_id: Option<&UserMessageId> = None;
|
||||
|
||||
for message in &self.messages {
|
||||
if let Message::User(user_msg) = message {
|
||||
if &user_msg.id == target_id {
|
||||
let prev_id = previous_user_message_id?;
|
||||
let usage = self.request_token_usage.get(prev_id)?;
|
||||
return Some(usage.input_tokens);
|
||||
}
|
||||
previous_user_message_id = Some(&user_msg.id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Look up the active profile and resolve its preferred model if one is configured.
|
||||
fn resolve_profile_model(
|
||||
profile_id: &AgentProfileId,
|
||||
@@ -1138,11 +1169,6 @@ impl Thread {
|
||||
where
|
||||
T: Into<UserMessageContent>,
|
||||
{
|
||||
let model = self.model().context("No language model configured")?;
|
||||
|
||||
log::info!("Thread::send called with model: {}", model.name().0);
|
||||
self.advance_prompt_id();
|
||||
|
||||
let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
|
||||
log::debug!("Thread::send content: {:?}", content);
|
||||
|
||||
@@ -1150,10 +1176,59 @@ impl Thread {
|
||||
.push(Message::User(UserMessage { id, content }));
|
||||
cx.notify();
|
||||
|
||||
self.send_existing(cx)
|
||||
}
|
||||
|
||||
pub fn send_existing(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
||||
let model = self.model().context("No language model configured")?;
|
||||
|
||||
log::info!("Thread::send called with model: {}", model.name().0);
|
||||
self.advance_prompt_id();
|
||||
|
||||
log::debug!("Total messages in thread: {}", self.messages.len());
|
||||
self.run_turn(cx)
|
||||
}
|
||||
|
||||
pub fn push_acp_user_block(
|
||||
&mut self,
|
||||
id: UserMessageId,
|
||||
blocks: impl IntoIterator<Item = acp::ContentBlock>,
|
||||
path_style: PathStyle,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let content = blocks
|
||||
.into_iter()
|
||||
.map(|block| UserMessageContent::from_content_block(block, path_style))
|
||||
.collect::<Vec<_>>();
|
||||
self.messages
|
||||
.push(Message::User(UserMessage { id, content }));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context<Self>) {
|
||||
let text = match block {
|
||||
acp::ContentBlock::Text(text_content) => text_content.text,
|
||||
acp::ContentBlock::Image(_) => "[image]".to_string(),
|
||||
acp::ContentBlock::Audio(_) => "[audio]".to_string(),
|
||||
acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
|
||||
acp::ContentBlock::Resource(resource) => match resource.resource {
|
||||
acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
|
||||
acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
|
||||
_ => "[resource]".to_string(),
|
||||
},
|
||||
_ => "[unknown]".to_string(),
|
||||
};
|
||||
|
||||
self.messages.push(Message::Agent(AgentMessage {
|
||||
content: vec![AgentMessageContent::Text(text)],
|
||||
..Default::default()
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
#[cfg(feature = "eval")]
|
||||
pub fn proceed(
|
||||
&mut self,
|
||||
@@ -1966,6 +2041,12 @@ impl Thread {
|
||||
self.running_turn.as_ref()?.tools.get(name).cloned()
|
||||
}
|
||||
|
||||
pub fn has_tool(&self, name: &str) -> bool {
|
||||
self.running_turn
|
||||
.as_ref()
|
||||
.is_some_and(|turn| turn.tools.contains_key(name))
|
||||
}
|
||||
|
||||
fn build_request_messages(
|
||||
&self,
|
||||
available_tools: Vec<SharedString>,
|
||||
|
||||
@@ -4,7 +4,6 @@ mod create_directory_tool;
|
||||
mod delete_path_tool;
|
||||
mod diagnostics_tool;
|
||||
mod edit_file_tool;
|
||||
|
||||
mod fetch_tool;
|
||||
mod find_path_tool;
|
||||
mod grep_tool;
|
||||
@@ -13,6 +12,8 @@ mod move_path_tool;
|
||||
mod now_tool;
|
||||
mod open_tool;
|
||||
mod read_file_tool;
|
||||
mod restore_file_from_disk_tool;
|
||||
mod save_file_tool;
|
||||
|
||||
mod terminal_tool;
|
||||
mod thinking_tool;
|
||||
@@ -27,7 +28,6 @@ pub use create_directory_tool::*;
|
||||
pub use delete_path_tool::*;
|
||||
pub use diagnostics_tool::*;
|
||||
pub use edit_file_tool::*;
|
||||
|
||||
pub use fetch_tool::*;
|
||||
pub use find_path_tool::*;
|
||||
pub use grep_tool::*;
|
||||
@@ -36,6 +36,8 @@ pub use move_path_tool::*;
|
||||
pub use now_tool::*;
|
||||
pub use open_tool::*;
|
||||
pub use read_file_tool::*;
|
||||
pub use restore_file_from_disk_tool::*;
|
||||
pub use save_file_tool::*;
|
||||
|
||||
pub use terminal_tool::*;
|
||||
pub use thinking_tool::*;
|
||||
@@ -92,6 +94,8 @@ tools! {
|
||||
NowTool,
|
||||
OpenTool,
|
||||
ReadFileTool,
|
||||
RestoreFileFromDiskTool,
|
||||
SaveFileTool,
|
||||
TerminalTool,
|
||||
ThinkingTool,
|
||||
WebSearchTool,
|
||||
|
||||
@@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream};
|
||||
use agent_client_protocol::ToolKind;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use collections::{BTreeMap, HashMap};
|
||||
use context_server::ContextServerId;
|
||||
use gpui::{App, Context, Entity, SharedString, Task};
|
||||
use context_server::{ContextServerId, client::NotificationSubscription};
|
||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
|
||||
use project::context_server_store::{ContextServerStatus, ContextServerStore};
|
||||
use std::sync::Arc;
|
||||
use util::ResultExt;
|
||||
|
||||
pub struct ContextServerPrompt {
|
||||
pub server_id: ContextServerId,
|
||||
pub prompt: context_server::types::Prompt,
|
||||
}
|
||||
|
||||
pub enum ContextServerRegistryEvent {
|
||||
ToolsChanged,
|
||||
PromptsChanged,
|
||||
}
|
||||
|
||||
impl EventEmitter<ContextServerRegistryEvent> for ContextServerRegistry {}
|
||||
|
||||
pub struct ContextServerRegistry {
|
||||
server_store: Entity<ContextServerStore>,
|
||||
registered_servers: HashMap<ContextServerId, RegisteredContextServer>,
|
||||
@@ -16,7 +28,10 @@ pub struct ContextServerRegistry {
|
||||
|
||||
struct RegisteredContextServer {
|
||||
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
||||
prompts: BTreeMap<SharedString, ContextServerPrompt>,
|
||||
load_tools: Task<Result<()>>,
|
||||
load_prompts: Task<Result<()>>,
|
||||
_tools_updated_subscription: Option<NotificationSubscription>,
|
||||
}
|
||||
|
||||
impl ContextServerRegistry {
|
||||
@@ -28,6 +43,7 @@ impl ContextServerRegistry {
|
||||
};
|
||||
for server in server_store.read(cx).running_servers() {
|
||||
this.reload_tools_for_server(server.id(), cx);
|
||||
this.reload_prompts_for_server(server.id(), cx);
|
||||
}
|
||||
this
|
||||
}
|
||||
@@ -56,6 +72,88 @@ impl ContextServerRegistry {
|
||||
.map(|(id, server)| (id, &server.tools))
|
||||
}
|
||||
|
||||
pub fn prompts(&self) -> impl Iterator<Item = &ContextServerPrompt> {
|
||||
self.registered_servers
|
||||
.values()
|
||||
.flat_map(|server| server.prompts.values())
|
||||
}
|
||||
|
||||
pub fn find_prompt(
|
||||
&self,
|
||||
server_id: Option<&ContextServerId>,
|
||||
name: &str,
|
||||
) -> Option<&ContextServerPrompt> {
|
||||
if let Some(server_id) = server_id {
|
||||
self.registered_servers
|
||||
.get(server_id)
|
||||
.and_then(|server| server.prompts.get(name))
|
||||
} else {
|
||||
self.registered_servers
|
||||
.values()
|
||||
.find_map(|server| server.prompts.get(name))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_store(&self) -> &Entity<ContextServerStore> {
|
||||
&self.server_store
|
||||
}
|
||||
|
||||
fn get_or_register_server(
|
||||
&mut self,
|
||||
server_id: &ContextServerId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> &mut RegisteredContextServer {
|
||||
self.registered_servers
|
||||
.entry(server_id.clone())
|
||||
.or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx))
|
||||
}
|
||||
|
||||
fn init_registered_server(
|
||||
server_id: &ContextServerId,
|
||||
server_store: &Entity<ContextServerStore>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> RegisteredContextServer {
|
||||
let tools_updated_subscription = server_store
|
||||
.read(cx)
|
||||
.get_running_server(server_id)
|
||||
.and_then(|server| {
|
||||
let client = server.client()?;
|
||||
|
||||
if !client.capable(context_server::protocol::ServerCapability::Tools) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let server_id = server.id();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Some(client.on_notification(
|
||||
"notifications/tools/list_changed",
|
||||
Box::new(move |_params, cx: AsyncApp| {
|
||||
let server_id = server_id.clone();
|
||||
let this = this.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
log::info!(
|
||||
"Received tools/list_changed notification for server {}",
|
||||
server_id
|
||||
);
|
||||
this.reload_tools_for_server(server_id, cx);
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
}),
|
||||
))
|
||||
});
|
||||
|
||||
RegisteredContextServer {
|
||||
tools: BTreeMap::default(),
|
||||
prompts: BTreeMap::default(),
|
||||
load_tools: Task::ready(Ok(())),
|
||||
load_prompts: Task::ready(Ok(())),
|
||||
_tools_updated_subscription: tools_updated_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
|
||||
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
|
||||
return;
|
||||
@@ -63,17 +161,12 @@ impl ContextServerRegistry {
|
||||
let Some(client) = server.client() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !client.capable(context_server::protocol::ServerCapability::Tools) {
|
||||
return;
|
||||
}
|
||||
|
||||
let registered_server =
|
||||
self.registered_servers
|
||||
.entry(server_id.clone())
|
||||
.or_insert(RegisteredContextServer {
|
||||
tools: BTreeMap::default(),
|
||||
load_tools: Task::ready(Ok(())),
|
||||
});
|
||||
let registered_server = self.get_or_register_server(&server_id, cx);
|
||||
registered_server.load_tools = cx.spawn(async move |this, cx| {
|
||||
let response = client
|
||||
.request::<context_server::types::requests::ListTools>(())
|
||||
@@ -94,6 +187,49 @@ impl ContextServerRegistry {
|
||||
));
|
||||
registered_server.tools.insert(tool.name(), tool);
|
||||
}
|
||||
cx.emit(ContextServerRegistryEvent::ToolsChanged);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
|
||||
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(client) = server.client() else {
|
||||
return;
|
||||
};
|
||||
if !client.capable(context_server::protocol::ServerCapability::Prompts) {
|
||||
return;
|
||||
}
|
||||
|
||||
let registered_server = self.get_or_register_server(&server_id, cx);
|
||||
|
||||
registered_server.load_prompts = cx.spawn(async move |this, cx| {
|
||||
let response = client
|
||||
.request::<context_server::types::requests::PromptsList>(())
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
registered_server.prompts.clear();
|
||||
if let Some(response) = response.log_err() {
|
||||
for prompt in response.prompts {
|
||||
let name: SharedString = prompt.name.clone().into();
|
||||
registered_server.prompts.insert(
|
||||
name,
|
||||
ContextServerPrompt {
|
||||
server_id: server_id.clone(),
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
}
|
||||
cx.emit(ContextServerRegistryEvent::PromptsChanged);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
@@ -112,9 +248,17 @@ impl ContextServerRegistry {
|
||||
ContextServerStatus::Starting => {}
|
||||
ContextServerStatus::Running => {
|
||||
self.reload_tools_for_server(server_id.clone(), cx);
|
||||
self.reload_prompts_for_server(server_id.clone(), cx);
|
||||
}
|
||||
ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
|
||||
self.registered_servers.remove(server_id);
|
||||
if let Some(registered_server) = self.registered_servers.remove(server_id) {
|
||||
if !registered_server.tools.is_empty() {
|
||||
cx.emit(ContextServerRegistryEvent::ToolsChanged);
|
||||
}
|
||||
if !registered_server.prompts.is_empty() {
|
||||
cx.emit(ContextServerRegistryEvent::PromptsChanged);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_prompt(
|
||||
server_store: &Entity<ContextServerStore>,
|
||||
server_id: &ContextServerId,
|
||||
prompt_name: &str,
|
||||
arguments: HashMap<String, String>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<context_server::types::PromptsGetResponse>> {
|
||||
let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) {
|
||||
Ok(server) => server,
|
||||
Err(error) => return Task::ready(Err(error)),
|
||||
};
|
||||
let Some(server) = server else {
|
||||
return Task::ready(Err(anyhow::anyhow!("Context server not found")));
|
||||
};
|
||||
|
||||
let Some(protocol) = server.client() else {
|
||||
return Task::ready(Err(anyhow::anyhow!("Context server not initialized")));
|
||||
};
|
||||
|
||||
let prompt_name = prompt_name.to_string();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let response = protocol
|
||||
.request::<context_server::types::requests::PromptsGet>(
|
||||
context_server::types::PromptsGetParams {
|
||||
name: prompt_name,
|
||||
arguments: (!arguments.is_empty()).then(|| arguments),
|
||||
meta: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -306,20 +306,39 @@ impl AgentTool for EditFileTool {
|
||||
|
||||
// Check if the file has been modified since the agent last read it
|
||||
if let Some(abs_path) = abs_path.as_ref() {
|
||||
let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| {
|
||||
let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
|
||||
let last_read = thread.file_read_times.get(abs_path).copied();
|
||||
let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
|
||||
let dirty = buffer.read(cx).is_dirty();
|
||||
(last_read, current, dirty)
|
||||
let has_save = thread.has_tool("save_file");
|
||||
let has_restore = thread.has_tool("restore_file_from_disk");
|
||||
(last_read, current, dirty, has_save, has_restore)
|
||||
})?;
|
||||
|
||||
// Check for unsaved changes first - these indicate modifications we don't know about
|
||||
if is_dirty {
|
||||
anyhow::bail!(
|
||||
"This file cannot be written to because it has unsaved changes. \
|
||||
Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \
|
||||
Ask the user to save that buffer's changes and to inform you when it's ok to proceed."
|
||||
);
|
||||
let message = match (has_save_tool, has_restore_tool) {
|
||||
(true, true) => {
|
||||
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
|
||||
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
|
||||
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
|
||||
}
|
||||
(true, false) => {
|
||||
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
|
||||
If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
|
||||
If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
|
||||
}
|
||||
(false, true) => {
|
||||
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
|
||||
If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
|
||||
If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
|
||||
}
|
||||
(false, false) => {
|
||||
"This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
|
||||
then ask them to save or revert the file manually and inform you when it's ok to proceed."
|
||||
}
|
||||
};
|
||||
anyhow::bail!("{}", message);
|
||||
}
|
||||
|
||||
// Check if the file was modified on disk since we last read it
|
||||
@@ -2202,9 +2221,21 @@ mod tests {
|
||||
assert!(result.is_err(), "Edit should fail when buffer is dirty");
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("cannot be written to because it has unsaved changes"),
|
||||
error_msg.contains("This file has unsaved changes."),
|
||||
"Error should mention unsaved changes, got: {}",
|
||||
error_msg
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains("keep or discard"),
|
||||
"Error should ask whether to keep or discard changes, got: {}",
|
||||
error_msg
|
||||
);
|
||||
// Since save_file and restore_file_from_disk tools aren't added to the thread,
|
||||
// the error message should ask the user to manually save or revert
|
||||
assert!(
|
||||
error_msg.contains("save or revert the file manually"),
|
||||
"Error should ask user to manually save or revert when tools aren't available, got: {}",
|
||||
error_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
352
crates/agent/src/tools/restore_file_from_disk_tool.rs
Normal file
352
crates/agent/src/tools/restore_file_from_disk_tool.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use collections::FxHashSet;
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use language::Buffer;
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
|
||||
/// 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;
|
||||
|
||||
fn 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: Self::Input,
|
||||
_event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let project = self.project.clone();
|
||||
let input_paths = input.paths;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
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 mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
|
||||
let mut reload_errors: Vec<String> = Vec::new();
|
||||
|
||||
for path in input_paths {
|
||||
let project_path =
|
||||
project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
|
||||
|
||||
let project_path = match project_path {
|
||||
Ok(Some(project_path)) => project_path,
|
||||
Ok(None) => {
|
||||
not_found_paths.push(path);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let open_buffer_task =
|
||||
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
|
||||
|
||||
let buffer = match open_buffer_task {
|
||||
Ok(task) => match task.await {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
|
||||
Ok(is_dirty) => is_dirty,
|
||||
Err(error) => {
|
||||
dirty_check_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
});
|
||||
|
||||
match reload_task {
|
||||
Ok(task) => {
|
||||
if let Err(error) = task.await {
|
||||
reload_errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[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(
|
||||
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(
|
||||
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(
|
||||
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
|
||||
}
|
||||
}
|
||||
351
crates/agent/src/tools/save_file_tool.rs
Normal file
351
crates/agent/src/tools/save_file_tool.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use collections::FxHashSet;
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use language::Buffer;
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
|
||||
/// 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;
|
||||
|
||||
fn 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: Self::Input,
|
||||
_event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let project = self.project.clone();
|
||||
let input_paths = input.paths;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
|
||||
|
||||
let mut saved_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 mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
|
||||
let mut save_errors: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for path in input_paths {
|
||||
let project_path =
|
||||
project.read_with(cx, |project, cx| project.find_project_path(&path, cx));
|
||||
|
||||
let project_path = match project_path {
|
||||
Ok(Some(project_path)) => project_path,
|
||||
Ok(None) => {
|
||||
not_found_paths.push(path);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let open_buffer_task =
|
||||
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
|
||||
|
||||
let buffer = match open_buffer_task {
|
||||
Ok(task) => match task.await {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
open_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) {
|
||||
Ok(is_dirty) => is_dirty,
|
||||
Err(error) => {
|
||||
dirty_check_errors.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if is_dirty {
|
||||
buffers_to_save.insert(buffer);
|
||||
saved_paths.push(path);
|
||||
} 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 = match 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())
|
||||
}) {
|
||||
Ok(path) => path.unwrap_or_else(|| "<unknown>".to_string()),
|
||||
Err(error) => {
|
||||
save_errors.push(("<unknown>".to_string(), error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
|
||||
|
||||
match save_task {
|
||||
Ok(task) => {
|
||||
if let Err(error) = task.await {
|
||||
save_errors.push((path_for_buffer, error.to_string()));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
save_errors.push((path_for_buffer, error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
if !saved_paths.is_empty() {
|
||||
lines.push(format!("Saved {} file(s).", saved_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 !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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[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(
|
||||
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(
|
||||
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(
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
path = "src/agent_settings.rs"
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol.workspace = true
|
||||
anyhow.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
collections.workspace = true
|
||||
|
||||
@@ -2,7 +2,8 @@ mod agent_profile;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use collections::IndexMap;
|
||||
use agent_client_protocol::ModelId;
|
||||
use collections::{HashSet, IndexMap};
|
||||
use gpui::{App, Pixels, px};
|
||||
use language_model::LanguageModel;
|
||||
use project::DisableAiSettings;
|
||||
@@ -33,6 +34,7 @@ pub struct AgentSettings {
|
||||
pub commit_message_model: Option<LanguageModelSelection>,
|
||||
pub thread_summary_model: Option<LanguageModelSelection>,
|
||||
pub inline_alternatives: Vec<LanguageModelSelection>,
|
||||
pub favorite_models: Vec<LanguageModelSelection>,
|
||||
pub default_profile: AgentProfileId,
|
||||
pub default_view: DefaultAgentView,
|
||||
pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
|
||||
@@ -96,6 +98,13 @@ impl AgentSettings {
|
||||
pub fn set_message_editor_max_lines(&self) -> usize {
|
||||
self.message_editor_min_lines * 2
|
||||
}
|
||||
|
||||
pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
|
||||
self.favorite_models
|
||||
.iter()
|
||||
.map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
|
||||
@@ -164,6 +173,7 @@ impl Settings for AgentSettings {
|
||||
commit_message_model: agent.commit_message_model,
|
||||
thread_summary_model: agent.thread_summary_model,
|
||||
inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
|
||||
favorite_models: agent.favorite_models,
|
||||
default_profile: AgentProfileId(agent.default_profile.unwrap()),
|
||||
default_view: agent.default_view.unwrap(),
|
||||
profiles: agent
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
use std::{cmp::Reverse, rc::Rc, sync::Arc};
|
||||
|
||||
use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
|
||||
use agent_client_protocol::ModelId;
|
||||
use agent_servers::AgentServer;
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::Result;
|
||||
use collections::IndexMap;
|
||||
use collections::{HashSet, IndexMap};
|
||||
use fs::Fs;
|
||||
use futures::FutureExt;
|
||||
use fuzzy::{StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, prelude::*};
|
||||
use settings::Settings;
|
||||
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
|
||||
use util::ResultExt;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
@@ -38,7 +42,7 @@ pub fn acp_model_selector(
|
||||
|
||||
enum AcpModelPickerEntry {
|
||||
Separator(SharedString),
|
||||
Model(AgentModelInfo),
|
||||
Model(AgentModelInfo, bool),
|
||||
}
|
||||
|
||||
pub struct AcpModelPickerDelegate {
|
||||
@@ -115,6 +119,67 @@ impl AcpModelPickerDelegate {
|
||||
pub fn active_model(&self) -> Option<&AgentModelInfo> {
|
||||
self.selected_model.as_ref()
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if !self.selector.supports_favorites() {
|
||||
return;
|
||||
}
|
||||
|
||||
let favorites = AgentSettings::get_global(cx).favorite_model_ids();
|
||||
|
||||
if favorites.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(models) = self.models.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let all_models: Vec<AgentModelInfo> = match models {
|
||||
AgentModelList::Flat(list) => list,
|
||||
AgentModelList::Grouped(index_map) => index_map
|
||||
.into_values()
|
||||
.flatten()
|
||||
.collect::<Vec<AgentModelInfo>>(),
|
||||
};
|
||||
|
||||
let favorite_models = all_models
|
||||
.iter()
|
||||
.filter(|model| favorites.contains(&model.id))
|
||||
.unique_by(|model| &model.id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
|
||||
|
||||
let current_index_in_favorites = current_id
|
||||
.as_ref()
|
||||
.and_then(|id| favorite_models.iter().position(|m| &m.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let next_index = if current_index_in_favorites == usize::MAX {
|
||||
0
|
||||
} else {
|
||||
(current_index_in_favorites + 1) % favorite_models.len()
|
||||
};
|
||||
|
||||
let next_model = favorite_models[next_index].clone();
|
||||
|
||||
self.selector
|
||||
.select_model(next_model.id.clone(), cx)
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
self.selected_model = Some(next_model);
|
||||
|
||||
// Keep the picker selection aligned with the newly-selected model
|
||||
if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
|
||||
matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
|
||||
}) {
|
||||
self.set_selected_index(new_index, window, cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for AcpModelPickerDelegate {
|
||||
@@ -140,7 +205,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> bool {
|
||||
match self.filtered_entries.get(ix) {
|
||||
Some(AcpModelPickerEntry::Model(_)) => true,
|
||||
Some(AcpModelPickerEntry::Model(_, _)) => true,
|
||||
Some(AcpModelPickerEntry::Separator(_)) | None => false,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +220,12 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let favorites = if self.selector.supports_favorites() {
|
||||
Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let filtered_models = match this
|
||||
.read_with(cx, |this, cx| {
|
||||
@@ -171,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries =
|
||||
info_list_to_picker_entries(filtered_models).collect();
|
||||
info_list_to_picker_entries(filtered_models, favorites);
|
||||
// Finds the currently selected model in the list
|
||||
let new_index = this
|
||||
.delegate
|
||||
@@ -179,7 +250,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
.as_ref()
|
||||
.and_then(|selected| {
|
||||
this.delegate.filtered_entries.iter().position(|entry| {
|
||||
if let AcpModelPickerEntry::Model(model_info) = entry {
|
||||
if let AcpModelPickerEntry::Model(model_info, _) = entry {
|
||||
model_info.id == selected.id
|
||||
} else {
|
||||
false
|
||||
@@ -195,7 +266,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if let Some(AcpModelPickerEntry::Model(model_info)) =
|
||||
if let Some(AcpModelPickerEntry::Model(model_info, _)) =
|
||||
self.filtered_entries.get(self.selected_index)
|
||||
{
|
||||
if window.modifiers().secondary() {
|
||||
@@ -233,7 +304,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
is_focused: bool,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
@@ -241,32 +312,53 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
AcpModelPickerEntry::Separator(title) => {
|
||||
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
|
||||
}
|
||||
AcpModelPickerEntry::Model(model_info) => {
|
||||
AcpModelPickerEntry::Model(model_info, is_favorite) => {
|
||||
let is_selected = Some(model_info) == self.selected_model.as_ref();
|
||||
let default_model = self.agent_server.default_model(cx);
|
||||
let is_default = default_model.as_ref() == Some(&model_info.id);
|
||||
|
||||
let supports_favorites = self.selector.supports_favorites();
|
||||
|
||||
let is_favorite = *is_favorite;
|
||||
let handle_action_click = {
|
||||
let model_id = model_info.id.clone();
|
||||
let fs = self.fs.clone();
|
||||
|
||||
move |cx: &App| {
|
||||
crate::favorite_models::toggle_model_id_in_settings(
|
||||
model_id.clone(),
|
||||
!is_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Some(
|
||||
div()
|
||||
.id(("model-picker-menu-child", ix))
|
||||
.when_some(model_info.description.clone(), |this, description| {
|
||||
this
|
||||
.on_hover(cx.listener(move |menu, hovered, _, cx| {
|
||||
if *hovered {
|
||||
menu.delegate.selected_description = Some((ix, description.clone(), is_default));
|
||||
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
|
||||
menu.delegate.selected_description = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
this.on_hover(cx.listener(move |menu, hovered, _, cx| {
|
||||
if *hovered {
|
||||
menu.delegate.selected_description =
|
||||
Some((ix, description.clone(), is_default));
|
||||
} else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
|
||||
menu.delegate.selected_description = None;
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ModelSelectorListItem::new(ix, model_info.name.clone())
|
||||
.is_focused(is_focused)
|
||||
.when_some(model_info.icon, |this, icon| this.icon(icon))
|
||||
.is_selected(is_selected)
|
||||
.when_some(model_info.icon, |this, icon| this.icon(icon)),
|
||||
.is_focused(selected)
|
||||
.when(supports_favorites, |this| {
|
||||
this.is_favorite(is_favorite)
|
||||
.on_toggle_favorite(handle_action_click)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -314,18 +406,51 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
fn info_list_to_picker_entries(
|
||||
model_list: AgentModelList,
|
||||
) -> impl Iterator<Item = AcpModelPickerEntry> {
|
||||
match model_list {
|
||||
AgentModelList::Flat(list) => {
|
||||
itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model))
|
||||
}
|
||||
AgentModelList::Grouped(index_map) => {
|
||||
itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| {
|
||||
std::iter::once(AcpModelPickerEntry::Separator(group_name.0))
|
||||
.chain(models.into_iter().map(AcpModelPickerEntry::Model))
|
||||
}))
|
||||
favorites: Arc<HashSet<ModelId>>,
|
||||
) -> Vec<AcpModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
let all_models: Vec<_> = match &model_list {
|
||||
AgentModelList::Flat(list) => list.iter().collect(),
|
||||
AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
|
||||
};
|
||||
|
||||
let favorite_models: Vec<_> = all_models
|
||||
.iter()
|
||||
.filter(|m| favorites.contains(&m.id))
|
||||
.unique_by(|m| &m.id)
|
||||
.collect();
|
||||
|
||||
let has_favorites = !favorite_models.is_empty();
|
||||
if has_favorites {
|
||||
entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
|
||||
for model in favorite_models {
|
||||
entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
|
||||
}
|
||||
}
|
||||
|
||||
match model_list {
|
||||
AgentModelList::Flat(list) => {
|
||||
if has_favorites {
|
||||
entries.push(AcpModelPickerEntry::Separator("All".into()));
|
||||
}
|
||||
for model in list {
|
||||
let is_favorite = favorites.contains(&model.id);
|
||||
entries.push(AcpModelPickerEntry::Model(model, is_favorite));
|
||||
}
|
||||
}
|
||||
AgentModelList::Grouped(index_map) => {
|
||||
for (group_name, models) in index_map {
|
||||
entries.push(AcpModelPickerEntry::Separator(group_name.0));
|
||||
for model in models {
|
||||
let is_favorite = favorites.contains(&model.id);
|
||||
entries.push(AcpModelPickerEntry::Model(model, is_favorite));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn fuzzy_search(
|
||||
@@ -447,6 +572,170 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
|
||||
Arc::new(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| ModelId::new(m.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
|
||||
AcpModelPickerEntry::Separator(s) => &s,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
|
||||
let favorites = create_favorites(vec![]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
|
||||
));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
|
||||
for entry in &entries {
|
||||
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
|
||||
if info.id.0.as_ref() == "zed/claude" {
|
||||
assert!(is_favorite, "zed/claude should be a favorite");
|
||||
} else {
|
||||
assert!(!is_favorite, "{} should not be a favorite", info.id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("zed", vec!["zed/claude", "zed/gemini"]),
|
||||
("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
assert_eq!(model_ids[1], "openai/gpt-5");
|
||||
|
||||
assert!(model_ids[2..].contains(&"zed/gemini"));
|
||||
assert!(model_ids[2..].contains(&"openai/gpt-5"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
("Recommended", vec!["zed/claude", "anthropic/claude"]),
|
||||
("Zed", vec!["zed/claude", "zed/gpt-5"]),
|
||||
("Antropic", vec!["anthropic/claude"]),
|
||||
("OpenAI", vec!["openai/gpt-5"]),
|
||||
]);
|
||||
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let labels = get_entry_labels(&entries);
|
||||
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"Favorite",
|
||||
"zed/claude",
|
||||
"Recommended",
|
||||
"zed/claude",
|
||||
"anthropic/claude",
|
||||
"Zed",
|
||||
"zed/claude",
|
||||
"zed/gpt-5",
|
||||
"Antropic",
|
||||
"anthropic/claude",
|
||||
"OpenAI",
|
||||
"openai/gpt-5"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
|
||||
let models = AgentModelList::Flat(vec![
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("zed/claude".to_string()),
|
||||
name: "Claude".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
},
|
||||
acp_thread::AgentModelInfo {
|
||||
id: acp::ModelId::new("zed/gemini".to_string()),
|
||||
name: "Gemini".into(),
|
||||
description: None,
|
||||
icon: None,
|
||||
},
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
assert!(entries.iter().any(|e| matches!(
|
||||
e,
|
||||
AcpModelPickerEntry::Separator(s) if s == "All"
|
||||
)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fuzzy_match(cx: &mut TestAppContext) {
|
||||
let models = create_model_list(vec![
|
||||
|
||||
@@ -3,15 +3,15 @@ use std::sync::Arc;
|
||||
|
||||
use acp_thread::{AgentModelInfo, AgentModelSelector};
|
||||
use agent_servers::AgentServer;
|
||||
use agent_settings::AgentSettings;
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use ui::{
|
||||
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
|
||||
prelude::*,
|
||||
};
|
||||
use settings::Settings as _;
|
||||
use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
use crate::CycleFavoriteModels;
|
||||
use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
|
||||
|
||||
pub struct AcpModelSelectorPopover {
|
||||
@@ -54,6 +54,12 @@ impl AcpModelSelectorPopover {
|
||||
pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
|
||||
self.selector.read(cx).delegate.active_model()
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selector.update(cx, |selector, cx| {
|
||||
selector.delegate.cycle_favorite_models(window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AcpModelSelectorPopover {
|
||||
@@ -74,6 +80,46 @@ impl Render for AcpModelSelectorPopover {
|
||||
(Color::Muted, IconName::ChevronDown)
|
||||
};
|
||||
|
||||
let tooltip = Tooltip::element({
|
||||
move |_, cx| {
|
||||
let focus_handle = focus_handle.clone();
|
||||
let should_show_cycle_row = !AgentSettings::get_global(cx)
|
||||
.favorite_model_ids()
|
||||
.is_empty();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("Change Model"))
|
||||
.child(KeyBinding::for_action_in(
|
||||
&ToggleModelSelector,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.when(should_show_cycle_row, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_between()
|
||||
.child(Label::new("Cycle Favorited Models"))
|
||||
.child(KeyBinding::for_action_in(
|
||||
&CycleFavoriteModels,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
}
|
||||
});
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
@@ -88,9 +134,7 @@ impl Render for AcpModelSelectorPopover {
|
||||
.ml_0p5(),
|
||||
)
|
||||
.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
|
||||
},
|
||||
tooltip,
|
||||
gpui::Corner::BottomRight,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -66,8 +66,8 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
|
||||
use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
|
||||
use crate::{
|
||||
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
|
||||
CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory,
|
||||
RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
|
||||
CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
|
||||
OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
@@ -389,6 +389,17 @@ impl AcpThreadView {
|
||||
),
|
||||
];
|
||||
|
||||
cx.on_release(|this, cx| {
|
||||
for window in this.notifications.drain(..) {
|
||||
window
|
||||
.update(cx, |_, window, _| {
|
||||
window.remove_window();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let show_codex_windows_warning = cfg!(windows)
|
||||
&& project.read(cx).is_local()
|
||||
&& agent.clone().downcast::<agent_servers::Codex>().is_some();
|
||||
@@ -1315,7 +1326,7 @@ impl AcpThreadView {
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn open_edited_buffer(
|
||||
@@ -1940,6 +1951,16 @@ impl AcpThreadView {
|
||||
window: &mut Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let is_indented = entry.is_indented();
|
||||
let is_first_indented = is_indented
|
||||
&& self.thread().is_some_and(|thread| {
|
||||
thread
|
||||
.read(cx)
|
||||
.entries()
|
||||
.get(entry_ix.saturating_sub(1))
|
||||
.is_none_or(|entry| !entry.is_indented())
|
||||
});
|
||||
|
||||
let primary = match &entry {
|
||||
AgentThreadEntry::UserMessage(message) => {
|
||||
let Some(editor) = self
|
||||
@@ -1972,7 +1993,9 @@ impl AcpThreadView {
|
||||
v_flex()
|
||||
.id(("user_message", entry_ix))
|
||||
.map(|this| {
|
||||
if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
|
||||
if is_first_indented {
|
||||
this.pt_0p5()
|
||||
} else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
|
||||
this.pt(rems_from_px(18.))
|
||||
} else if rules_item.is_some() {
|
||||
this.pt_3()
|
||||
@@ -2018,6 +2041,9 @@ impl AcpThreadView {
|
||||
.shadow_md()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_1()
|
||||
.when(is_indented, |this| {
|
||||
this.py_2().px_2().shadow_sm()
|
||||
})
|
||||
.when(editing && !editor_focus, |this| this.border_dashed())
|
||||
.border_color(cx.theme().colors().border)
|
||||
.map(|this|{
|
||||
@@ -2112,7 +2138,10 @@ impl AcpThreadView {
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
|
||||
AgentThreadEntry::AssistantMessage(AssistantMessage {
|
||||
chunks,
|
||||
indented: _,
|
||||
}) => {
|
||||
let is_last = entry_ix + 1 == total_entries;
|
||||
|
||||
let style = default_markdown_style(false, false, window, cx);
|
||||
@@ -2146,6 +2175,7 @@ impl AcpThreadView {
|
||||
v_flex()
|
||||
.px_5()
|
||||
.py_1p5()
|
||||
.when(is_first_indented, |this| this.pt_0p5())
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
@@ -2155,19 +2185,48 @@ impl AcpThreadView {
|
||||
AgentThreadEntry::ToolCall(tool_call) => {
|
||||
let has_terminals = tool_call.terminals().next().is_some();
|
||||
|
||||
div().w_full().map(|this| {
|
||||
if has_terminals {
|
||||
this.children(tool_call.terminals().map(|terminal| {
|
||||
self.render_terminal_tool_call(
|
||||
entry_ix, terminal, tool_call, window, cx,
|
||||
)
|
||||
}))
|
||||
} else {
|
||||
this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
|
||||
}
|
||||
})
|
||||
div()
|
||||
.w_full()
|
||||
.map(|this| {
|
||||
if has_terminals {
|
||||
this.children(tool_call.terminals().map(|terminal| {
|
||||
self.render_terminal_tool_call(
|
||||
entry_ix, terminal, tool_call, window, cx,
|
||||
)
|
||||
}))
|
||||
} else {
|
||||
this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
|
||||
}
|
||||
})
|
||||
.into_any()
|
||||
}
|
||||
.into_any(),
|
||||
};
|
||||
|
||||
let primary = if is_indented {
|
||||
let line_top = if is_first_indented {
|
||||
rems_from_px(-12.0)
|
||||
} else {
|
||||
rems_from_px(0.0)
|
||||
};
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.pl(rems_from_px(20.0))
|
||||
.bg(cx.theme().colors().panel_background.opacity(0.2))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left(rems_from_px(18.0))
|
||||
.top(line_top)
|
||||
.bottom_0()
|
||||
.w_px()
|
||||
.bg(cx.theme().colors().border.opacity(0.6)),
|
||||
)
|
||||
.child(primary)
|
||||
.into_any_element()
|
||||
} else {
|
||||
primary
|
||||
};
|
||||
|
||||
let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
|
||||
@@ -4234,6 +4293,13 @@ impl AcpThreadView {
|
||||
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
|
||||
if let Some(model_selector) = this.model_selector.as_ref() {
|
||||
model_selector.update(cx, |model_selector, cx| {
|
||||
model_selector.cycle_favorite_models(window, cx);
|
||||
});
|
||||
}
|
||||
}))
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
@@ -4994,8 +5060,8 @@ impl AcpThreadView {
|
||||
});
|
||||
|
||||
if let Some(screen_window) = cx
|
||||
.open_window(options, |_, cx| {
|
||||
cx.new(|_| {
|
||||
.open_window(options, |_window, cx| {
|
||||
cx.new(|_cx| {
|
||||
AgentNotification::new(title.clone(), caption.clone(), icon, project_name)
|
||||
})
|
||||
})
|
||||
@@ -6421,6 +6487,57 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
|
||||
|
||||
let weak_view = thread_view.downgrade();
|
||||
|
||||
let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
|
||||
message_editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Hello", window, cx);
|
||||
});
|
||||
|
||||
cx.deactivate_window();
|
||||
|
||||
thread_view.update_in(cx, |thread_view, window, cx| {
|
||||
thread_view.send(window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify notification is shown
|
||||
assert!(
|
||||
cx.windows()
|
||||
.iter()
|
||||
.any(|window| window.downcast::<AgentNotification>().is_some()),
|
||||
"Expected notification to be shown"
|
||||
);
|
||||
|
||||
// Drop the thread view (simulating navigation to a new thread)
|
||||
drop(thread_view);
|
||||
drop(message_editor);
|
||||
// Trigger an update to flush effects, which will call release_dropped_entities
|
||||
cx.update(|_window, _cx| {});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify the entity was actually released
|
||||
assert!(
|
||||
!weak_view.is_upgradable(),
|
||||
"Thread view entity should be released after dropping"
|
||||
);
|
||||
|
||||
// The notification should be automatically closed via on_release
|
||||
assert!(
|
||||
!cx.windows()
|
||||
.iter()
|
||||
.any(|window| window.downcast::<AgentNotification>().is_some()),
|
||||
"Notification should be closed when thread view is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_thread_view(
|
||||
agent: impl AgentServer + 'static,
|
||||
cx: &mut TestAppContext,
|
||||
|
||||
@@ -222,7 +222,6 @@ impl ManageProfilesModal {
|
||||
let profile_id_for_closure = profile_id.clone();
|
||||
|
||||
let model_picker = cx.new(|cx| {
|
||||
let fs = fs.clone();
|
||||
let profile_id = profile_id_for_closure.clone();
|
||||
|
||||
language_model_selector(
|
||||
@@ -250,22 +249,36 @@ impl ManageProfilesModal {
|
||||
})
|
||||
}
|
||||
},
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
let profile_id = profile_id.clone();
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
let profile_id = profile_id.clone();
|
||||
|
||||
update_settings_file(fs.clone(), cx, move |settings, _cx| {
|
||||
let agent_settings = settings.agent.get_or_insert_default();
|
||||
if let Some(profiles) = agent_settings.profiles.as_mut() {
|
||||
if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
|
||||
profile.default_model = Some(LanguageModelSelection {
|
||||
provider: LanguageModelProviderSetting(provider.clone()),
|
||||
model: model_id.clone(),
|
||||
});
|
||||
update_settings_file(fs.clone(), cx, move |settings, _cx| {
|
||||
let agent_settings = settings.agent.get_or_insert_default();
|
||||
if let Some(profiles) = agent_settings.profiles.as_mut() {
|
||||
if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) {
|
||||
profile.default_model = Some(LanguageModelSelection {
|
||||
provider: LanguageModelProviderSetting(provider.clone()),
|
||||
model: model_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, should_be_favorite, cx| {
|
||||
crate::favorite_models::toggle_in_settings(
|
||||
model,
|
||||
should_be_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
false, // Do not use popover styles for the model picker
|
||||
self.focus_handle.clone(),
|
||||
|
||||
@@ -29,26 +29,39 @@ impl AgentModelSelector {
|
||||
|
||||
Self {
|
||||
selector: cx.new(move |cx| {
|
||||
let fs = fs.clone();
|
||||
language_model_selector(
|
||||
{
|
||||
let model_context = model_usage_context.clone();
|
||||
move |cx| model_context.configured_model(cx)
|
||||
},
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
match &model_usage_context {
|
||||
ModelUsageContext::InlineAssistant => {
|
||||
update_settings_file(fs.clone(), cx, move |settings, _cx| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_inline_assistant_model(provider.clone(), model_id);
|
||||
});
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, cx| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model_id = model.id().0.to_string();
|
||||
match &model_usage_context {
|
||||
ModelUsageContext::InlineAssistant => {
|
||||
update_settings_file(fs.clone(), cx, move |settings, _cx| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_inline_assistant_model(provider.clone(), model_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, should_be_favorite, cx| {
|
||||
crate::favorite_models::toggle_in_settings(
|
||||
model,
|
||||
should_be_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
true, // Use popover styles for picker
|
||||
focus_handle_clone,
|
||||
window,
|
||||
|
||||
@@ -2,10 +2,12 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
use acp_thread::AcpThread;
|
||||
use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
|
||||
use agent_servers::AgentServer;
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use project::{
|
||||
ExternalAgentServerName,
|
||||
agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
|
||||
trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, wait_for_workspace_trust},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{
|
||||
@@ -262,6 +264,17 @@ impl AgentType {
|
||||
Self::Custom { .. } => Some(IconName::Sparkle),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mcp(&self) -> bool {
|
||||
match self {
|
||||
Self::NativeAgent => false,
|
||||
Self::TextThread => false,
|
||||
Self::Custom { .. } => false,
|
||||
Self::Gemini => true,
|
||||
Self::ClaudeCode => true,
|
||||
Self::Codex => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalAgent> for AgentType {
|
||||
@@ -287,7 +300,7 @@ impl ActiveView {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_agent(
|
||||
fn native_agent(
|
||||
fs: Arc<dyn Fs>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
history_store: Entity<agent::HistoryStore>,
|
||||
@@ -442,6 +455,9 @@ pub struct AgentPanel {
|
||||
pending_serialization: Option<Task<Result<()>>>,
|
||||
onboarding: Entity<AgentPanelOnboarding>,
|
||||
selected_agent: AgentType,
|
||||
new_agent_thread_task: Task<()>,
|
||||
show_trust_workspace_message: bool,
|
||||
_worktree_trust_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl AgentPanel {
|
||||
@@ -665,6 +681,48 @@ impl AgentPanel {
|
||||
None
|
||||
};
|
||||
|
||||
let mut show_trust_workspace_message = false;
|
||||
let worktree_trust_subscription =
|
||||
TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
|
||||
let has_global_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust_workspace(
|
||||
project
|
||||
.read(cx)
|
||||
.remote_connection_options(cx)
|
||||
.map(RemoteHostLocation::from),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
if has_global_trust {
|
||||
None
|
||||
} else {
|
||||
show_trust_workspace_message = true;
|
||||
let project = project.clone();
|
||||
Some(cx.subscribe(
|
||||
&trusted_worktrees,
|
||||
move |agent_panel, trusted_worktrees, _, cx| {
|
||||
let new_show_trust_workspace_message =
|
||||
!trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust_workspace(
|
||||
project
|
||||
.read(cx)
|
||||
.remote_connection_options(cx)
|
||||
.map(RemoteHostLocation::from),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
if new_show_trust_workspace_message
|
||||
!= agent_panel.show_trust_workspace_message
|
||||
{
|
||||
agent_panel.show_trust_workspace_message =
|
||||
new_show_trust_workspace_message;
|
||||
cx.notify();
|
||||
};
|
||||
},
|
||||
))
|
||||
}
|
||||
});
|
||||
|
||||
let mut panel = Self {
|
||||
active_view,
|
||||
workspace,
|
||||
@@ -687,11 +745,14 @@ impl AgentPanel {
|
||||
height: None,
|
||||
zoomed: false,
|
||||
pending_serialization: None,
|
||||
new_agent_thread_task: Task::ready(()),
|
||||
onboarding,
|
||||
acp_history,
|
||||
history_store,
|
||||
selected_agent: AgentType::default(),
|
||||
loading: false,
|
||||
show_trust_workspace_message,
|
||||
_worktree_trust_subscription: worktree_trust_subscription,
|
||||
};
|
||||
|
||||
// Initial sync of agent servers from extensions
|
||||
@@ -884,37 +945,63 @@ impl AgentPanel {
|
||||
}
|
||||
};
|
||||
|
||||
let server = ext_agent.server(fs, history);
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
let selected_agent = ext_agent.into();
|
||||
if this.selected_agent != selected_agent {
|
||||
this.selected_agent = selected_agent;
|
||||
this.serialize(cx);
|
||||
if ext_agent.is_mcp() {
|
||||
let wait_task = this.update(cx, |agent_panel, cx| {
|
||||
agent_panel.project.update(cx, |project, cx| {
|
||||
wait_for_workspace_trust(
|
||||
project.remote_connection_options(cx),
|
||||
"context servers",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
if let Some(wait_task) = wait_task {
|
||||
this.update_in(cx, |agent_panel, window, cx| {
|
||||
agent_panel.show_trust_workspace_message = true;
|
||||
cx.notify();
|
||||
agent_panel.new_agent_thread_task =
|
||||
cx.spawn_in(window, async move |agent_panel, cx| {
|
||||
wait_task.await;
|
||||
let server = ext_agent.server(fs, history);
|
||||
agent_panel
|
||||
.update_in(cx, |agent_panel, window, cx| {
|
||||
agent_panel.show_trust_workspace_message = false;
|
||||
cx.notify();
|
||||
agent_panel._external_thread(
|
||||
server,
|
||||
resume_thread,
|
||||
summarize_thread,
|
||||
workspace,
|
||||
project,
|
||||
loading,
|
||||
ext_agent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let thread_view = cx.new(|cx| {
|
||||
crate::acp::AcpThreadView::new(
|
||||
server,
|
||||
resume_thread,
|
||||
summarize_thread,
|
||||
workspace.clone(),
|
||||
project,
|
||||
this.history_store.clone(),
|
||||
this.prompt_store.clone(),
|
||||
!loading,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
this.set_active_view(
|
||||
ActiveView::ExternalAgentThread { thread_view },
|
||||
!loading,
|
||||
let server = ext_agent.server(fs, history);
|
||||
this.update_in(cx, |agent_panel, window, cx| {
|
||||
agent_panel._external_thread(
|
||||
server,
|
||||
resume_thread,
|
||||
summarize_thread,
|
||||
workspace,
|
||||
project,
|
||||
loading,
|
||||
ext_agent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
@@ -1423,6 +1510,36 @@ impl AgentPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let wait_task = if agent.is_mcp() {
|
||||
self.project.update(cx, |project, cx| {
|
||||
wait_for_workspace_trust(
|
||||
project.remote_connection_options(cx),
|
||||
"context servers",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(wait_task) = wait_task {
|
||||
self.show_trust_workspace_message = true;
|
||||
cx.notify();
|
||||
self.new_agent_thread_task = cx.spawn_in(window, async move |agent_panel, cx| {
|
||||
wait_task.await;
|
||||
agent_panel
|
||||
.update_in(cx, |agent_panel, window, cx| {
|
||||
agent_panel.show_trust_workspace_message = false;
|
||||
cx.notify();
|
||||
agent_panel._new_agent_thread(agent, window, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
} else {
|
||||
self._new_agent_thread(agent, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn _new_agent_thread(&mut self, agent: AgentType, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match agent {
|
||||
AgentType::TextThread => {
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
@@ -1477,6 +1594,47 @@ impl AgentPanel {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn _external_thread(
|
||||
&mut self,
|
||||
server: Rc<dyn AgentServer>,
|
||||
resume_thread: Option<DbThreadMetadata>,
|
||||
summarize_thread: Option<DbThreadMetadata>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
loading: bool,
|
||||
ext_agent: ExternalAgent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let selected_agent = AgentType::from(ext_agent);
|
||||
if self.selected_agent != selected_agent {
|
||||
self.selected_agent = selected_agent;
|
||||
self.serialize(cx);
|
||||
}
|
||||
|
||||
let thread_view = cx.new(|cx| {
|
||||
crate::acp::AcpThreadView::new(
|
||||
server,
|
||||
resume_thread,
|
||||
summarize_thread,
|
||||
workspace.clone(),
|
||||
project,
|
||||
self.history_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
!loading,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
self.set_active_view(
|
||||
ActiveView::ExternalAgentThread { thread_view },
|
||||
!loading,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for AgentPanel {
|
||||
@@ -2557,6 +2715,38 @@ impl AgentPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_workspace_trust_message(&self, cx: &Context<Self>) -> Option<impl IntoElement> {
|
||||
if !self.show_trust_workspace_message {
|
||||
return None;
|
||||
}
|
||||
|
||||
let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
|
||||
|
||||
Some(
|
||||
Callout::new()
|
||||
.icon(IconName::Warning)
|
||||
.severity(Severity::Warning)
|
||||
.border_position(ui::BorderPosition::Bottom)
|
||||
.title("You're in Restricted Mode")
|
||||
.description(description)
|
||||
.actions_slot(
|
||||
Button::new("open-trust-modal", "Configure Project Trust")
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.show_worktree_trust_security_modal(true, window, cx)
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn key_context(&self) -> KeyContext {
|
||||
let mut key_context = KeyContext::new_with_defaults();
|
||||
key_context.add("AgentPanel");
|
||||
@@ -2609,6 +2799,7 @@ impl Render for AgentPanel {
|
||||
}
|
||||
}))
|
||||
.child(self.render_toolbar(window, cx))
|
||||
.children(self.render_workspace_trust_message(cx))
|
||||
.children(self.render_onboarding(window, cx))
|
||||
.map(|parent| match &self.active_view {
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => parent
|
||||
|
||||
@@ -7,6 +7,7 @@ mod buffer_codegen;
|
||||
mod completion_provider;
|
||||
mod context;
|
||||
mod context_server_configuration;
|
||||
mod favorite_models;
|
||||
mod inline_assistant;
|
||||
mod inline_prompt_editor;
|
||||
mod language_model_selector;
|
||||
@@ -67,6 +68,8 @@ actions!(
|
||||
ToggleProfileSelector,
|
||||
/// Cycles through available session modes.
|
||||
CycleModeSelector,
|
||||
/// Cycles through favorited models in the ACP model selector.
|
||||
CycleFavoriteModels,
|
||||
/// Expands the message editor to full size.
|
||||
ExpandMessageEditor,
|
||||
/// Removes all thread history.
|
||||
@@ -171,6 +174,16 @@ impl ExternalAgent {
|
||||
Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_mcp(&self) -> bool {
|
||||
match self {
|
||||
Self::Gemini => true,
|
||||
Self::ClaudeCode => true,
|
||||
Self::Codex => true,
|
||||
Self::NativeAgent => false,
|
||||
Self::Custom { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the profile management interface for configuring agent tools and settings.
|
||||
@@ -457,6 +470,7 @@ mod tests {
|
||||
commit_message_model: None,
|
||||
thread_summary_model: None,
|
||||
inline_alternatives: vec![],
|
||||
favorite_models: vec![],
|
||||
default_profile: AgentProfileId::default(),
|
||||
default_view: DefaultAgentView::Thread,
|
||||
profiles: Default::default(),
|
||||
|
||||
@@ -20,7 +20,7 @@ use project::{
|
||||
Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse,
|
||||
PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId,
|
||||
};
|
||||
use prompt_store::{PromptId, PromptStore, UserPromptId};
|
||||
use prompt_store::{PromptStore, UserPromptId};
|
||||
use rope::Point;
|
||||
use text::{Anchor, ToPoint as _};
|
||||
use ui::prelude::*;
|
||||
@@ -1585,13 +1585,10 @@ pub(crate) fn search_rules(
|
||||
if metadata.default {
|
||||
None
|
||||
} else {
|
||||
match metadata.id {
|
||||
PromptId::EditWorkflow => None,
|
||||
PromptId::User { uuid } => Some(RulesContextEntry {
|
||||
prompt_id: uuid,
|
||||
title: metadata.title?,
|
||||
}),
|
||||
}
|
||||
Some(RulesContextEntry {
|
||||
prompt_id: metadata.id.user_id()?,
|
||||
title: metadata.title?,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
57
crates/agent_ui/src/favorite_models.rs
Normal file
57
crates/agent_ui/src/favorite_models.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_client_protocol::ModelId;
|
||||
use fs::Fs;
|
||||
use language_model::LanguageModel;
|
||||
use settings::{LanguageModelSelection, update_settings_file};
|
||||
use ui::App;
|
||||
|
||||
fn language_model_to_selection(model: &Arc<dyn LanguageModel>) -> LanguageModelSelection {
|
||||
LanguageModelSelection {
|
||||
provider: model.provider_id().to_string().into(),
|
||||
model: model.id().0.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn model_id_to_selection(model_id: &ModelId) -> LanguageModelSelection {
|
||||
let id = model_id.0.as_ref();
|
||||
let (provider, model) = id.split_once('/').unwrap_or(("", id));
|
||||
LanguageModelSelection {
|
||||
provider: provider.to_owned().into(),
|
||||
model: model.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_in_settings(
|
||||
model: Arc<dyn LanguageModel>,
|
||||
should_be_favorite: bool,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &App,
|
||||
) {
|
||||
let selection = language_model_to_selection(&model);
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
let agent = settings.agent.get_or_insert_default();
|
||||
if should_be_favorite {
|
||||
agent.add_favorite_model(selection.clone());
|
||||
} else {
|
||||
agent.remove_favorite_model(&selection);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_model_id_in_settings(
|
||||
model_id: ModelId,
|
||||
should_be_favorite: bool,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &App,
|
||||
) {
|
||||
let selection = model_id_to_selection(&model_id);
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
let agent = settings.agent.get_or_insert_default();
|
||||
if should_be_favorite {
|
||||
agent.add_favorite_model(selection.clone());
|
||||
} else {
|
||||
agent.remove_favorite_model(&selection);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -844,26 +844,59 @@ impl<T: 'static> PromptEditor<T> {
|
||||
|
||||
if show_rating_buttons {
|
||||
buttons.push(
|
||||
IconButton::new("thumbs-down", IconName::ThumbsDown)
|
||||
.icon_color(if rated { Color::Muted } else { Color::Default })
|
||||
.shape(IconButtonShape::Square)
|
||||
.disabled(rated)
|
||||
.tooltip(Tooltip::text("Bad result"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.thumbs_down(&ThumbsDownResult, window, cx);
|
||||
}))
|
||||
.into_any_element(),
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
IconButton::new("thumbs-up", IconName::ThumbsUp)
|
||||
.icon_color(if rated { Color::Muted } else { Color::Default })
|
||||
.shape(IconButtonShape::Square)
|
||||
.disabled(rated)
|
||||
.tooltip(Tooltip::text("Good result"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.thumbs_up(&ThumbsUpResult, window, cx);
|
||||
}))
|
||||
h_flex()
|
||||
.pl_1()
|
||||
.gap_1()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
IconButton::new("thumbs-up", IconName::ThumbsUp)
|
||||
.shape(IconButtonShape::Square)
|
||||
.map(|this| {
|
||||
if rated {
|
||||
this.disabled(true)
|
||||
.icon_color(Color::Ignored)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Good Result",
|
||||
None,
|
||||
"You already rated this result",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
this.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Good Result"))
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.thumbs_up(&ThumbsUpResult, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("thumbs-down", IconName::ThumbsDown)
|
||||
.shape(IconButtonShape::Square)
|
||||
.map(|this| {
|
||||
if rated {
|
||||
this.disabled(true)
|
||||
.icon_color(Color::Ignored)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Bad Result",
|
||||
None,
|
||||
"You already rated this result",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
this.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Bad Result"))
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.thumbs_down(&ThumbsDownResult, window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
@@ -927,10 +960,21 @@ impl<T: 'static> PromptEditor<T> {
|
||||
}
|
||||
|
||||
fn render_close_button(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let focus_handle = self.editor.focus_handle(cx);
|
||||
|
||||
IconButton::new("cancel", IconName::Close)
|
||||
.icon_color(Color::Muted)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::text("Close Assistant"))
|
||||
.tooltip({
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Close Assistant",
|
||||
&editor::actions::Cancel,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use collections::IndexMap;
|
||||
use agent_settings::AgentSettings;
|
||||
use collections::{HashMap, HashSet, IndexMap};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
|
||||
};
|
||||
use language_model::{
|
||||
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
|
||||
LanguageModelRegistry,
|
||||
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelRegistry,
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use settings::Settings;
|
||||
use ui::prelude::*;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
@@ -18,12 +20,14 @@ use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
|
||||
type OnToggleFavorite = Arc<dyn Fn(Arc<dyn LanguageModel>, bool, &App) + 'static>;
|
||||
|
||||
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
|
||||
|
||||
pub fn language_model_selector(
|
||||
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
|
||||
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
|
||||
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -32,6 +36,7 @@ pub fn language_model_selector(
|
||||
let delegate = LanguageModelPickerDelegate::new(
|
||||
get_active_model,
|
||||
on_model_changed,
|
||||
on_toggle_favorite,
|
||||
popover_styles,
|
||||
focus_handle,
|
||||
window,
|
||||
@@ -49,7 +54,17 @@ pub fn language_model_selector(
|
||||
}
|
||||
|
||||
fn all_models(cx: &App) -> GroupedModels {
|
||||
let providers = LanguageModelRegistry::global(cx).read(cx).providers();
|
||||
let lm_registry = LanguageModelRegistry::global(cx).read(cx);
|
||||
let providers = lm_registry.providers();
|
||||
|
||||
let mut favorites_index = FavoritesIndex::default();
|
||||
|
||||
for sel in &AgentSettings::get_global(cx).favorite_models {
|
||||
favorites_index
|
||||
.entry(sel.provider.0.clone().into())
|
||||
.or_default()
|
||||
.insert(sel.model.clone().into());
|
||||
}
|
||||
|
||||
let recommended = providers
|
||||
.iter()
|
||||
@@ -57,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels {
|
||||
provider
|
||||
.recommended_models(cx)
|
||||
.into_iter()
|
||||
.map(|model| ModelInfo {
|
||||
model,
|
||||
icon: provider.icon(),
|
||||
})
|
||||
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -70,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels {
|
||||
provider
|
||||
.provided_models(cx)
|
||||
.into_iter()
|
||||
.map(|model| ModelInfo {
|
||||
model,
|
||||
icon: provider.icon(),
|
||||
})
|
||||
.map(|model| ModelInfo::new(&**provider, model, &favorites_index))
|
||||
})
|
||||
.collect();
|
||||
|
||||
GroupedModels::new(all, recommended)
|
||||
}
|
||||
|
||||
type FavoritesIndex = HashMap<LanguageModelProviderId, HashSet<LanguageModelId>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ModelInfo {
|
||||
model: Arc<dyn LanguageModel>,
|
||||
icon: IconName,
|
||||
is_favorite: bool,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
fn new(
|
||||
provider: &dyn LanguageModelProvider,
|
||||
model: Arc<dyn LanguageModel>,
|
||||
favorites_index: &FavoritesIndex,
|
||||
) -> Self {
|
||||
let is_favorite = favorites_index
|
||||
.get(&provider.id())
|
||||
.map_or(false, |set| set.contains(&model.id()));
|
||||
|
||||
Self {
|
||||
model,
|
||||
icon: provider.icon(),
|
||||
is_favorite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LanguageModelPickerDelegate {
|
||||
on_model_changed: OnModelChanged,
|
||||
get_active_model: GetActiveModel,
|
||||
on_toggle_favorite: OnToggleFavorite,
|
||||
all_models: Arc<GroupedModels>,
|
||||
filtered_entries: Vec<LanguageModelPickerEntry>,
|
||||
selected_index: usize,
|
||||
@@ -102,6 +133,7 @@ impl LanguageModelPickerDelegate {
|
||||
fn new(
|
||||
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
|
||||
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
|
||||
on_toggle_favorite: impl Fn(Arc<dyn LanguageModel>, bool, &App) + 'static,
|
||||
popover_styles: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -117,6 +149,7 @@ impl LanguageModelPickerDelegate {
|
||||
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
|
||||
filtered_entries: entries,
|
||||
get_active_model: Arc::new(get_active_model),
|
||||
on_toggle_favorite: Arc::new(on_toggle_favorite),
|
||||
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
|
||||
_subscriptions: vec![cx.subscribe_in(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
@@ -216,15 +249,57 @@ impl LanguageModelPickerDelegate {
|
||||
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
|
||||
(self.get_active_model)(cx)
|
||||
}
|
||||
|
||||
pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
if self.all_models.favorites.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_model = (self.get_active_model)(cx);
|
||||
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
|
||||
let active_model_id = active_model.as_ref().map(|m| m.model.id());
|
||||
|
||||
let current_index = self
|
||||
.all_models
|
||||
.favorites
|
||||
.iter()
|
||||
.position(|info| {
|
||||
Some(info.model.provider_id()) == active_provider_id
|
||||
&& Some(info.model.id()) == active_model_id
|
||||
})
|
||||
.unwrap_or(usize::MAX);
|
||||
|
||||
let next_index = if current_index == usize::MAX {
|
||||
0
|
||||
} else {
|
||||
(current_index + 1) % self.all_models.favorites.len()
|
||||
};
|
||||
|
||||
let next_model = self.all_models.favorites[next_index].model.clone();
|
||||
|
||||
(self.on_model_changed)(next_model, cx);
|
||||
|
||||
// Align the picker selection with the newly-active model
|
||||
let new_index =
|
||||
Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
|
||||
self.set_selected_index(new_index, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupedModels {
|
||||
favorites: Vec<ModelInfo>,
|
||||
recommended: Vec<ModelInfo>,
|
||||
all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
|
||||
}
|
||||
|
||||
impl GroupedModels {
|
||||
pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
|
||||
let favorites = all
|
||||
.iter()
|
||||
.filter(|info| info.is_favorite)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
|
||||
for model in all {
|
||||
let provider = model.model.provider_id();
|
||||
@@ -236,6 +311,7 @@ impl GroupedModels {
|
||||
}
|
||||
|
||||
Self {
|
||||
favorites,
|
||||
recommended,
|
||||
all: all_by_provider,
|
||||
}
|
||||
@@ -244,13 +320,18 @@ impl GroupedModels {
|
||||
fn entries(&self) -> Vec<LanguageModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if !self.favorites.is_empty() {
|
||||
entries.push(LanguageModelPickerEntry::Separator("Favorite".into()));
|
||||
for info in &self.favorites {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.recommended.is_empty() {
|
||||
entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
|
||||
entries.extend(
|
||||
self.recommended
|
||||
.iter()
|
||||
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
|
||||
);
|
||||
for info in &self.recommended {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
for models in self.all.values() {
|
||||
@@ -260,12 +341,11 @@ impl GroupedModels {
|
||||
entries.push(LanguageModelPickerEntry::Separator(
|
||||
models[0].model.provider_name().0,
|
||||
));
|
||||
entries.extend(
|
||||
models
|
||||
.iter()
|
||||
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
|
||||
);
|
||||
for info in models {
|
||||
entries.push(LanguageModelPickerEntry::Model(info.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -461,7 +541,7 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
is_focused: bool,
|
||||
selected: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
@@ -477,11 +557,20 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
|
||||
&& Some(model_info.model.id()) == active_model_id;
|
||||
|
||||
let is_favorite = model_info.is_favorite;
|
||||
let handle_action_click = {
|
||||
let model = model_info.model.clone();
|
||||
let on_toggle_favorite = self.on_toggle_favorite.clone();
|
||||
move |cx: &App| on_toggle_favorite(model.clone(), !is_favorite, cx)
|
||||
};
|
||||
|
||||
Some(
|
||||
ModelSelectorListItem::new(ix, model_info.model.name().0)
|
||||
.is_focused(is_focused)
|
||||
.is_selected(is_selected)
|
||||
.icon(model_info.icon)
|
||||
.is_selected(is_selected)
|
||||
.is_focused(selected)
|
||||
.is_favorite(is_favorite)
|
||||
.on_toggle_favorite(handle_action_click)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -493,12 +582,12 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
if !self.popover_styles {
|
||||
return None;
|
||||
}
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -598,11 +687,24 @@ mod tests {
|
||||
}
|
||||
|
||||
fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
|
||||
create_models_with_favorites(model_specs, vec![])
|
||||
}
|
||||
|
||||
fn create_models_with_favorites(
|
||||
model_specs: Vec<(&str, &str)>,
|
||||
favorites: Vec<(&str, &str)>,
|
||||
) -> Vec<ModelInfo> {
|
||||
model_specs
|
||||
.into_iter()
|
||||
.map(|(provider, name)| ModelInfo {
|
||||
model: Arc::new(TestLanguageModel::new(name, provider)),
|
||||
icon: IconName::Ai,
|
||||
.map(|(provider, name)| {
|
||||
let is_favorite = favorites
|
||||
.iter()
|
||||
.any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name);
|
||||
ModelInfo {
|
||||
model: Arc::new(TestLanguageModel::new(name, provider)),
|
||||
icon: IconName::Ai,
|
||||
is_favorite,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -740,4 +842,93 @@ mod tests {
|
||||
vec!["zed/claude", "zed/gemini", "copilot/claude"],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
|
||||
vec![("zed", "gemini")],
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite"
|
||||
));
|
||||
|
||||
assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended"
|
||||
));
|
||||
|
||||
assert!(grouped_models.favorites.is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
|
||||
let recommended_models =
|
||||
create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]);
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")],
|
||||
vec![("zed", "claude")],
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
let entries = grouped_models.entries();
|
||||
|
||||
for entry in &entries {
|
||||
if let LanguageModelPickerEntry::Model(info) = entry {
|
||||
if info.model.telemetry_id() == "zed/claude" {
|
||||
assert!(info.is_favorite, "zed/claude should be a favorite");
|
||||
} else {
|
||||
assert!(
|
||||
!info.is_favorite,
|
||||
"{} should not be a favorite",
|
||||
info.model.telemetry_id()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) {
|
||||
let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")];
|
||||
|
||||
let recommended_models =
|
||||
create_models_with_favorites(vec![("zed", "claude")], favorites.clone());
|
||||
|
||||
let all_models = create_models_with_favorites(
|
||||
vec![
|
||||
("zed", "claude"),
|
||||
("zed", "gemini"),
|
||||
("openai", "gpt-4"),
|
||||
("openai", "gpt-3.5"),
|
||||
],
|
||||
favorites,
|
||||
);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]);
|
||||
assert_models_eq(grouped_models.recommended, vec!["zed/claude"]);
|
||||
assert_models_eq(
|
||||
grouped_models.all.values().flatten().cloned().collect(),
|
||||
vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
language_model_selector::{LanguageModelSelector, language_model_selector},
|
||||
ui::BurnModeTooltip,
|
||||
};
|
||||
use agent_settings::CompletionMode;
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
use anyhow::Result;
|
||||
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
|
||||
use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
|
||||
@@ -73,6 +73,8 @@ use workspace::{
|
||||
};
|
||||
use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
|
||||
|
||||
use crate::CycleFavoriteModels;
|
||||
|
||||
use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
|
||||
use assistant_text_thread::{
|
||||
CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId,
|
||||
@@ -304,17 +306,31 @@ impl TextThreadEditor {
|
||||
language_model_selector: cx.new(|cx| {
|
||||
language_model_selector(
|
||||
|cx| LanguageModelRegistry::read_global(cx).default_model(),
|
||||
move |model, cx| {
|
||||
update_settings_file(fs.clone(), cx, move |settings, _| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model = model.id().0.to_string();
|
||||
settings.agent.get_or_insert_default().set_model(
|
||||
LanguageModelSelection {
|
||||
provider: LanguageModelProviderSetting(provider),
|
||||
model,
|
||||
},
|
||||
)
|
||||
});
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, cx| {
|
||||
update_settings_file(fs.clone(), cx, move |settings, _| {
|
||||
let provider = model.provider_id().0.to_string();
|
||||
let model = model.id().0.to_string();
|
||||
settings.agent.get_or_insert_default().set_model(
|
||||
LanguageModelSelection {
|
||||
provider: LanguageModelProviderSetting(provider),
|
||||
model,
|
||||
},
|
||||
)
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
let fs = fs.clone();
|
||||
move |model, should_be_favorite, cx| {
|
||||
crate::favorite_models::toggle_in_settings(
|
||||
model,
|
||||
should_be_favorite,
|
||||
fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
true, // Use popover styles for picker
|
||||
focus_handle,
|
||||
@@ -2195,12 +2211,53 @@ impl TextThreadEditor {
|
||||
};
|
||||
|
||||
let focus_handle = self.editor().focus_handle(cx);
|
||||
|
||||
let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() {
|
||||
(Color::Accent, IconName::ChevronUp)
|
||||
} else {
|
||||
(Color::Muted, IconName::ChevronDown)
|
||||
};
|
||||
|
||||
let tooltip = Tooltip::element({
|
||||
move |_, cx| {
|
||||
let focus_handle = focus_handle.clone();
|
||||
let should_show_cycle_row = !AgentSettings::get_global(cx)
|
||||
.favorite_model_ids()
|
||||
.is_empty();
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("Change Model"))
|
||||
.child(KeyBinding::for_action_in(
|
||||
&ToggleModelSelector,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.when(should_show_cycle_row, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.justify_between()
|
||||
.child(Label::new("Cycle Favorited Models"))
|
||||
.child(KeyBinding::for_action_in(
|
||||
&CycleFavoriteModels,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
}
|
||||
});
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.language_model_selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
@@ -2217,9 +2274,7 @@ impl TextThreadEditor {
|
||||
)
|
||||
.child(Icon::new(icon).color(color).size(IconSize::XSmall)),
|
||||
),
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
|
||||
},
|
||||
tooltip,
|
||||
gpui::Corner::BottomRight,
|
||||
cx,
|
||||
)
|
||||
@@ -2579,6 +2634,11 @@ impl Render for TextThreadEditor {
|
||||
.on_action(move |_: &ToggleModelSelector, window, cx| {
|
||||
language_model_selector.toggle(window, cx);
|
||||
})
|
||||
.on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
|
||||
this.language_model_selector.update(cx, |selector, cx| {
|
||||
selector.delegate.cycle_favorite_models(window, cx);
|
||||
});
|
||||
}))
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use gpui::{Action, FocusHandle, prelude::*};
|
||||
use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
|
||||
use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorHeader {
|
||||
@@ -42,6 +42,8 @@ pub struct ModelSelectorListItem {
|
||||
icon: Option<IconName>,
|
||||
is_selected: bool,
|
||||
is_focused: bool,
|
||||
is_favorite: bool,
|
||||
on_toggle_favorite: Option<Box<dyn Fn(&App) + 'static>>,
|
||||
}
|
||||
|
||||
impl ModelSelectorListItem {
|
||||
@@ -52,6 +54,8 @@ impl ModelSelectorListItem {
|
||||
icon: None,
|
||||
is_selected: false,
|
||||
is_focused: false,
|
||||
is_favorite: false,
|
||||
on_toggle_favorite: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +73,16 @@ impl ModelSelectorListItem {
|
||||
self.is_focused = is_focused;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_favorite(mut self, is_favorite: bool) -> Self {
|
||||
self.is_favorite = is_favorite;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_toggle_favorite(mut self, handler: impl Fn(&App) + 'static) -> Self {
|
||||
self.on_toggle_favorite = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorListItem {
|
||||
@@ -79,6 +93,8 @@ impl RenderOnce for ModelSelectorListItem {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
let is_favorite = self.is_favorite;
|
||||
|
||||
ListItem::new(self.index)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
@@ -97,11 +113,24 @@ impl RenderOnce for ModelSelectorListItem {
|
||||
.child(Label::new(self.title).truncate()),
|
||||
)
|
||||
.end_slot(div().pr_2().when(self.is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
this.child(Icon::new(IconName::Check).color(Color::Accent))
|
||||
}))
|
||||
.end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
|
||||
|this, handle_click| {
|
||||
let (icon, color, tooltip) = if is_favorite {
|
||||
(IconName::StarFilled, Color::Accent, "Unfavorite Model")
|
||||
} else {
|
||||
(IconName::Star, Color::Default, "Favorite Model")
|
||||
};
|
||||
this.child(
|
||||
IconButton::new(("toggle-favorite", self.index), icon)
|
||||
.layer(ElevationIndex::ElevatedSurface)
|
||||
.icon_color(color)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text(tooltip))
|
||||
.on_click(move |_, _, cx| (handle_click)(cx)),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option<u64> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Request body for the token counting API.
|
||||
/// Similar to `Request` but without `max_tokens` since it's not needed for counting.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CountTokensRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<StringOrContents>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<Tool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thinking: Option<Thinking>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
}
|
||||
|
||||
/// Response from the token counting API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CountTokensResponse {
|
||||
pub input_tokens: u64,
|
||||
}
|
||||
|
||||
/// Count the number of tokens in a message without creating it.
|
||||
pub async fn count_tokens(
|
||||
client: &dyn HttpClient,
|
||||
api_url: &str,
|
||||
api_key: &str,
|
||||
request: CountTokensRequest,
|
||||
) -> Result<CountTokensResponse, AnthropicError> {
|
||||
let uri = format!("{api_url}/v1/messages/count_tokens");
|
||||
|
||||
let request_builder = HttpRequest::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header("Anthropic-Version", "2023-06-01")
|
||||
.header("X-Api-Key", api_key.trim())
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
let serialized_request =
|
||||
serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
|
||||
let http_request = request_builder
|
||||
.body(AsyncBody::from(serialized_request))
|
||||
.map_err(AnthropicError::BuildRequestBody)?;
|
||||
|
||||
let mut response = client
|
||||
.send(http_request)
|
||||
.await
|
||||
.map_err(AnthropicError::HttpSend)?;
|
||||
|
||||
let rate_limits = RateLimitInfo::from_headers(response.headers());
|
||||
|
||||
if response.status().is_success() {
|
||||
let mut body = String::new();
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_string(&mut body)
|
||||
.await
|
||||
.map_err(AnthropicError::ReadResponse)?;
|
||||
|
||||
serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)
|
||||
} else {
|
||||
Err(handle_error_response(response, rate_limits).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_window_exceeded() {
|
||||
let error = ApiError {
|
||||
|
||||
@@ -4,6 +4,7 @@ use collections::{HashMap, HashSet};
|
||||
|
||||
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
|
||||
use debugger_ui::debugger_panel::DebugPanel;
|
||||
use editor::{Editor, EditorMode, MultiBuffer};
|
||||
use extension::ExtensionHostProxy;
|
||||
use fs::{FakeFs, Fs as _, RemoveOptions};
|
||||
use futures::StreamExt as _;
|
||||
@@ -12,22 +13,30 @@ use http_client::BlockedHttpClient;
|
||||
use language::{
|
||||
FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
|
||||
language_settings::{Formatter, FormatterList, language_settings},
|
||||
tree_sitter_typescript,
|
||||
rust_lang, tree_sitter_typescript,
|
||||
};
|
||||
use node_runtime::NodeRuntime;
|
||||
use project::{
|
||||
ProjectPath,
|
||||
debugger::session::ThreadId,
|
||||
lsp_store::{FormatTrigger, LspFormatTarget},
|
||||
trusted_worktrees::{PathTrust, TrustedWorktrees},
|
||||
};
|
||||
use remote::RemoteClient;
|
||||
use remote_server::{HeadlessAppState, HeadlessProject};
|
||||
use rpc::proto;
|
||||
use serde_json::json;
|
||||
use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore};
|
||||
use settings::{
|
||||
InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent,
|
||||
SettingsStore,
|
||||
};
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, atomic::AtomicUsize},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use task::TcpArgumentsTemplate;
|
||||
use util::{path, rel_path::rel_path};
|
||||
@@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project(
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
|
||||
let (project_a, worktree_id) = client_a
|
||||
.build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
|
||||
.build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a)
|
||||
.await;
|
||||
|
||||
// While the SSH worktree is being scanned, user A shares the remote project.
|
||||
@@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches(
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
|
||||
let (project_a, _) = client_a
|
||||
.build_ssh_project("/project", client_ssh, cx_a)
|
||||
.build_ssh_project("/project", client_ssh, false, cx_a)
|
||||
.await;
|
||||
|
||||
// While the SSH worktree is being scanned, user A shares the remote project.
|
||||
@@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier(
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
|
||||
let (project_a, worktree_id) = client_a
|
||||
.build_ssh_project(path!("/project"), client_ssh, cx_a)
|
||||
.build_ssh_project(path!("/project"), client_ssh, false, cx_a)
|
||||
.await;
|
||||
|
||||
// While the SSH worktree is being scanned, user A shares the remote project.
|
||||
@@ -615,6 +627,7 @@ async fn test_remote_server_debugger(
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -627,7 +640,7 @@ async fn test_remote_server_debugger(
|
||||
command_palette_hooks::init(cx);
|
||||
});
|
||||
let (project_a, _) = client_a
|
||||
.build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
|
||||
.build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
|
||||
.await;
|
||||
|
||||
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
|
||||
@@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries(
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries(
|
||||
command_palette_hooks::init(cx);
|
||||
});
|
||||
let (project_a, _) = client_a
|
||||
.build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
|
||||
.build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
|
||||
.await;
|
||||
|
||||
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
|
||||
@@ -838,3 +852,259 @@ async fn test_slow_adapter_startup_retries(
|
||||
|
||||
shutdown_session.await.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
|
||||
use project::trusted_worktrees::RemoteHostLocation;
|
||||
|
||||
cx_a.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
server_cx.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
|
||||
let mut server = TestServer::start(cx_a.executor().clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
|
||||
let server_name = "override-rust-analyzer";
|
||||
let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
remote_fs
|
||||
.insert_tree(
|
||||
path!("/projects"),
|
||||
json!({
|
||||
"project_a": {
|
||||
".zed": {
|
||||
"settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
|
||||
},
|
||||
"main.rs": "fn main() {}"
|
||||
},
|
||||
"project_b": { "lib.rs": "pub fn lib() {}" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let remote_http_client = Arc::new(BlockedHttpClient);
|
||||
let node = NodeRuntime::unavailable();
|
||||
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
|
||||
languages.add(rust_lang());
|
||||
|
||||
let capabilities = lsp::ServerCapabilities {
|
||||
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
|
||||
..lsp::ServerCapabilities::default()
|
||||
};
|
||||
let mut fake_language_servers = languages.register_fake_lsp(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
name: server_name,
|
||||
capabilities: capabilities.clone(),
|
||||
initializer: Some(Box::new({
|
||||
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
|
||||
move |fake_server| {
|
||||
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
|
||||
fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
|
||||
move |_params, _| {
|
||||
lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release);
|
||||
async move {
|
||||
Ok(Some(vec![lsp::InlayHint {
|
||||
position: lsp::Position::new(0, 0),
|
||||
label: lsp::InlayHintLabel::String("hint".to_string()),
|
||||
kind: None,
|
||||
text_edits: None,
|
||||
tooltip: None,
|
||||
padding_left: None,
|
||||
padding_right: None,
|
||||
data: None,
|
||||
}]))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
})),
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
let _headless_project = server_cx.new(|cx| {
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_ssh,
|
||||
fs: remote_fs.clone(),
|
||||
http_client: remote_http_client,
|
||||
node_runtime: node,
|
||||
languages,
|
||||
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
|
||||
},
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
|
||||
let (project_a, worktree_id_a) = client_a
|
||||
.build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a)
|
||||
.await;
|
||||
|
||||
cx_a.update(|cx| {
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
let language_settings = &mut settings.project.all_languages.defaults;
|
||||
language_settings.inlay_hints = Some(InlayHintSettingsContent {
|
||||
enabled: Some(true),
|
||||
..InlayHintSettingsContent::default()
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
project_a
|
||||
.update(cx_a, |project, cx| {
|
||||
project.languages().add(rust_lang());
|
||||
project.languages().register_fake_lsp_adapter(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
name: server_name,
|
||||
capabilities,
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
project.find_or_create_worktree(path!("/projects/project_b"), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
let worktree_ids = project_a.read_with(cx_a, |project, cx| {
|
||||
project
|
||||
.worktrees(cx)
|
||||
.map(|wt| wt.read(cx).id())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(worktree_ids.len(), 2);
|
||||
|
||||
let remote_host = project_a.read_with(cx_a, |project, cx| {
|
||||
project
|
||||
.remote_connection_options(cx)
|
||||
.map(RemoteHostLocation::from)
|
||||
});
|
||||
|
||||
let trusted_worktrees =
|
||||
cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
|
||||
|
||||
let can_trust_a =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
|
||||
let can_trust_b =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
|
||||
assert!(!can_trust_a, "project_a should be restricted initially");
|
||||
assert!(!can_trust_b, "project_b should be restricted initially");
|
||||
|
||||
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
|
||||
let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
|
||||
store.has_restricted_worktrees(&worktree_store, cx)
|
||||
});
|
||||
assert!(has_restricted, "should have restricted worktrees");
|
||||
|
||||
let buffer_before_approval = project_a
|
||||
.update(cx_a, |project, cx| {
|
||||
project.open_buffer((worktree_id_a, rel_path("main.rs")), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
|
||||
Editor::new(
|
||||
EditorMode::full(),
|
||||
cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
|
||||
Some(project_a.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx_a.run_until_parked();
|
||||
let fake_language_server = fake_language_servers.next();
|
||||
|
||||
cx_a.read(|cx| {
|
||||
let file = buffer_before_approval.read(cx).file();
|
||||
assert_eq!(
|
||||
language_settings(Some("Rust".into()), file, cx).language_servers,
|
||||
["...".to_string()],
|
||||
"remote .zed/settings.json must not sync before trust approval"
|
||||
)
|
||||
});
|
||||
|
||||
editor.update_in(cx_a, |editor, window, cx| {
|
||||
editor.handle_input("1", window, cx);
|
||||
});
|
||||
cx_a.run_until_parked();
|
||||
cx_a.executor().advance_clock(Duration::from_secs(1));
|
||||
assert_eq!(
|
||||
lsp_inlay_hint_request_count.load(Ordering::Acquire),
|
||||
0,
|
||||
"inlay hints must not be queried before trust approval"
|
||||
);
|
||||
|
||||
trusted_worktrees.update(cx_a, |store, cx| {
|
||||
store.trust(
|
||||
HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
|
||||
remote_host.clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx_a.run_until_parked();
|
||||
|
||||
cx_a.read(|cx| {
|
||||
let file = buffer_before_approval.read(cx).file();
|
||||
assert_eq!(
|
||||
language_settings(Some("Rust".into()), file, cx).language_servers,
|
||||
["override-rust-analyzer".to_string()],
|
||||
"remote .zed/settings.json should sync after trust approval"
|
||||
)
|
||||
});
|
||||
let _fake_language_server = fake_language_server.await.unwrap();
|
||||
editor.update_in(cx_a, |editor, window, cx| {
|
||||
editor.handle_input("1", window, cx);
|
||||
});
|
||||
cx_a.run_until_parked();
|
||||
cx_a.executor().advance_clock(Duration::from_secs(1));
|
||||
assert!(
|
||||
lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0,
|
||||
"inlay hints should be queried after trust approval"
|
||||
);
|
||||
|
||||
let can_trust_a =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
|
||||
let can_trust_b =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
|
||||
assert!(can_trust_a, "project_a should be trusted after trust()");
|
||||
assert!(!can_trust_b, "project_b should still be restricted");
|
||||
|
||||
trusted_worktrees.update(cx_a, |store, cx| {
|
||||
store.trust(
|
||||
HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
|
||||
remote_host.clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let can_trust_a =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
|
||||
let can_trust_b =
|
||||
trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
|
||||
assert!(can_trust_a, "project_a should remain trusted");
|
||||
assert!(can_trust_b, "project_b should now be trusted");
|
||||
|
||||
let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| {
|
||||
store.has_restricted_worktrees(&worktree_store, cx)
|
||||
});
|
||||
assert!(
|
||||
!has_restricted_after,
|
||||
"should have no restricted worktrees after trusting both"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -761,6 +761,7 @@ impl TestClient {
|
||||
&self,
|
||||
root_path: impl AsRef<Path>,
|
||||
ssh: Entity<RemoteClient>,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Project>, WorktreeId) {
|
||||
let project = cx.update(|cx| {
|
||||
@@ -771,6 +772,7 @@ impl TestClient {
|
||||
self.app_state.user_store.clone(),
|
||||
self.app_state.languages.clone(),
|
||||
self.app_state.fs.clone(),
|
||||
init_worktree_trust,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -839,6 +841,7 @@ impl TestClient {
|
||||
self.app_state.languages.clone(),
|
||||
self.app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ schemars.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
slotmap.workspace = true
|
||||
smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
|
||||
@@ -6,6 +6,7 @@ use parking_lot::Mutex;
|
||||
use postage::barrier;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use slotmap::SlotMap;
|
||||
use smol::channel;
|
||||
use std::{
|
||||
fmt,
|
||||
@@ -50,7 +51,7 @@ pub(crate) struct Client {
|
||||
next_id: AtomicI32,
|
||||
outbound_tx: channel::Sender<String>,
|
||||
name: Arc<str>,
|
||||
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
|
||||
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(dead_code)]
|
||||
@@ -191,21 +192,20 @@ impl Client {
|
||||
let (outbound_tx, outbound_rx) = channel::unbounded::<String>();
|
||||
let (output_done_tx, output_done_rx) = barrier::channel();
|
||||
|
||||
let notification_handlers =
|
||||
Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default()));
|
||||
let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default()));
|
||||
let response_handlers =
|
||||
Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default())));
|
||||
let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default()));
|
||||
|
||||
let receive_input_task = cx.spawn({
|
||||
let notification_handlers = notification_handlers.clone();
|
||||
let subscription_set = subscription_set.clone();
|
||||
let response_handlers = response_handlers.clone();
|
||||
let request_handlers = request_handlers.clone();
|
||||
let transport = transport.clone();
|
||||
async move |cx| {
|
||||
Self::handle_input(
|
||||
transport,
|
||||
notification_handlers,
|
||||
subscription_set,
|
||||
request_handlers,
|
||||
response_handlers,
|
||||
cx,
|
||||
@@ -236,7 +236,7 @@ impl Client {
|
||||
|
||||
Ok(Self {
|
||||
server_id,
|
||||
notification_handlers,
|
||||
subscription_set,
|
||||
response_handlers,
|
||||
name: server_name,
|
||||
next_id: Default::default(),
|
||||
@@ -257,7 +257,7 @@ impl Client {
|
||||
/// to pending requests) and notifications (which trigger registered handlers).
|
||||
async fn handle_input(
|
||||
transport: Arc<dyn Transport>,
|
||||
notification_handlers: Arc<Mutex<HashMap<&'static str, NotificationHandler>>>,
|
||||
subscription_set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
request_handlers: Arc<Mutex<HashMap<&'static str, RequestHandler>>>,
|
||||
response_handlers: Arc<Mutex<Option<HashMap<RequestId, ResponseHandler>>>>,
|
||||
cx: &mut AsyncApp,
|
||||
@@ -282,10 +282,11 @@ impl Client {
|
||||
handler(Ok(message.to_string()));
|
||||
}
|
||||
} else if let Ok(notification) = serde_json::from_str::<AnyNotification>(&message) {
|
||||
let mut notification_handlers = notification_handlers.lock();
|
||||
if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) {
|
||||
handler(notification.params.unwrap_or(Value::Null), cx.clone());
|
||||
}
|
||||
subscription_set.lock().notify(
|
||||
¬ification.method,
|
||||
notification.params.unwrap_or(Value::Null),
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
log::error!("Unhandled JSON from context_server: {}", message);
|
||||
}
|
||||
@@ -451,12 +452,18 @@ impl Client {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn on_notification(
|
||||
&self,
|
||||
method: &'static str,
|
||||
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
|
||||
) {
|
||||
self.notification_handlers.lock().insert(method, f);
|
||||
) -> NotificationSubscription {
|
||||
let mut notification_subscriptions = self.subscription_set.lock();
|
||||
|
||||
NotificationSubscription {
|
||||
id: notification_subscriptions.add_handler(method, f),
|
||||
set: self.subscription_set.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,3 +492,73 @@ impl fmt::Debug for Client {
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
slotmap::new_key_type! {
|
||||
struct NotificationSubscriptionId;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NotificationSubscriptionSet {
|
||||
// we have very few subscriptions at the moment
|
||||
methods: Vec<(&'static str, Vec<NotificationSubscriptionId>)>,
|
||||
handlers: SlotMap<NotificationSubscriptionId, NotificationHandler>,
|
||||
}
|
||||
|
||||
impl NotificationSubscriptionSet {
|
||||
#[must_use]
|
||||
fn add_handler(
|
||||
&mut self,
|
||||
method: &'static str,
|
||||
handler: NotificationHandler,
|
||||
) -> NotificationSubscriptionId {
|
||||
let id = self.handlers.insert(handler);
|
||||
if let Some((_, handler_ids)) = self
|
||||
.methods
|
||||
.iter_mut()
|
||||
.find(|(probe_method, _)| method == *probe_method)
|
||||
{
|
||||
debug_assert!(
|
||||
handler_ids.len() < 20,
|
||||
"Too many MCP handlers for {}. Consider using a different data structure.",
|
||||
method
|
||||
);
|
||||
|
||||
handler_ids.push(id);
|
||||
} else {
|
||||
self.methods.push((method, vec![id]));
|
||||
};
|
||||
id
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) {
|
||||
let Some((_, handler_ids)) = self
|
||||
.methods
|
||||
.iter_mut()
|
||||
.find(|(probe_method, _)| method == *probe_method)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for handler_id in handler_ids {
|
||||
if let Some(handler) = self.handlers.get_mut(*handler_id) {
|
||||
handler(payload.clone(), cx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationSubscription {
|
||||
id: NotificationSubscriptionId,
|
||||
set: Arc<Mutex<NotificationSubscriptionSet>>,
|
||||
}
|
||||
|
||||
impl Drop for NotificationSubscription {
|
||||
fn drop(&mut self) {
|
||||
let mut set = self.set.lock();
|
||||
set.handlers.remove(self.id);
|
||||
set.methods.retain_mut(|(_, handler_ids)| {
|
||||
handler_ids.retain(|id| *id != self.id);
|
||||
!handler_ids.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,22 +96,6 @@ impl ContextServer {
|
||||
self.initialize(self.new_client(cx)?).await
|
||||
}
|
||||
|
||||
/// Starts the context server, making sure handlers are registered before initialization happens
|
||||
pub async fn start_with_handlers(
|
||||
&self,
|
||||
notification_handlers: Vec<(
|
||||
&'static str,
|
||||
Box<dyn 'static + Send + FnMut(serde_json::Value, AsyncApp)>,
|
||||
)>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<()> {
|
||||
let client = self.new_client(cx)?;
|
||||
for (method, handler) in notification_handlers {
|
||||
client.on_notification(method, handler);
|
||||
}
|
||||
self.initialize(client).await
|
||||
}
|
||||
|
||||
fn new_client(&self, cx: &AsyncApp) -> Result<Client> {
|
||||
Ok(match &self.configuration {
|
||||
ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
|
||||
|
||||
@@ -12,7 +12,7 @@ use futures::channel::oneshot;
|
||||
use gpui::AsyncApp;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, NotificationSubscription};
|
||||
use crate::types::{self, Notification, Request};
|
||||
|
||||
pub struct ModelContextProtocol {
|
||||
@@ -119,7 +119,7 @@ impl InitializedContextServerProtocol {
|
||||
&self,
|
||||
method: &'static str,
|
||||
f: Box<dyn 'static + Send + FnMut(Value, AsyncApp)>,
|
||||
) {
|
||||
self.inner.on_notification(method, f);
|
||||
) -> NotificationSubscription {
|
||||
self.inner.on_notification(method, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ pub struct PromptMessage {
|
||||
pub content: MessageContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
User,
|
||||
|
||||
@@ -8,8 +8,7 @@ use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
use language_extension::LspAccess;
|
||||
use node_runtime::{NodeBinaryOptions, NodeRuntime};
|
||||
use project::Project;
|
||||
use project::project_settings::ProjectSettings;
|
||||
use project::{Project, project_settings::ProjectSettings};
|
||||
use release_channel::{AppCommitSha, AppVersion};
|
||||
use reqwest_client::ReqwestClient;
|
||||
use settings::{Settings, SettingsStore};
|
||||
@@ -115,7 +114,7 @@ pub fn init(cx: &mut App) -> EpAppState {
|
||||
tx.send(Some(options)).log_err();
|
||||
})
|
||||
.detach();
|
||||
let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
|
||||
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
|
||||
|
||||
let extension_host_proxy = ExtensionHostProxy::global(cx);
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ async fn setup_project(
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -41,14 +41,16 @@ use multi_buffer::{
|
||||
use parking_lot::Mutex;
|
||||
use pretty_assertions::{assert_eq, assert_ne};
|
||||
use project::{
|
||||
FakeFs,
|
||||
FakeFs, Project,
|
||||
debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
|
||||
project_settings::LspSettings,
|
||||
trusted_worktrees::{PathTrust, TrustedWorktrees},
|
||||
};
|
||||
use serde_json::{self, json};
|
||||
use settings::{
|
||||
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
|
||||
IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
|
||||
IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent,
|
||||
SettingsStore,
|
||||
};
|
||||
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
|
||||
use std::{
|
||||
@@ -25578,6 +25580,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
|
||||
ˇ log('for else')
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
ˇfor item in items:
|
||||
@@ -25597,6 +25600,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
|
||||
// test relative indent is preserved when tab
|
||||
// for `if`, `elif`, `else`, `while`, `with` and `for`
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
ˇfor item in items:
|
||||
@@ -25630,6 +25634,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
|
||||
ˇ return 0
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
ˇtry:
|
||||
@@ -25646,6 +25651,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
|
||||
// test relative indent is preserved when tab
|
||||
// for `try`, `except`, `else`, `finally`, `match` and `def`
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
ˇtry:
|
||||
@@ -25679,6 +25685,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("else:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
if i == 2:
|
||||
@@ -25696,6 +25703,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("except:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25715,6 +25723,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("else:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25738,6 +25747,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("finally:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25762,6 +25772,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("else:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25787,6 +25798,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("finally:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25812,6 +25824,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("except:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25835,6 +25848,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("except:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
try:
|
||||
@@ -25856,6 +25870,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("else:", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def main():
|
||||
for i in range(10):
|
||||
@@ -25872,6 +25887,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("a", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
def f() -> list[str]:
|
||||
aˇ
|
||||
@@ -25885,6 +25901,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input(":", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
match 1:
|
||||
case:ˇ
|
||||
@@ -25908,6 +25925,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
# COMMENT:
|
||||
ˇ
|
||||
@@ -25920,7 +25938,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
{
|
||||
ˇ
|
||||
@@ -25980,6 +25998,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
|
||||
ˇ}
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
function main() {
|
||||
ˇfor item in $items; do
|
||||
@@ -25997,6 +26016,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
|
||||
"});
|
||||
// test relative indent is preserved when tab
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
function main() {
|
||||
ˇfor item in $items; do
|
||||
@@ -26031,6 +26051,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
|
||||
ˇ}
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
function handle() {
|
||||
ˇcase \"$1\" in
|
||||
@@ -26073,6 +26094,7 @@ async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
ˇ}
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.handle_input("#", window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
function main() {
|
||||
#ˇ for item in $items; do
|
||||
@@ -26107,6 +26129,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("else", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
echo \"foo bar\"
|
||||
@@ -26122,6 +26145,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("elif", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
echo \"foo bar\"
|
||||
@@ -26139,6 +26163,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("fi", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
echo \"foo bar\"
|
||||
@@ -26156,6 +26181,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("done", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
while read line; do
|
||||
echo \"$line\"
|
||||
@@ -26171,6 +26197,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("done", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
for file in *.txt; do
|
||||
cat \"$file\"
|
||||
@@ -26191,6 +26218,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("esac", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
case \"$1\" in
|
||||
start)
|
||||
@@ -26213,6 +26241,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("*)", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
case \"$1\" in
|
||||
start)
|
||||
@@ -26232,6 +26261,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("fi", window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
echo \"outer if\"
|
||||
@@ -26258,6 +26288,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
# COMMENT:
|
||||
ˇ
|
||||
@@ -26271,7 +26302,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
@@ -26286,7 +26317,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
else
|
||||
@@ -26301,7 +26332,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
if [ \"$1\" = \"test\" ]; then
|
||||
elif
|
||||
@@ -26315,7 +26346,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
for file in *.txt; do
|
||||
ˇ
|
||||
@@ -26329,7 +26360,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
case \"$1\" in
|
||||
start)
|
||||
@@ -26346,7 +26377,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
case \"$1\" in
|
||||
start)
|
||||
@@ -26362,7 +26393,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
function test() {
|
||||
ˇ
|
||||
@@ -26376,7 +26407,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.newline(&Newline, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
echo \"test\";
|
||||
ˇ
|
||||
@@ -29335,3 +29366,166 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
|
||||
|
||||
cx.assert_editor_state(after);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
cx.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.all_languages.defaults.inlay_hints =
|
||||
Some(InlayHintSettingsContent {
|
||||
enabled: Some(true),
|
||||
..InlayHintSettingsContent::default()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".zed": {
|
||||
"settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
|
||||
},
|
||||
"main.rs": "fn main() {}"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
|
||||
let server_name = "override-rust-analyzer";
|
||||
let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await;
|
||||
|
||||
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
||||
language_registry.add(rust_lang());
|
||||
|
||||
let capabilities = lsp::ServerCapabilities {
|
||||
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
|
||||
..lsp::ServerCapabilities::default()
|
||||
};
|
||||
let mut fake_language_servers = language_registry.register_fake_lsp(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
name: server_name,
|
||||
capabilities,
|
||||
initializer: Some(Box::new({
|
||||
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
|
||||
move |fake_server| {
|
||||
let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
|
||||
fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
|
||||
move |_params, _| {
|
||||
lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release);
|
||||
async move {
|
||||
Ok(Some(vec![lsp::InlayHint {
|
||||
position: lsp::Position::new(0, 0),
|
||||
label: lsp::InlayHintLabel::String("hint".to_string()),
|
||||
kind: None,
|
||||
text_edits: None,
|
||||
tooltip: None,
|
||||
padding_left: None,
|
||||
padding_right: None,
|
||||
data: None,
|
||||
}]))
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
})),
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let worktree_id = project.read_with(cx, |project, cx| {
|
||||
project
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.map(|wt| wt.read(cx).id())
|
||||
.expect("should have a worktree")
|
||||
});
|
||||
|
||||
let trusted_worktrees =
|
||||
cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
|
||||
|
||||
let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
|
||||
assert!(!can_trust, "worktree should be restricted initially");
|
||||
|
||||
let buffer_before_approval = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer((worktree_id, rel_path("main.rs")), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| {
|
||||
Editor::new(
|
||||
EditorMode::full(),
|
||||
cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
|
||||
Some(project.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let fake_language_server = fake_language_servers.next();
|
||||
|
||||
cx.read(|cx| {
|
||||
let file = buffer_before_approval.read(cx).file();
|
||||
assert_eq!(
|
||||
language::language_settings::language_settings(Some("Rust".into()), file, cx)
|
||||
.language_servers,
|
||||
["...".to_string()],
|
||||
"local .zed/settings.json must not apply before trust approval"
|
||||
)
|
||||
});
|
||||
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.handle_input("1", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_secs(1));
|
||||
assert_eq!(
|
||||
lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire),
|
||||
0,
|
||||
"inlay hints must not be queried before trust approval"
|
||||
);
|
||||
|
||||
trusted_worktrees.update(cx, |store, cx| {
|
||||
store.trust(
|
||||
std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.read(|cx| {
|
||||
let file = buffer_before_approval.read(cx).file();
|
||||
assert_eq!(
|
||||
language::language_settings::language_settings(Some("Rust".into()), file, cx)
|
||||
.language_servers,
|
||||
["override-rust-analyzer".to_string()],
|
||||
"local .zed/settings.json should apply after trust approval"
|
||||
)
|
||||
});
|
||||
let _fake_language_server = fake_language_server.await.unwrap();
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.handle_input("1", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_secs(1));
|
||||
assert!(
|
||||
lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0,
|
||||
"inlay hints should be queried after trust approval"
|
||||
);
|
||||
|
||||
let can_trust_after =
|
||||
trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
|
||||
assert!(can_trust_after, "worktree should be trusted after trust()");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct JsxTagCompletionState {
|
||||
/// that corresponds to the tag name
|
||||
/// Note that this is not configurable, i.e. we assume the first
|
||||
/// named child of a tag node is the tag name
|
||||
const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0;
|
||||
const TS_NODE_TAG_NAME_CHILD_INDEX: u32 = 0;
|
||||
|
||||
/// Maximum number of parent elements to walk back when checking if an open tag
|
||||
/// is already closed.
|
||||
|
||||
@@ -305,6 +305,12 @@ impl EditorTestContext {
|
||||
snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
|
||||
}
|
||||
|
||||
pub async fn wait_for_autoindent_applied(&mut self) {
|
||||
if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) {
|
||||
fut.await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_head_text(&mut self, diff_base: &str) {
|
||||
self.cx.run_until_parked();
|
||||
let fs =
|
||||
|
||||
@@ -422,7 +422,7 @@ pub fn init(cx: &mut App) -> Arc<AgentAppState> {
|
||||
tx.send(Some(options)).log_err();
|
||||
})
|
||||
.detach();
|
||||
let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
|
||||
let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
|
||||
|
||||
let extension_host_proxy = ExtensionHostProxy::global(cx);
|
||||
debug_adapter_extension::init(extension_host_proxy.clone(), cx);
|
||||
|
||||
@@ -202,6 +202,7 @@ impl ExampleInstance {
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ use wasmtime::{
|
||||
CacheStore, Engine, Store,
|
||||
component::{Component, ResourceTable},
|
||||
};
|
||||
use wasmtime_wasi::{self as wasi, WasiView};
|
||||
use wasmtime_wasi::p2::{self as wasi, IoView as _};
|
||||
use wit::Extension;
|
||||
|
||||
pub struct WasmHost {
|
||||
@@ -685,8 +685,8 @@ impl WasmHost {
|
||||
.await
|
||||
.context("failed to create extension work dir")?;
|
||||
|
||||
let file_perms = wasi::FilePerms::all();
|
||||
let dir_perms = wasi::DirPerms::all();
|
||||
let file_perms = wasmtime_wasi::FilePerms::all();
|
||||
let dir_perms = wasmtime_wasi::DirPerms::all();
|
||||
let path = SanitizedPath::new(&extension_work_dir).to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
let path = path.replace('\\', "/");
|
||||
@@ -856,11 +856,13 @@ impl WasmState {
|
||||
}
|
||||
}
|
||||
|
||||
impl wasi::WasiView for WasmState {
|
||||
impl wasi::IoView for WasmState {
|
||||
fn table(&mut self) -> &mut ResourceTable {
|
||||
&mut self.table
|
||||
}
|
||||
}
|
||||
|
||||
impl wasi::WasiView for WasmState {
|
||||
fn ctx(&mut self) -> &mut wasi::WasiCtx {
|
||||
&mut self.ctx
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn new_linker(
|
||||
f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
|
||||
) -> Linker<WasmState> {
|
||||
let mut linker = Linker::new(&wasm_engine(executor));
|
||||
wasmtime_wasi::add_to_linker_async(&mut linker).unwrap();
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
|
||||
f(&mut linker, wasi_view).unwrap();
|
||||
linker
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::wasm_host::wit::since_v0_6_0::{
|
||||
dap::{
|
||||
AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest,
|
||||
StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate,
|
||||
BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments,
|
||||
TcpArguments, TcpArgumentsTemplate,
|
||||
},
|
||||
slash_command::SlashCommandOutputSection,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
|
||||
pub const LFS_DIR: &str = "lfs";
|
||||
pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG";
|
||||
pub const INDEX_LOCK: &str = "index.lock";
|
||||
pub const REPO_EXCLUDE: &str = "info/exclude";
|
||||
|
||||
actions!(
|
||||
git,
|
||||
|
||||
@@ -43,7 +43,8 @@ use gpui::{
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
|
||||
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
|
||||
Role, ZED_CLOUD_PROVIDER_ID,
|
||||
};
|
||||
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
||||
use multi_buffer::ExcerptInfo;
|
||||
@@ -57,7 +58,7 @@ use project::{
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
|
||||
project_settings::{GitPathStyle, ProjectSettings},
|
||||
};
|
||||
use prompt_store::RULES_FILE_NAMES;
|
||||
use prompt_store::{PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore, StatusStyle};
|
||||
use std::future::Future;
|
||||
@@ -98,6 +99,10 @@ actions!(
|
||||
ToggleSortByPath,
|
||||
/// Toggles showing entries in tree vs flat view.
|
||||
ToggleTreeView,
|
||||
/// Expands the selected entry to show its children.
|
||||
ExpandSelectedEntry,
|
||||
/// Collapses the selected entry to hide its children.
|
||||
CollapseSelectedEntry,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -896,6 +901,48 @@ impl GitPanel {
|
||||
.position(|entry| entry.status_entry().is_some())
|
||||
}
|
||||
|
||||
fn expand_selected_entry(
|
||||
&mut self,
|
||||
_: &ExpandSelectedEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(entry) = self.get_selected_entry().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let GitListEntry::Directory(dir_entry) = entry {
|
||||
if dir_entry.expanded {
|
||||
self.select_next(&SelectNext, window, cx);
|
||||
} else {
|
||||
self.toggle_directory(&dir_entry.key, window, cx);
|
||||
}
|
||||
} else {
|
||||
self.select_next(&SelectNext, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_selected_entry(
|
||||
&mut self,
|
||||
_: &CollapseSelectedEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(entry) = self.get_selected_entry().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let GitListEntry::Directory(dir_entry) = entry {
|
||||
if dir_entry.expanded {
|
||||
self.toggle_directory(&dir_entry.key, window, cx);
|
||||
} else {
|
||||
self.select_previous(&SelectPrevious, window, cx);
|
||||
}
|
||||
} else {
|
||||
self.select_previous(&SelectPrevious, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(first_entry) = self.first_status_entry_index() {
|
||||
self.selected_entry = Some(first_entry);
|
||||
@@ -914,28 +961,44 @@ impl GitPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected_entry) = self.selected_entry {
|
||||
let new_selected_entry = if selected_entry > 0 {
|
||||
selected_entry - 1
|
||||
} else {
|
||||
selected_entry
|
||||
};
|
||||
let Some(selected_entry) = self.selected_entry else {
|
||||
return;
|
||||
};
|
||||
|
||||
if matches!(
|
||||
self.entries.get(new_selected_entry),
|
||||
Some(GitListEntry::Header(..))
|
||||
) {
|
||||
if new_selected_entry > 0 {
|
||||
self.selected_entry = Some(new_selected_entry - 1)
|
||||
}
|
||||
} else {
|
||||
self.selected_entry = Some(new_selected_entry);
|
||||
let new_index = match &self.view_mode {
|
||||
GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
|
||||
GitPanelViewMode::Tree(state) => {
|
||||
let Some(current_logical_index) = state
|
||||
.logical_indices
|
||||
.iter()
|
||||
.position(|&i| i == selected_entry)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.logical_indices[current_logical_index.saturating_sub(1)]
|
||||
}
|
||||
};
|
||||
|
||||
self.scroll_to_selected_entry(cx);
|
||||
if selected_entry == 0 && new_index == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
if matches!(
|
||||
self.entries.get(new_index.saturating_sub(1)),
|
||||
Some(GitListEntry::Header(..))
|
||||
) && new_index == 0
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
|
||||
self.selected_entry = Some(new_index.saturating_sub(1));
|
||||
} else {
|
||||
self.selected_entry = Some(new_index);
|
||||
}
|
||||
|
||||
self.scroll_to_selected_entry(cx);
|
||||
}
|
||||
|
||||
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -944,25 +1007,36 @@ impl GitPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selected_entry) = self.selected_entry {
|
||||
let new_selected_entry = if selected_entry < item_count - 1 {
|
||||
selected_entry + 1
|
||||
} else {
|
||||
selected_entry
|
||||
};
|
||||
if matches!(
|
||||
self.entries.get(new_selected_entry),
|
||||
Some(GitListEntry::Header(..))
|
||||
) {
|
||||
self.selected_entry = Some(new_selected_entry + 1);
|
||||
} else {
|
||||
self.selected_entry = Some(new_selected_entry);
|
||||
}
|
||||
let Some(selected_entry) = self.selected_entry else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.scroll_to_selected_entry(cx);
|
||||
if selected_entry == item_count - 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
let new_index = match &self.view_mode {
|
||||
GitPanelViewMode::Flat => selected_entry.saturating_add(1),
|
||||
GitPanelViewMode::Tree(state) => {
|
||||
let Some(current_logical_index) = state
|
||||
.logical_indices
|
||||
.iter()
|
||||
.position(|&i| i == selected_entry)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
state.logical_indices[current_logical_index.saturating_add(1)]
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
|
||||
self.selected_entry = Some(new_index.saturating_add(1));
|
||||
} else {
|
||||
self.selected_entry = Some(new_index);
|
||||
}
|
||||
|
||||
self.scroll_to_selected_entry(cx);
|
||||
}
|
||||
|
||||
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -2376,6 +2450,31 @@ impl GitPanel {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_commit_message_prompt(
|
||||
is_using_legacy_zed_pro: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> String {
|
||||
const DEFAULT_PROMPT: &str = include_str!("commit_message_prompt.txt");
|
||||
|
||||
// Remove this once we stop supporting legacy Zed Pro
|
||||
// In legacy Zed Pro, Git commit summary generation did not count as a
|
||||
// prompt. If the user changes the prompt, our classification will fail,
|
||||
// meaning that users will be charged for generating commit messages.
|
||||
if is_using_legacy_zed_pro {
|
||||
return DEFAULT_PROMPT.to_string();
|
||||
}
|
||||
|
||||
let load = async {
|
||||
let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
|
||||
store
|
||||
.update(cx, |s, cx| s.load(PromptId::CommitMessage, cx))
|
||||
.ok()?
|
||||
.await
|
||||
.ok()
|
||||
};
|
||||
load.await.unwrap_or_else(|| DEFAULT_PROMPT.to_string())
|
||||
}
|
||||
|
||||
/// Generates a commit message using an LLM.
|
||||
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
|
||||
@@ -2406,6 +2505,13 @@ impl GitPanel {
|
||||
let project = self.project.clone();
|
||||
let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
|
||||
|
||||
// Remove this once we stop supporting legacy Zed Pro
|
||||
let is_using_legacy_zed_pro = provider.id() == ZED_CLOUD_PROVIDER_ID
|
||||
&& self.workspace.upgrade().map_or(false, |workspace| {
|
||||
workspace.read(cx).user_store().read(cx).plan()
|
||||
== Some(cloud_llm_client::Plan::V1(cloud_llm_client::PlanV1::ZedPro))
|
||||
});
|
||||
|
||||
self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
|
||||
async move {
|
||||
let _defer = cx.on_drop(&this, |this, _cx| {
|
||||
@@ -2441,14 +2547,14 @@ impl GitPanel {
|
||||
|
||||
let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
|
||||
|
||||
let prompt = Self::load_commit_message_prompt(is_using_legacy_zed_pro, &mut cx).await;
|
||||
|
||||
let subject = this.update(cx, |this, cx| {
|
||||
this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
|
||||
})?;
|
||||
|
||||
let text_empty = subject.trim().is_empty();
|
||||
|
||||
const PROMPT: &str = include_str!("commit_message_prompt.txt");
|
||||
|
||||
let rules_section = match &rules_content {
|
||||
Some(rules) => format!(
|
||||
"\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
|
||||
@@ -2464,7 +2570,7 @@ impl GitPanel {
|
||||
};
|
||||
|
||||
let content = format!(
|
||||
"{PROMPT}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
|
||||
"{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
|
||||
);
|
||||
|
||||
let request = LanguageModelRequest {
|
||||
@@ -5264,6 +5370,8 @@ impl Render for GitPanel {
|
||||
.on_action(cx.listener(Self::stash_all))
|
||||
.on_action(cx.listener(Self::stash_pop))
|
||||
})
|
||||
.on_action(cx.listener(Self::collapse_selected_entry))
|
||||
.on_action(cx.listener(Self::expand_selected_entry))
|
||||
.on_action(cx.listener(Self::select_first))
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_previous))
|
||||
|
||||
@@ -421,6 +421,7 @@ async fn open_remote_worktree(
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -15,9 +15,6 @@ pub(crate) struct MetalAtlas(Mutex<MetalAtlasState>);
|
||||
impl MetalAtlas {
|
||||
pub(crate) fn new(device: Device) -> Self {
|
||||
MetalAtlas(Mutex::new(MetalAtlasState {
|
||||
// Shared memory can be used only if CPU and GPU share the same memory space.
|
||||
// https://developer.apple.com/documentation/metal/setting-resource-storage-modes
|
||||
unified_memory: device.has_unified_memory(),
|
||||
device: AssertSend(device),
|
||||
monochrome_textures: Default::default(),
|
||||
polychrome_textures: Default::default(),
|
||||
@@ -32,7 +29,6 @@ impl MetalAtlas {
|
||||
|
||||
struct MetalAtlasState {
|
||||
device: AssertSend<Device>,
|
||||
unified_memory: bool,
|
||||
monochrome_textures: AtlasTextureList<MetalAtlasTexture>,
|
||||
polychrome_textures: AtlasTextureList<MetalAtlasTexture>,
|
||||
tiles_by_key: FxHashMap<AtlasKey, AtlasTile>,
|
||||
@@ -150,11 +146,6 @@ impl MetalAtlasState {
|
||||
}
|
||||
texture_descriptor.set_pixel_format(pixel_format);
|
||||
texture_descriptor.set_usage(usage);
|
||||
texture_descriptor.set_storage_mode(if self.unified_memory {
|
||||
metal::MTLStorageMode::Shared
|
||||
} else {
|
||||
metal::MTLStorageMode::Managed
|
||||
});
|
||||
let metal_texture = self.device.new_texture(&texture_descriptor);
|
||||
|
||||
let texture_list = match kind {
|
||||
|
||||
@@ -76,22 +76,12 @@ impl InstanceBufferPool {
|
||||
self.buffers.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn acquire(
|
||||
&mut self,
|
||||
device: &metal::Device,
|
||||
unified_memory: bool,
|
||||
) -> InstanceBuffer {
|
||||
pub(crate) fn acquire(&mut self, device: &metal::Device) -> InstanceBuffer {
|
||||
let buffer = self.buffers.pop().unwrap_or_else(|| {
|
||||
let options = if unified_memory {
|
||||
MTLResourceOptions::StorageModeShared
|
||||
// Buffers are write only which can benefit from the combined cache
|
||||
// https://developer.apple.com/documentation/metal/mtlresourceoptions/cpucachemodewritecombined
|
||||
| MTLResourceOptions::CPUCacheModeWriteCombined
|
||||
} else {
|
||||
MTLResourceOptions::StorageModeManaged
|
||||
};
|
||||
|
||||
device.new_buffer(self.buffer_size as u64, options)
|
||||
device.new_buffer(
|
||||
self.buffer_size as u64,
|
||||
MTLResourceOptions::StorageModeManaged,
|
||||
)
|
||||
});
|
||||
InstanceBuffer {
|
||||
metal_buffer: buffer,
|
||||
@@ -109,7 +99,6 @@ impl InstanceBufferPool {
|
||||
pub(crate) struct MetalRenderer {
|
||||
device: metal::Device,
|
||||
layer: metal::MetalLayer,
|
||||
unified_memory: bool,
|
||||
presents_with_transaction: bool,
|
||||
command_queue: CommandQueue,
|
||||
paths_rasterization_pipeline_state: metal::RenderPipelineState,
|
||||
@@ -190,10 +179,6 @@ impl MetalRenderer {
|
||||
output
|
||||
}
|
||||
|
||||
// Shared memory can be used only if CPU and GPU share the same memory space.
|
||||
// https://developer.apple.com/documentation/metal/setting-resource-storage-modes
|
||||
let unified_memory = device.has_unified_memory();
|
||||
|
||||
let unit_vertices = [
|
||||
to_float2_bits(point(0., 0.)),
|
||||
to_float2_bits(point(1., 0.)),
|
||||
@@ -205,12 +190,7 @@ impl MetalRenderer {
|
||||
let unit_vertices = device.new_buffer_with_data(
|
||||
unit_vertices.as_ptr() as *const c_void,
|
||||
mem::size_of_val(&unit_vertices) as u64,
|
||||
if unified_memory {
|
||||
MTLResourceOptions::StorageModeShared
|
||||
| MTLResourceOptions::CPUCacheModeWriteCombined
|
||||
} else {
|
||||
MTLResourceOptions::StorageModeManaged
|
||||
},
|
||||
MTLResourceOptions::StorageModeManaged,
|
||||
);
|
||||
|
||||
let paths_rasterization_pipeline_state = build_path_rasterization_pipeline_state(
|
||||
@@ -288,7 +268,6 @@ impl MetalRenderer {
|
||||
device,
|
||||
layer,
|
||||
presents_with_transaction: false,
|
||||
unified_memory,
|
||||
command_queue,
|
||||
paths_rasterization_pipeline_state,
|
||||
path_sprites_pipeline_state,
|
||||
@@ -358,23 +337,14 @@ impl MetalRenderer {
|
||||
texture_descriptor.set_width(size.width.0 as u64);
|
||||
texture_descriptor.set_height(size.height.0 as u64);
|
||||
texture_descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm);
|
||||
texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
|
||||
texture_descriptor
|
||||
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
|
||||
self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor));
|
||||
|
||||
if self.path_sample_count > 1 {
|
||||
// https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus
|
||||
// Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon
|
||||
let storage_mode = if self.unified_memory {
|
||||
metal::MTLStorageMode::Memoryless
|
||||
} else {
|
||||
metal::MTLStorageMode::Private
|
||||
};
|
||||
|
||||
let mut msaa_descriptor = texture_descriptor;
|
||||
msaa_descriptor.set_texture_type(metal::MTLTextureType::D2Multisample);
|
||||
msaa_descriptor.set_storage_mode(storage_mode);
|
||||
msaa_descriptor.set_storage_mode(metal::MTLStorageMode::Private);
|
||||
msaa_descriptor.set_sample_count(self.path_sample_count as _);
|
||||
self.path_intermediate_msaa_texture = Some(self.device.new_texture(&msaa_descriptor));
|
||||
} else {
|
||||
@@ -408,10 +378,7 @@ impl MetalRenderer {
|
||||
};
|
||||
|
||||
loop {
|
||||
let mut instance_buffer = self
|
||||
.instance_buffer_pool
|
||||
.lock()
|
||||
.acquire(&self.device, self.unified_memory);
|
||||
let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device);
|
||||
|
||||
let command_buffer =
|
||||
self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size);
|
||||
@@ -583,14 +550,10 @@ impl MetalRenderer {
|
||||
|
||||
command_encoder.end_encoding();
|
||||
|
||||
if !self.unified_memory {
|
||||
// Sync the instance buffer to the GPU
|
||||
instance_buffer.metal_buffer.did_modify_range(NSRange {
|
||||
location: 0,
|
||||
length: instance_offset as NSUInteger,
|
||||
});
|
||||
}
|
||||
|
||||
instance_buffer.metal_buffer.did_modify_range(NSRange {
|
||||
location: 0,
|
||||
length: instance_offset as NSUInteger,
|
||||
});
|
||||
Ok(command_buffer.to_owned())
|
||||
}
|
||||
|
||||
|
||||
@@ -1961,7 +1961,7 @@ impl Window {
|
||||
}
|
||||
|
||||
/// Determine whether the given action is available along the dispatch path to the currently focused element.
|
||||
pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool {
|
||||
pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool {
|
||||
let node_id =
|
||||
self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id));
|
||||
self.rendered_frame
|
||||
@@ -1969,6 +1969,14 @@ impl Window {
|
||||
.is_action_available(action, node_id)
|
||||
}
|
||||
|
||||
/// Determine whether the given action is available along the dispatch path to the given focus_handle.
|
||||
pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool {
|
||||
let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id));
|
||||
self.rendered_frame
|
||||
.dispatch_tree
|
||||
.is_action_available(action, node_id)
|
||||
}
|
||||
|
||||
/// The position of the mouse relative to the window.
|
||||
pub fn mouse_position(&self) -> Point<Pixels> {
|
||||
self.mouse_position
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
|
||||
|
||||
@@ -1133,8 +1133,8 @@ fn check_interpolation(
|
||||
check_node_edits(
|
||||
depth,
|
||||
range,
|
||||
old_node.child(i).unwrap(),
|
||||
new_node.child(i).unwrap(),
|
||||
old_node.child(i as u32).unwrap(),
|
||||
new_node.child(i as u32).unwrap(),
|
||||
old_buffer,
|
||||
new_buffer,
|
||||
edits,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use anthropic::{
|
||||
ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, Event, ResponseContent,
|
||||
ToolResultContent, ToolResultPart, Usage,
|
||||
ANTHROPIC_API_URL, AnthropicError, AnthropicModelMode, ContentDelta, CountTokensRequest, Event,
|
||||
ResponseContent, ToolResultContent, ToolResultPart, Usage,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use collections::{BTreeMap, HashMap};
|
||||
@@ -219,68 +219,215 @@ pub struct AnthropicModel {
|
||||
request_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
pub fn count_anthropic_tokens(
|
||||
/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
|
||||
pub fn into_anthropic_count_tokens_request(
|
||||
request: LanguageModelRequest,
|
||||
cx: &App,
|
||||
) -> BoxFuture<'static, Result<u64>> {
|
||||
cx.background_spawn(async move {
|
||||
let messages = request.messages;
|
||||
let mut tokens_from_images = 0;
|
||||
let mut string_messages = Vec::with_capacity(messages.len());
|
||||
model: String,
|
||||
mode: AnthropicModelMode,
|
||||
) -> CountTokensRequest {
|
||||
let mut new_messages: Vec<anthropic::Message> = Vec::new();
|
||||
let mut system_message = String::new();
|
||||
|
||||
for message in messages {
|
||||
use language_model::MessageContent;
|
||||
for message in request.messages {
|
||||
if message.contents_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut string_contents = String::new();
|
||||
match message.role {
|
||||
Role::User | Role::Assistant => {
|
||||
let anthropic_message_content: Vec<anthropic::RequestContent> = message
|
||||
.content
|
||||
.into_iter()
|
||||
.filter_map(|content| match content {
|
||||
MessageContent::Text(text) => {
|
||||
let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
|
||||
text.trim_end().to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if !text.is_empty() {
|
||||
Some(anthropic::RequestContent::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking {
|
||||
text: thinking,
|
||||
signature,
|
||||
} => {
|
||||
if !thinking.is_empty() {
|
||||
Some(anthropic::RequestContent::Thinking {
|
||||
thinking,
|
||||
signature: signature.unwrap_or_default(),
|
||||
cache_control: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
MessageContent::RedactedThinking(data) => {
|
||||
if !data.is_empty() {
|
||||
Some(anthropic::RequestContent::RedactedThinking { data })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
|
||||
source: anthropic::ImageSource {
|
||||
source_type: "base64".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
data: image.source.to_string(),
|
||||
},
|
||||
cache_control: None,
|
||||
}),
|
||||
MessageContent::ToolUse(tool_use) => {
|
||||
Some(anthropic::RequestContent::ToolUse {
|
||||
id: tool_use.id.to_string(),
|
||||
name: tool_use.name.to_string(),
|
||||
input: tool_use.input,
|
||||
cache_control: None,
|
||||
})
|
||||
}
|
||||
MessageContent::ToolResult(tool_result) => {
|
||||
Some(anthropic::RequestContent::ToolResult {
|
||||
tool_use_id: tool_result.tool_use_id.to_string(),
|
||||
is_error: tool_result.is_error,
|
||||
content: match tool_result.content {
|
||||
LanguageModelToolResultContent::Text(text) => {
|
||||
ToolResultContent::Plain(text.to_string())
|
||||
}
|
||||
LanguageModelToolResultContent::Image(image) => {
|
||||
ToolResultContent::Multipart(vec![ToolResultPart::Image {
|
||||
source: anthropic::ImageSource {
|
||||
source_type: "base64".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
data: image.source.to_string(),
|
||||
},
|
||||
}])
|
||||
}
|
||||
},
|
||||
cache_control: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let anthropic_role = match message.role {
|
||||
Role::User => anthropic::Role::User,
|
||||
Role::Assistant => anthropic::Role::Assistant,
|
||||
Role::System => unreachable!("System role should never occur here"),
|
||||
};
|
||||
if let Some(last_message) = new_messages.last_mut()
|
||||
&& last_message.role == anthropic_role
|
||||
{
|
||||
last_message.content.extend(anthropic_message_content);
|
||||
continue;
|
||||
}
|
||||
|
||||
for content in message.content {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
string_contents.push_str(&text);
|
||||
new_messages.push(anthropic::Message {
|
||||
role: anthropic_role,
|
||||
content: anthropic_message_content,
|
||||
});
|
||||
}
|
||||
Role::System => {
|
||||
if !system_message.is_empty() {
|
||||
system_message.push_str("\n\n");
|
||||
}
|
||||
system_message.push_str(&message.string_contents());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CountTokensRequest {
|
||||
model,
|
||||
messages: new_messages,
|
||||
system: if system_message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(anthropic::StringOrContents::String(system_message))
|
||||
},
|
||||
thinking: if request.thinking_allowed
|
||||
&& let AnthropicModelMode::Thinking { budget_tokens } = mode
|
||||
{
|
||||
Some(anthropic::Thinking::Enabled { budget_tokens })
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tools: request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| anthropic::Tool {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
})
|
||||
.collect(),
|
||||
tool_choice: request.tool_choice.map(|choice| match choice {
|
||||
LanguageModelToolChoice::Auto => anthropic::ToolChoice::Auto,
|
||||
LanguageModelToolChoice::Any => anthropic::ToolChoice::Any,
|
||||
LanguageModelToolChoice::None => anthropic::ToolChoice::None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
|
||||
/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
|
||||
pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result<u64> {
|
||||
let messages = request.messages;
|
||||
let mut tokens_from_images = 0;
|
||||
let mut string_messages = Vec::with_capacity(messages.len());
|
||||
|
||||
for message in messages {
|
||||
let mut string_contents = String::new();
|
||||
|
||||
for content in message.content {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
string_contents.push_str(&text);
|
||||
}
|
||||
MessageContent::Thinking { .. } => {
|
||||
// Thinking blocks are not included in the input token count.
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// Thinking blocks are not included in the input token count.
|
||||
}
|
||||
MessageContent::Image(image) => {
|
||||
tokens_from_images += image.estimate_tokens();
|
||||
}
|
||||
MessageContent::ToolUse(_tool_use) => {
|
||||
// TODO: Estimate token usage from tool uses.
|
||||
}
|
||||
MessageContent::ToolResult(tool_result) => match &tool_result.content {
|
||||
LanguageModelToolResultContent::Text(text) => {
|
||||
string_contents.push_str(text);
|
||||
}
|
||||
MessageContent::Thinking { .. } => {
|
||||
// Thinking blocks are not included in the input token count.
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// Thinking blocks are not included in the input token count.
|
||||
}
|
||||
MessageContent::Image(image) => {
|
||||
LanguageModelToolResultContent::Image(image) => {
|
||||
tokens_from_images += image.estimate_tokens();
|
||||
}
|
||||
MessageContent::ToolUse(_tool_use) => {
|
||||
// TODO: Estimate token usage from tool uses.
|
||||
}
|
||||
MessageContent::ToolResult(tool_result) => match &tool_result.content {
|
||||
LanguageModelToolResultContent::Text(text) => {
|
||||
string_contents.push_str(text);
|
||||
}
|
||||
LanguageModelToolResultContent::Image(image) => {
|
||||
tokens_from_images += image.estimate_tokens();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if !string_contents.is_empty() {
|
||||
string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
|
||||
role: match message.role {
|
||||
Role::User => "user".into(),
|
||||
Role::Assistant => "assistant".into(),
|
||||
Role::System => "system".into(),
|
||||
},
|
||||
content: Some(string_contents),
|
||||
name: None,
|
||||
function_call: None,
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Tiktoken doesn't yet support these models, so we manually use the
|
||||
// same tokenizer as GPT-4.
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
|
||||
.map(|tokens| (tokens + tokens_from_images) as u64)
|
||||
})
|
||||
.boxed()
|
||||
if !string_contents.is_empty() {
|
||||
string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
|
||||
role: match message.role {
|
||||
Role::User => "user".into(),
|
||||
Role::Assistant => "assistant".into(),
|
||||
Role::System => "system".into(),
|
||||
},
|
||||
content: Some(string_contents),
|
||||
name: None,
|
||||
function_call: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tiktoken doesn't yet support these models, so we manually use the
|
||||
// same tokenizer as GPT-4.
|
||||
tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
|
||||
.map(|tokens| (tokens + tokens_from_images) as u64)
|
||||
}
|
||||
|
||||
impl AnthropicModel {
|
||||
@@ -386,7 +533,40 @@ impl LanguageModel for AnthropicModel {
|
||||
request: LanguageModelRequest,
|
||||
cx: &App,
|
||||
) -> BoxFuture<'static, Result<u64>> {
|
||||
count_anthropic_tokens(request, cx)
|
||||
let http_client = self.http_client.clone();
|
||||
let model_id = self.model.request_id().to_string();
|
||||
let mode = self.model.mode();
|
||||
|
||||
let (api_key, api_url) = self.state.read_with(cx, |state, cx| {
|
||||
let api_url = AnthropicLanguageModelProvider::api_url(cx);
|
||||
(
|
||||
state.api_key_state.key(&api_url).map(|k| k.to_string()),
|
||||
api_url.to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
async move {
|
||||
// If no API key, fall back to tiktoken estimation
|
||||
let Some(api_key) = api_key else {
|
||||
return count_anthropic_tokens_with_tiktoken(request);
|
||||
};
|
||||
|
||||
let count_request =
|
||||
into_anthropic_count_tokens_request(request.clone(), model_id, mode);
|
||||
|
||||
match anthropic::count_tokens(http_client.as_ref(), &api_url, &api_key, count_request)
|
||||
.await
|
||||
{
|
||||
Ok(response) => Ok(response.input_tokens),
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"Anthropic count_tokens API failed, falling back to tiktoken: {err:?}"
|
||||
);
|
||||
count_anthropic_tokens_with_tiktoken(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stream_completion(
|
||||
|
||||
@@ -42,7 +42,9 @@ use thiserror::Error;
|
||||
use ui::{TintColor, prelude::*};
|
||||
use util::{ResultExt as _, maybe};
|
||||
|
||||
use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic};
|
||||
use crate::provider::anthropic::{
|
||||
AnthropicEventMapper, count_anthropic_tokens_with_tiktoken, into_anthropic,
|
||||
};
|
||||
use crate::provider::google::{GoogleEventMapper, into_google};
|
||||
use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
|
||||
use crate::provider::x_ai::count_xai_tokens;
|
||||
@@ -667,9 +669,9 @@ impl LanguageModel for CloudLanguageModel {
|
||||
cx: &App,
|
||||
) -> BoxFuture<'static, Result<u64>> {
|
||||
match self.model.provider {
|
||||
cloud_llm_client::LanguageModelProvider::Anthropic => {
|
||||
count_anthropic_tokens(request, cx)
|
||||
}
|
||||
cloud_llm_client::LanguageModelProvider::Anthropic => cx
|
||||
.background_spawn(async move { count_anthropic_tokens_with_tiktoken(request) })
|
||||
.boxed(),
|
||||
cloud_llm_client::LanguageModelProvider::OpenAi => {
|
||||
let model = match open_ai::Model::from_id(&self.model.id.0) {
|
||||
Ok(model) => model,
|
||||
|
||||
@@ -9,6 +9,8 @@ use serde::Deserialize;
|
||||
use smol::io::BufReader;
|
||||
use smol::{fs, lock::Mutex};
|
||||
use std::fmt::Display;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::{
|
||||
env::{self, consts},
|
||||
ffi::OsString,
|
||||
@@ -46,6 +48,7 @@ struct NodeRuntimeState {
|
||||
last_options: Option<NodeBinaryOptions>,
|
||||
options: watch::Receiver<Option<NodeBinaryOptions>>,
|
||||
shell_env_loaded: Shared<oneshot::Receiver<()>>,
|
||||
trust_task: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
|
||||
}
|
||||
|
||||
impl NodeRuntime {
|
||||
@@ -53,9 +56,11 @@ impl NodeRuntime {
|
||||
http: Arc<dyn HttpClient>,
|
||||
shell_env_loaded: Option<oneshot::Receiver<()>>,
|
||||
options: watch::Receiver<Option<NodeBinaryOptions>>,
|
||||
trust_task: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
|
||||
) -> Self {
|
||||
NodeRuntime(Arc::new(Mutex::new(NodeRuntimeState {
|
||||
http,
|
||||
trust_task,
|
||||
instance: None,
|
||||
last_options: None,
|
||||
options,
|
||||
@@ -70,11 +75,15 @@ impl NodeRuntime {
|
||||
last_options: None,
|
||||
options: watch::channel(Some(NodeBinaryOptions::default())).1,
|
||||
shell_env_loaded: oneshot::channel().1.shared(),
|
||||
trust_task: None,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn instance(&self) -> Box<dyn NodeRuntimeTrait> {
|
||||
let mut state = self.0.lock().await;
|
||||
if let Some(trust_task) = state.trust_task.take() {
|
||||
trust_task.await;
|
||||
}
|
||||
|
||||
let options = loop {
|
||||
if let Some(options) = state.options.borrow().as_ref() {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use client::TelemetrySettings;
|
||||
use fs::Fs;
|
||||
use gpui::{Action, App, IntoElement};
|
||||
use project::project_settings::ProjectSettings;
|
||||
use settings::{BaseKeymap, Settings, update_settings_file};
|
||||
use theme::{
|
||||
Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection,
|
||||
@@ -10,8 +11,8 @@ use theme::{
|
||||
};
|
||||
use ui::{
|
||||
Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
|
||||
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, prelude::*,
|
||||
rems_from_px,
|
||||
ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
|
||||
prelude::*, rems_from_px,
|
||||
};
|
||||
use vim_mode_setting::VimModeSetting;
|
||||
|
||||
@@ -409,6 +410,48 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
|
||||
})
|
||||
}
|
||||
|
||||
fn render_worktree_auto_trust_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement {
|
||||
let toggle_state = if ProjectSettings::get_global(cx).session.trust_all_worktrees {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
ui::ToggleState::Unselected
|
||||
};
|
||||
|
||||
let tooltip_description = "Zed can only allow services like language servers, project settings, and MCP servers to run after you mark a new project as trusted.";
|
||||
|
||||
SwitchField::new(
|
||||
"onboarding-auto-trust-worktrees",
|
||||
Some("Trust All Projects By Default"),
|
||||
Some("Automatically mark all new projects as trusted to unlock all Zed's features".into()),
|
||||
toggle_state,
|
||||
{
|
||||
let fs = <dyn Fs>::global(cx);
|
||||
move |&selection, _, cx| {
|
||||
let trust = match selection {
|
||||
ToggleState::Selected => true,
|
||||
ToggleState::Unselected => false,
|
||||
ToggleState::Indeterminate => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
update_settings_file(fs.clone(), cx, move |setting, _| {
|
||||
setting.session.get_or_insert_default().trust_all_worktrees = Some(trust);
|
||||
});
|
||||
|
||||
telemetry::event!(
|
||||
"Welcome Page Worktree Auto Trust Toggled",
|
||||
options = if trust { "on" } else { "off" }
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.tab_index({
|
||||
*tab_index += 1;
|
||||
*tab_index - 1
|
||||
})
|
||||
.tooltip(Tooltip::text(tooltip_description))
|
||||
}
|
||||
|
||||
fn render_setting_import_button(
|
||||
tab_index: isize,
|
||||
label: SharedString,
|
||||
@@ -481,6 +524,7 @@ pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement {
|
||||
.child(render_base_keymap_section(&mut tab_index, cx))
|
||||
.child(render_import_settings_section(&mut tab_index, cx))
|
||||
.child(render_vim_mode_switch(&mut tab_index, cx))
|
||||
.child(render_worktree_auto_trust_switch(&mut tab_index, cx))
|
||||
.child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
|
||||
.child(render_telemetry_section(&mut tab_index, cx))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ clock.workspace = true
|
||||
collections.workspace = true
|
||||
context_server.workspace = true
|
||||
dap.workspace = true
|
||||
db.workspace = true
|
||||
extension.workspace = true
|
||||
fancy-regex.workspace = true
|
||||
fs.workspace = true
|
||||
@@ -96,6 +97,7 @@ tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
db = { workspace = true, features = ["test-support"] }
|
||||
collections = { workspace = true, features = ["test-support"] }
|
||||
context_server = { workspace = true, features = ["test-support"] }
|
||||
buffer_diff = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -15,6 +15,7 @@ use util::{ResultExt as _, rel_path::RelPath};
|
||||
use crate::{
|
||||
Project,
|
||||
project_settings::{ContextServerSettings, ProjectSettings},
|
||||
trusted_worktrees::wait_for_workspace_trust,
|
||||
worktree_store::WorktreeStore,
|
||||
};
|
||||
|
||||
@@ -332,6 +333,15 @@ impl ContextServerStore {
|
||||
|
||||
pub fn start_server(&mut self, server: Arc<ContextServer>, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
let wait_task = this.update(cx, |context_server_store, cx| {
|
||||
context_server_store.project.update(cx, |project, cx| {
|
||||
let remote_host = project.remote_connection_options(cx);
|
||||
wait_for_workspace_trust(remote_host, "context servers", cx)
|
||||
})
|
||||
})??;
|
||||
if let Some(wait_task) = wait_task {
|
||||
wait_task.await;
|
||||
}
|
||||
let this = this.upgrade().context("Context server store dropped")?;
|
||||
let settings = this
|
||||
.update(cx, |this, _| {
|
||||
@@ -572,6 +582,15 @@ impl ContextServerStore {
|
||||
}
|
||||
|
||||
async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
|
||||
let wait_task = this.update(cx, |context_server_store, cx| {
|
||||
context_server_store.project.update(cx, |project, cx| {
|
||||
let remote_host = project.remote_connection_options(cx);
|
||||
wait_for_workspace_trust(remote_host, "context servers", cx)
|
||||
})
|
||||
})??;
|
||||
if let Some(wait_task) = wait_task {
|
||||
wait_task.await;
|
||||
}
|
||||
let (mut configured_servers, registry, worktree_store) = this.update(cx, |this, _| {
|
||||
(
|
||||
this.context_server_settings.clone(),
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::{
|
||||
prettier_store::{self, PrettierStore, PrettierStoreEvent},
|
||||
project_settings::{LspSettings, ProjectSettings},
|
||||
toolchain_store::{LocalToolchainStore, ToolchainStoreEvent},
|
||||
trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
|
||||
worktree_store::{WorktreeStore, WorktreeStoreEvent},
|
||||
yarn::YarnPathStore,
|
||||
};
|
||||
@@ -54,8 +55,8 @@ use futures::{
|
||||
};
|
||||
use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString, Task,
|
||||
WeakEntity,
|
||||
App, AppContext, AsyncApp, Context, Entity, EventEmitter, PromptLevel, SharedString,
|
||||
Subscription, Task, WeakEntity,
|
||||
};
|
||||
use http_client::HttpClient;
|
||||
use itertools::Itertools as _;
|
||||
@@ -96,13 +97,14 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use settings::{Settings, SettingsLocation, SettingsStore};
|
||||
use sha2::{Digest, Sha256};
|
||||
use smol::channel::Sender;
|
||||
use smol::channel::{Receiver, Sender};
|
||||
use snippet::Snippet;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
cmp::{Ordering, Reverse},
|
||||
collections::hash_map,
|
||||
convert::TryInto,
|
||||
ffi::OsStr,
|
||||
future::ready,
|
||||
@@ -296,6 +298,7 @@ pub struct LocalLspStore {
|
||||
LanguageServerId,
|
||||
HashMap<Option<SharedString>, HashMap<PathBuf, Option<SharedString>>>,
|
||||
>,
|
||||
restricted_worktrees_tasks: HashMap<WorktreeId, (Subscription, Receiver<()>)>,
|
||||
}
|
||||
|
||||
impl LocalLspStore {
|
||||
@@ -367,7 +370,8 @@ impl LocalLspStore {
|
||||
) -> LanguageServerId {
|
||||
let worktree = worktree_handle.read(cx);
|
||||
|
||||
let root_path = worktree.abs_path();
|
||||
let worktree_id = worktree.id();
|
||||
let worktree_abs_path = worktree.abs_path();
|
||||
let toolchain = key.toolchain.clone();
|
||||
let override_options = settings.initialization_options.clone();
|
||||
|
||||
@@ -375,19 +379,49 @@ impl LocalLspStore {
|
||||
|
||||
let server_id = self.languages.next_language_server_id();
|
||||
log::trace!(
|
||||
"attempting to start language server {:?}, path: {root_path:?}, id: {server_id}",
|
||||
"attempting to start language server {:?}, path: {worktree_abs_path:?}, id: {server_id}",
|
||||
adapter.name.0
|
||||
);
|
||||
|
||||
let untrusted_worktree_task =
|
||||
TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
|
||||
let can_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust(worktree_id, cx)
|
||||
});
|
||||
if can_trust {
|
||||
self.restricted_worktrees_tasks.remove(&worktree_id);
|
||||
None
|
||||
} else {
|
||||
match self.restricted_worktrees_tasks.entry(worktree_id) {
|
||||
hash_map::Entry::Occupied(o) => Some(o.get().1.clone()),
|
||||
hash_map::Entry::Vacant(v) => {
|
||||
let (tx, rx) = smol::channel::bounded::<()>(1);
|
||||
let subscription = cx.subscribe(&trusted_worktrees, move |_, e, _| {
|
||||
if let TrustedWorktreesEvent::Trusted(_, trusted_paths) = e {
|
||||
if trusted_paths.contains(&PathTrust::Worktree(worktree_id)) {
|
||||
tx.send_blocking(()).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
v.insert((subscription, rx.clone()));
|
||||
Some(rx)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let update_binary_status = untrusted_worktree_task.is_none();
|
||||
|
||||
let binary = self.get_language_server_binary(
|
||||
worktree_abs_path.clone(),
|
||||
adapter.clone(),
|
||||
settings,
|
||||
toolchain.clone(),
|
||||
delegate.clone(),
|
||||
true,
|
||||
untrusted_worktree_task,
|
||||
cx,
|
||||
);
|
||||
let pending_workspace_folders: Arc<Mutex<BTreeSet<Uri>>> = Default::default();
|
||||
let pending_workspace_folders = Arc::<Mutex<BTreeSet<Uri>>>::default();
|
||||
|
||||
let pending_server = cx.spawn({
|
||||
let adapter = adapter.clone();
|
||||
@@ -420,7 +454,7 @@ impl LocalLspStore {
|
||||
server_id,
|
||||
server_name,
|
||||
binary,
|
||||
&root_path,
|
||||
&worktree_abs_path,
|
||||
code_action_kinds,
|
||||
Some(pending_workspace_folders),
|
||||
cx,
|
||||
@@ -556,8 +590,10 @@ impl LocalLspStore {
|
||||
pending_workspace_folders,
|
||||
};
|
||||
|
||||
self.languages
|
||||
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
|
||||
if update_binary_status {
|
||||
self.languages
|
||||
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
|
||||
}
|
||||
|
||||
self.language_servers.insert(server_id, state);
|
||||
self.language_server_ids
|
||||
@@ -571,19 +607,34 @@ impl LocalLspStore {
|
||||
|
||||
fn get_language_server_binary(
|
||||
&self,
|
||||
worktree_abs_path: Arc<Path>,
|
||||
adapter: Arc<CachedLspAdapter>,
|
||||
settings: Arc<LspSettings>,
|
||||
toolchain: Option<Toolchain>,
|
||||
delegate: Arc<dyn LspAdapterDelegate>,
|
||||
allow_binary_download: bool,
|
||||
untrusted_worktree_task: Option<Receiver<()>>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<LanguageServerBinary>> {
|
||||
if let Some(settings) = &settings.binary
|
||||
&& let Some(path) = settings.path.as_ref().map(PathBuf::from)
|
||||
{
|
||||
let settings = settings.clone();
|
||||
|
||||
let languages = self.languages.clone();
|
||||
return cx.background_spawn(async move {
|
||||
if let Some(untrusted_worktree_task) = untrusted_worktree_task {
|
||||
log::info!(
|
||||
"Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
|
||||
adapter.name(),
|
||||
);
|
||||
untrusted_worktree_task.recv().await.ok();
|
||||
log::info!(
|
||||
"Worktree {worktree_abs_path:?} is trusted, starting language server {}",
|
||||
adapter.name(),
|
||||
);
|
||||
languages
|
||||
.update_lsp_binary_status(adapter.name(), BinaryStatus::Starting);
|
||||
}
|
||||
let mut env = delegate.shell_env().await;
|
||||
env.extend(settings.env.unwrap_or_default());
|
||||
|
||||
@@ -614,6 +665,18 @@ impl LocalLspStore {
|
||||
};
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Some(untrusted_worktree_task) = untrusted_worktree_task {
|
||||
log::info!(
|
||||
"Waiting for worktree {worktree_abs_path:?} to be trusted, before starting language server {}",
|
||||
adapter.name(),
|
||||
);
|
||||
untrusted_worktree_task.recv().await.ok();
|
||||
log::info!(
|
||||
"Worktree {worktree_abs_path:?} is trusted, starting language server {}",
|
||||
adapter.name(),
|
||||
);
|
||||
}
|
||||
|
||||
let (existing_binary, maybe_download_binary) = adapter
|
||||
.clone()
|
||||
.get_language_server_command(delegate.clone(), toolchain, lsp_binary_options, cx)
|
||||
@@ -3258,6 +3321,7 @@ impl LocalLspStore {
|
||||
id_to_remove: WorktreeId,
|
||||
cx: &mut Context<LspStore>,
|
||||
) -> Vec<LanguageServerId> {
|
||||
self.restricted_worktrees_tasks.remove(&id_to_remove);
|
||||
self.diagnostics.remove(&id_to_remove);
|
||||
self.prettier_store.update(cx, |prettier_store, cx| {
|
||||
prettier_store.remove_worktree(id_to_remove, cx);
|
||||
@@ -3974,6 +4038,7 @@ impl LspStore {
|
||||
buffers_opened_in_servers: HashMap::default(),
|
||||
buffer_pull_diagnostics_result_ids: HashMap::default(),
|
||||
workspace_pull_diagnostics_result_ids: HashMap::default(),
|
||||
restricted_worktrees_tasks: HashMap::default(),
|
||||
watched_manifest_filenames: ManifestProvidersStore::global(cx)
|
||||
.manifest_file_names(),
|
||||
}),
|
||||
|
||||
411
crates/project/src/persistence.rs
Normal file
411
crates/project/src/persistence.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
use collections::{HashMap, HashSet};
|
||||
use gpui::{App, Entity, SharedString};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use db::{
|
||||
query,
|
||||
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
|
||||
sqlez_macros::sql,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store},
|
||||
worktree_store::WorktreeStore,
|
||||
};
|
||||
|
||||
// https://www.sqlite.org/limits.html
|
||||
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
|
||||
// > which defaults to <..> 32766 for SQLite versions after 3.32.0.
|
||||
#[allow(unused)]
|
||||
const MAX_QUERY_PLACEHOLDERS: usize = 32000;
|
||||
|
||||
#[allow(unused)]
|
||||
pub struct ProjectDb(ThreadSafeConnection);
|
||||
|
||||
impl Domain for ProjectDb {
|
||||
const NAME: &str = stringify!(ProjectDb);
|
||||
|
||||
const MIGRATIONS: &[&str] = &[sql!(
|
||||
CREATE TABLE IF NOT EXISTS trusted_worktrees (
|
||||
trust_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
absolute_path TEXT,
|
||||
user_name TEXT,
|
||||
host_name TEXT
|
||||
) STRICT;
|
||||
)];
|
||||
}
|
||||
|
||||
db::static_connection!(PROJECT_DB, ProjectDb, []);
|
||||
|
||||
impl ProjectDb {
|
||||
pub(crate) async fn save_trusted_worktrees(
|
||||
&self,
|
||||
trusted_worktrees: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>>,
|
||||
trusted_workspaces: HashSet<Option<RemoteHostLocation>>,
|
||||
) -> anyhow::Result<()> {
|
||||
use anyhow::Context as _;
|
||||
use db::sqlez::statement::Statement;
|
||||
use itertools::Itertools as _;
|
||||
|
||||
PROJECT_DB
|
||||
.clear_trusted_worktrees()
|
||||
.await
|
||||
.context("clearing previous trust state")?;
|
||||
|
||||
let trusted_worktrees = trusted_worktrees
|
||||
.into_iter()
|
||||
.flat_map(|(host, abs_paths)| {
|
||||
abs_paths
|
||||
.into_iter()
|
||||
.map(move |abs_path| (Some(abs_path), host.clone()))
|
||||
})
|
||||
.chain(trusted_workspaces.into_iter().map(|host| (None, host)))
|
||||
.collect::<Vec<_>>();
|
||||
let mut first_worktree;
|
||||
let mut last_worktree = 0_usize;
|
||||
for (count, placeholders) in std::iter::once("(?, ?, ?)")
|
||||
.cycle()
|
||||
.take(trusted_worktrees.len())
|
||||
.chunks(MAX_QUERY_PLACEHOLDERS / 3)
|
||||
.into_iter()
|
||||
.map(|chunk| {
|
||||
let mut count = 0;
|
||||
let placeholders = chunk
|
||||
.inspect(|_| {
|
||||
count += 1;
|
||||
})
|
||||
.join(", ");
|
||||
(count, placeholders)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
first_worktree = last_worktree;
|
||||
last_worktree = last_worktree + count;
|
||||
let query = format!(
|
||||
r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name)
|
||||
VALUES {placeholders};"#
|
||||
);
|
||||
|
||||
let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec();
|
||||
self.write(move |conn| {
|
||||
let mut statement = Statement::prepare(conn, query)?;
|
||||
let mut next_index = 1;
|
||||
for (abs_path, host) in trusted_worktrees {
|
||||
let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy());
|
||||
next_index = statement.bind(
|
||||
&abs_path.as_ref().map(|abs_path| abs_path.as_ref()),
|
||||
next_index,
|
||||
)?;
|
||||
next_index = statement.bind(
|
||||
&host
|
||||
.as_ref()
|
||||
.and_then(|host| Some(host.user_name.as_ref()?.as_str())),
|
||||
next_index,
|
||||
)?;
|
||||
next_index = statement.bind(
|
||||
&host.as_ref().map(|host| host.host_identifier.as_str()),
|
||||
next_index,
|
||||
)?;
|
||||
}
|
||||
statement.exec()
|
||||
})
|
||||
.await
|
||||
.context("inserting new trusted state")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_trusted_worktrees(
|
||||
&self,
|
||||
worktree_store: Option<Entity<WorktreeStore>>,
|
||||
host: Option<RemoteHostLocation>,
|
||||
cx: &App,
|
||||
) -> anyhow::Result<HashMap<Option<RemoteHostLocation>, HashSet<PathTrust>>> {
|
||||
let trusted_worktrees = PROJECT_DB.trusted_worktrees()?;
|
||||
Ok(trusted_worktrees
|
||||
.into_iter()
|
||||
.map(|(abs_path, user_name, host_name)| {
|
||||
let db_host = match (user_name, host_name) {
|
||||
(_, None) => None,
|
||||
(None, Some(host_name)) => Some(RemoteHostLocation {
|
||||
user_name: None,
|
||||
host_identifier: SharedString::new(host_name),
|
||||
}),
|
||||
(Some(user_name), Some(host_name)) => Some(RemoteHostLocation {
|
||||
user_name: Some(SharedString::new(user_name)),
|
||||
host_identifier: SharedString::new(host_name),
|
||||
}),
|
||||
};
|
||||
|
||||
match abs_path {
|
||||
Some(abs_path) => {
|
||||
if db_host != host {
|
||||
(db_host, PathTrust::AbsPath(abs_path))
|
||||
} else if let Some(worktree_store) = &worktree_store {
|
||||
find_worktree_in_store(worktree_store.read(cx), &abs_path, cx)
|
||||
.map(PathTrust::Worktree)
|
||||
.map(|trusted_worktree| (host.clone(), trusted_worktree))
|
||||
.unwrap_or_else(|| (db_host.clone(), PathTrust::AbsPath(abs_path)))
|
||||
} else {
|
||||
(db_host, PathTrust::AbsPath(abs_path))
|
||||
}
|
||||
}
|
||||
None => (db_host, PathTrust::Workspace),
|
||||
}
|
||||
})
|
||||
.fold(HashMap::default(), |mut acc, (remote_host, path_trust)| {
|
||||
acc.entry(remote_host)
|
||||
.or_insert_with(HashSet::default)
|
||||
.insert(path_trust);
|
||||
acc
|
||||
}))
|
||||
}
|
||||
|
||||
query! {
|
||||
fn trusted_worktrees() -> Result<Vec<(Option<PathBuf>, Option<String>, Option<String>)>> {
|
||||
SELECT absolute_path, user_name, host_name
|
||||
FROM trusted_worktrees
|
||||
}
|
||||
}
|
||||
|
||||
query! {
|
||||
pub async fn clear_trusted_worktrees() -> Result<()> {
|
||||
DELETE FROM trusted_worktrees
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use collections::{HashMap, HashSet};
|
||||
use gpui::{SharedString, TestAppContext};
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use smol::lock::Mutex;
|
||||
use util::path;
|
||||
|
||||
use crate::{
|
||||
FakeFs, Project,
|
||||
persistence::PROJECT_DB,
|
||||
trusted_worktrees::{PathTrust, RemoteHostLocation},
|
||||
};
|
||||
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_and_fetch_trusted_worktrees(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
let _guard = TEST_LOCK.lock().await;
|
||||
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
|
||||
cx.update(|cx| {
|
||||
if cx.try_global::<SettingsStore>().is_none() {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
}
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/"),
|
||||
json!({
|
||||
"project_a": { "main.rs": "" },
|
||||
"project_b": { "lib.rs": "" }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(
|
||||
fs,
|
||||
[path!("/project_a").as_ref(), path!("/project_b").as_ref()],
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
|
||||
|
||||
let mut trusted_paths: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> =
|
||||
HashMap::default();
|
||||
trusted_paths.insert(
|
||||
None,
|
||||
HashSet::from_iter([
|
||||
PathBuf::from(path!("/project_a")),
|
||||
PathBuf::from(path!("/project_b")),
|
||||
]),
|
||||
);
|
||||
|
||||
PROJECT_DB
|
||||
.save_trusted_worktrees(trusted_paths, HashSet::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = cx.update(|cx| {
|
||||
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
|
||||
});
|
||||
let fetched = fetched.unwrap();
|
||||
|
||||
let local_trust = fetched.get(&None).expect("should have local host entry");
|
||||
assert_eq!(local_trust.len(), 2);
|
||||
assert!(
|
||||
local_trust
|
||||
.iter()
|
||||
.all(|p| matches!(p, PathTrust::Worktree(_)))
|
||||
);
|
||||
|
||||
let fetched_no_store = cx
|
||||
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
|
||||
.unwrap();
|
||||
let local_trust_no_store = fetched_no_store
|
||||
.get(&None)
|
||||
.expect("should have local host entry");
|
||||
assert_eq!(local_trust_no_store.len(), 2);
|
||||
assert!(
|
||||
local_trust_no_store
|
||||
.iter()
|
||||
.all(|p| matches!(p, PathTrust::AbsPath(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_and_fetch_workspace_trust(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
let _guard = TEST_LOCK.lock().await;
|
||||
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
|
||||
cx.update(|cx| {
|
||||
if cx.try_global::<SettingsStore>().is_none() {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
}
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
||||
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
|
||||
|
||||
let trusted_workspaces = HashSet::from_iter([None]);
|
||||
PROJECT_DB
|
||||
.save_trusted_worktrees(HashMap::default(), trusted_workspaces)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = cx.update(|cx| {
|
||||
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
|
||||
});
|
||||
let fetched = fetched.unwrap();
|
||||
|
||||
let local_trust = fetched.get(&None).expect("should have local host entry");
|
||||
assert!(local_trust.contains(&PathTrust::Workspace));
|
||||
|
||||
let fetched_no_store = cx
|
||||
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
|
||||
.unwrap();
|
||||
let local_trust_no_store = fetched_no_store
|
||||
.get(&None)
|
||||
.expect("should have local host entry");
|
||||
assert!(local_trust_no_store.contains(&PathTrust::Workspace));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_save_and_fetch_remote_host_trust(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
let _guard = TEST_LOCK.lock().await;
|
||||
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
|
||||
cx.update(|cx| {
|
||||
if cx.try_global::<SettingsStore>().is_none() {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
}
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
||||
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
|
||||
|
||||
let remote_host = Some(RemoteHostLocation {
|
||||
user_name: Some(SharedString::from("testuser")),
|
||||
host_identifier: SharedString::from("remote.example.com"),
|
||||
});
|
||||
|
||||
let mut trusted_paths: HashMap<Option<RemoteHostLocation>, HashSet<PathBuf>> =
|
||||
HashMap::default();
|
||||
trusted_paths.insert(
|
||||
remote_host.clone(),
|
||||
HashSet::from_iter([PathBuf::from("/home/testuser/project")]),
|
||||
);
|
||||
|
||||
PROJECT_DB
|
||||
.save_trusted_worktrees(trusted_paths, HashSet::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = cx.update(|cx| {
|
||||
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
|
||||
});
|
||||
let fetched = fetched.unwrap();
|
||||
|
||||
let remote_trust = fetched
|
||||
.get(&remote_host)
|
||||
.expect("should have remote host entry");
|
||||
assert_eq!(remote_trust.len(), 1);
|
||||
assert!(remote_trust
|
||||
.iter()
|
||||
.any(|p| matches!(p, PathTrust::AbsPath(path) if path == &PathBuf::from("/home/testuser/project"))));
|
||||
|
||||
let fetched_no_store = cx
|
||||
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
|
||||
.unwrap();
|
||||
let remote_trust_no_store = fetched_no_store
|
||||
.get(&remote_host)
|
||||
.expect("should have remote host entry");
|
||||
assert_eq!(remote_trust_no_store.len(), 1);
|
||||
assert!(remote_trust_no_store
|
||||
.iter()
|
||||
.any(|p| matches!(p, PathTrust::AbsPath(path) if path == &PathBuf::from("/home/testuser/project"))));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_clear_trusted_worktrees(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
let _guard = TEST_LOCK.lock().await;
|
||||
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
|
||||
cx.update(|cx| {
|
||||
if cx.try_global::<SettingsStore>().is_none() {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
}
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/root"), json!({ "main.rs": "" }))
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
|
||||
let worktree_store = project.read_with(cx, |p, _| p.worktree_store());
|
||||
|
||||
let trusted_workspaces = HashSet::from_iter([None]);
|
||||
PROJECT_DB
|
||||
.save_trusted_worktrees(HashMap::default(), trusted_workspaces)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
PROJECT_DB.clear_trusted_worktrees().await.unwrap();
|
||||
|
||||
let fetched = cx.update(|cx| {
|
||||
PROJECT_DB.fetch_trusted_worktrees(Some(worktree_store.clone()), None, cx)
|
||||
});
|
||||
let fetched = fetched.unwrap();
|
||||
|
||||
assert!(fetched.is_empty(), "should be empty after clear");
|
||||
|
||||
let fetched_no_store = cx
|
||||
.update(|cx| PROJECT_DB.fetch_trusted_worktrees(None, None, cx))
|
||||
.unwrap();
|
||||
assert!(fetched_no_store.is_empty(), "should be empty after clear");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod image_store;
|
||||
pub mod lsp_command;
|
||||
pub mod lsp_store;
|
||||
mod manifest_tree;
|
||||
mod persistence;
|
||||
pub mod prettier_store;
|
||||
mod project_search;
|
||||
pub mod project_settings;
|
||||
@@ -19,6 +20,7 @@ pub mod task_store;
|
||||
pub mod telemetry_snapshot;
|
||||
pub mod terminals;
|
||||
pub mod toolchain_store;
|
||||
pub mod trusted_worktrees;
|
||||
pub mod worktree_store;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -39,6 +41,7 @@ use crate::{
|
||||
git_store::GitStore,
|
||||
lsp_store::{SymbolLocation, log_store::LogKind},
|
||||
project_search::SearchResultsHandle,
|
||||
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
|
||||
};
|
||||
pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName};
|
||||
pub use git_store::{
|
||||
@@ -1069,6 +1072,7 @@ impl Project {
|
||||
languages: Arc<LanguageRegistry>,
|
||||
fs: Arc<dyn Fs>,
|
||||
env: Option<HashMap<String, String>>,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx: &mut Context<Self>| {
|
||||
@@ -1077,6 +1081,15 @@ impl Project {
|
||||
.detach();
|
||||
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
|
||||
let worktree_store = cx.new(|_| WorktreeStore::local(false, fs.clone()));
|
||||
if init_worktree_trust {
|
||||
trusted_worktrees::track_worktree_trust(
|
||||
worktree_store.clone(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
|
||||
.detach();
|
||||
|
||||
@@ -1250,6 +1263,7 @@ impl Project {
|
||||
user_store: Entity<UserStore>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
fs: Arc<dyn Fs>,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
cx.new(|cx: &mut Context<Self>| {
|
||||
@@ -1258,8 +1272,14 @@ impl Project {
|
||||
.detach();
|
||||
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
|
||||
|
||||
let (remote_proto, path_style) =
|
||||
remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
|
||||
let (remote_proto, path_style, connection_options) =
|
||||
remote.read_with(cx, |remote, _| {
|
||||
(
|
||||
remote.proto_client(),
|
||||
remote.path_style(),
|
||||
remote.connection_options(),
|
||||
)
|
||||
});
|
||||
let worktree_store = cx.new(|_| {
|
||||
WorktreeStore::remote(
|
||||
false,
|
||||
@@ -1268,8 +1288,23 @@ impl Project {
|
||||
path_style,
|
||||
)
|
||||
});
|
||||
|
||||
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
|
||||
.detach();
|
||||
if init_worktree_trust {
|
||||
match &connection_options {
|
||||
RemoteConnectionOptions::Wsl(..) | RemoteConnectionOptions::Ssh(..) => {
|
||||
trusted_worktrees::track_worktree_trust(
|
||||
worktree_store.clone(),
|
||||
Some(RemoteHostLocation::from(connection_options)),
|
||||
None,
|
||||
Some((remote_proto.clone(), REMOTE_SERVER_PROJECT_ID)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
RemoteConnectionOptions::Docker(..) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let weak_self = cx.weak_entity();
|
||||
let context_server_store =
|
||||
@@ -1450,6 +1485,9 @@ impl Project {
|
||||
remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
|
||||
remote_proto.add_entity_message_handler(Self::handle_hide_toast);
|
||||
remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
|
||||
remote_proto.add_entity_request_handler(Self::handle_trust_worktrees);
|
||||
remote_proto.add_entity_request_handler(Self::handle_restrict_worktrees);
|
||||
|
||||
BufferStore::init(&remote_proto);
|
||||
LspStore::init(&remote_proto);
|
||||
SettingsObserver::init(&remote_proto);
|
||||
@@ -1810,6 +1848,7 @@ impl Project {
|
||||
Arc::new(languages),
|
||||
fs,
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
@@ -1834,6 +1873,25 @@ impl Project {
|
||||
fs: Arc<dyn Fs>,
|
||||
root_paths: impl IntoIterator<Item = &Path>,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) -> Entity<Project> {
|
||||
Self::test_project(fs, root_paths, false, cx).await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub async fn test_with_worktree_trust(
|
||||
fs: Arc<dyn Fs>,
|
||||
root_paths: impl IntoIterator<Item = &Path>,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) -> Entity<Project> {
|
||||
Self::test_project(fs, root_paths, true, cx).await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
async fn test_project(
|
||||
fs: Arc<dyn Fs>,
|
||||
root_paths: impl IntoIterator<Item = &Path>,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) -> Entity<Project> {
|
||||
use clock::FakeSystemClock;
|
||||
|
||||
@@ -1850,6 +1908,7 @@ impl Project {
|
||||
Arc::new(languages),
|
||||
fs,
|
||||
None,
|
||||
init_worktree_trust,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -4757,9 +4816,14 @@ impl Project {
|
||||
envelope: TypedEnvelope<proto::UpdateWorktree>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.update(&mut cx, |project, cx| {
|
||||
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
|
||||
if let Some(worktree) = this.worktree_for_id(worktree_id, cx) {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust(worktree_id, cx)
|
||||
});
|
||||
}
|
||||
if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
|
||||
worktree.update(cx, |worktree, _| {
|
||||
let worktree = worktree.as_remote_mut().unwrap();
|
||||
worktree.update_from_remote(envelope.payload);
|
||||
@@ -4786,6 +4850,61 @@ impl Project {
|
||||
BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
|
||||
}
|
||||
|
||||
async fn handle_trust_worktrees(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::TrustWorktrees>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let trusted_worktrees = cx
|
||||
.update(|cx| TrustedWorktrees::try_get_global(cx))?
|
||||
.context("missing trusted worktrees")?;
|
||||
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
|
||||
let remote_host = this
|
||||
.read(cx)
|
||||
.remote_connection_options(cx)
|
||||
.map(RemoteHostLocation::from);
|
||||
trusted_worktrees.trust(
|
||||
envelope
|
||||
.payload
|
||||
.trusted_paths
|
||||
.into_iter()
|
||||
.filter_map(|proto_path| PathTrust::from_proto(proto_path))
|
||||
.collect(),
|
||||
remote_host,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
async fn handle_restrict_worktrees(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::RestrictWorktrees>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let trusted_worktrees = cx
|
||||
.update(|cx| TrustedWorktrees::try_get_global(cx))?
|
||||
.context("missing trusted worktrees")?;
|
||||
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
|
||||
let mut restricted_paths = envelope
|
||||
.payload
|
||||
.worktree_ids
|
||||
.into_iter()
|
||||
.map(WorktreeId::from_proto)
|
||||
.map(PathTrust::Worktree)
|
||||
.collect::<HashSet<_>>();
|
||||
if envelope.payload.restrict_workspace {
|
||||
restricted_paths.insert(PathTrust::Workspace);
|
||||
}
|
||||
let remote_host = this
|
||||
.read(cx)
|
||||
.remote_connection_options(cx)
|
||||
.map(RemoteHostLocation::from);
|
||||
trusted_worktrees.restrict(restricted_paths, remote_host, cx);
|
||||
})?;
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
async fn handle_update_buffer(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::UpdateBuffer>,
|
||||
|
||||
@@ -23,13 +23,14 @@ use settings::{
|
||||
DapSettingsContent, InvalidSettingsError, LocalSettingsKind, RegisterSetting, Settings,
|
||||
SettingsLocation, SettingsStore, parse_json_with_comments, watch_config_file,
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use std::{cell::OnceCell, collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
use task::{DebugTaskFile, TaskTemplates, VsCodeDebugTaskFile, VsCodeTaskFile};
|
||||
use util::{ResultExt, rel_path::RelPath, serde::default_true};
|
||||
use worktree::{PathChange, UpdatedEntriesSet, Worktree, WorktreeId};
|
||||
|
||||
use crate::{
|
||||
task_store::{TaskSettingsLocation, TaskStore},
|
||||
trusted_worktrees::{PathTrust, TrustedWorktrees, TrustedWorktreesEvent},
|
||||
worktree_store::{WorktreeStore, WorktreeStoreEvent},
|
||||
};
|
||||
|
||||
@@ -83,6 +84,12 @@ pub struct SessionSettings {
|
||||
///
|
||||
/// Default: true
|
||||
pub restore_unsaved_buffers: bool,
|
||||
/// Whether or not to skip worktree trust checks.
|
||||
/// When trusted, project settings are synchronized automatically,
|
||||
/// language and MCP servers are downloaded and started automatically.
|
||||
///
|
||||
/// Default: false
|
||||
pub trust_all_worktrees: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -570,6 +577,7 @@ impl Settings for ProjectSettings {
|
||||
load_direnv: project.load_direnv.clone().unwrap(),
|
||||
session: SessionSettings {
|
||||
restore_unsaved_buffers: content.session.unwrap().restore_unsaved_buffers.unwrap(),
|
||||
trust_all_worktrees: content.session.unwrap().trust_all_worktrees.unwrap(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -595,6 +603,9 @@ pub struct SettingsObserver {
|
||||
worktree_store: Entity<WorktreeStore>,
|
||||
project_id: u64,
|
||||
task_store: Entity<TaskStore>,
|
||||
pending_local_settings:
|
||||
HashMap<PathTrust, BTreeMap<(WorktreeId, Arc<RelPath>), Option<String>>>,
|
||||
_trusted_worktrees_watcher: Option<Subscription>,
|
||||
_user_settings_watcher: Option<Subscription>,
|
||||
_global_task_config_watcher: Task<()>,
|
||||
_global_debug_config_watcher: Task<()>,
|
||||
@@ -620,11 +631,61 @@ impl SettingsObserver {
|
||||
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
|
||||
.detach();
|
||||
|
||||
let _trusted_worktrees_watcher =
|
||||
TrustedWorktrees::try_get_global(cx).map(|trusted_worktrees| {
|
||||
cx.subscribe(
|
||||
&trusted_worktrees,
|
||||
move |settings_observer, _, e, cx| match e {
|
||||
TrustedWorktreesEvent::Trusted(_, trusted_paths) => {
|
||||
for trusted_path in trusted_paths {
|
||||
if let Some(pending_local_settings) = settings_observer
|
||||
.pending_local_settings
|
||||
.remove(trusted_path)
|
||||
{
|
||||
for ((worktree_id, directory_path), settings_contents) in
|
||||
pending_local_settings
|
||||
{
|
||||
apply_local_settings(
|
||||
worktree_id,
|
||||
&directory_path,
|
||||
LocalSettingsKind::Settings,
|
||||
&settings_contents,
|
||||
cx,
|
||||
);
|
||||
if let Some(downstream_client) =
|
||||
&settings_observer.downstream_client
|
||||
{
|
||||
downstream_client
|
||||
.send(proto::UpdateWorktreeSettings {
|
||||
project_id: settings_observer.project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
path: directory_path.to_proto(),
|
||||
content: settings_contents,
|
||||
kind: Some(
|
||||
local_settings_kind_to_proto(
|
||||
LocalSettingsKind::Settings,
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrustedWorktreesEvent::Restricted(..) => {}
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
worktree_store,
|
||||
task_store,
|
||||
mode: SettingsObserverMode::Local(fs.clone()),
|
||||
downstream_client: None,
|
||||
_trusted_worktrees_watcher,
|
||||
pending_local_settings: HashMap::default(),
|
||||
_user_settings_watcher: None,
|
||||
project_id: REMOTE_SERVER_PROJECT_ID,
|
||||
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
|
||||
@@ -677,6 +738,8 @@ impl SettingsObserver {
|
||||
mode: SettingsObserverMode::Remote,
|
||||
downstream_client: None,
|
||||
project_id: REMOTE_SERVER_PROJECT_ID,
|
||||
_trusted_worktrees_watcher: None,
|
||||
pending_local_settings: HashMap::default(),
|
||||
_user_settings_watcher: user_settings_watcher,
|
||||
_global_task_config_watcher: Self::subscribe_to_global_task_file_changes(
|
||||
fs.clone(),
|
||||
@@ -975,36 +1038,32 @@ impl SettingsObserver {
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
let remote_worktree_id = worktree.read(cx).id();
|
||||
let task_store = self.task_store.clone();
|
||||
|
||||
let can_trust_worktree = OnceCell::new();
|
||||
for (directory, kind, file_content) in settings_contents {
|
||||
let mut applied = true;
|
||||
match kind {
|
||||
LocalSettingsKind::Settings | LocalSettingsKind::Editorconfig => cx
|
||||
.update_global::<SettingsStore, _>(|store, cx| {
|
||||
let result = store.set_local_settings(
|
||||
worktree_id,
|
||||
directory.clone(),
|
||||
kind,
|
||||
file_content.as_deref(),
|
||||
cx,
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(InvalidSettingsError::LocalSettings { path, message }) => {
|
||||
log::error!("Failed to set local settings in {path:?}: {message}");
|
||||
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
|
||||
InvalidSettingsError::LocalSettings { path, message },
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to set local settings: {e}");
|
||||
}
|
||||
Ok(()) => {
|
||||
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
|
||||
.as_std_path()
|
||||
.join(local_settings_file_relative_path().as_std_path()))));
|
||||
}
|
||||
LocalSettingsKind::Settings => {
|
||||
if *can_trust_worktree.get_or_init(|| {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust(worktree_id, cx)
|
||||
})
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}),
|
||||
}) {
|
||||
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
|
||||
} else {
|
||||
applied = false;
|
||||
self.pending_local_settings
|
||||
.entry(PathTrust::Worktree(worktree_id))
|
||||
.or_default()
|
||||
.insert((worktree_id, directory.clone()), file_content.clone());
|
||||
}
|
||||
}
|
||||
LocalSettingsKind::Editorconfig => {
|
||||
apply_local_settings(worktree_id, &directory, kind, &file_content, cx)
|
||||
}
|
||||
LocalSettingsKind::Tasks => {
|
||||
let result = task_store.update(cx, |task_store, cx| {
|
||||
task_store.update_user_tasks(
|
||||
@@ -1067,16 +1126,18 @@ impl SettingsObserver {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(downstream_client) = &self.downstream_client {
|
||||
downstream_client
|
||||
.send(proto::UpdateWorktreeSettings {
|
||||
project_id: self.project_id,
|
||||
worktree_id: remote_worktree_id.to_proto(),
|
||||
path: directory.to_proto(),
|
||||
content: file_content.clone(),
|
||||
kind: Some(local_settings_kind_to_proto(kind).into()),
|
||||
})
|
||||
.log_err();
|
||||
if applied {
|
||||
if let Some(downstream_client) = &self.downstream_client {
|
||||
downstream_client
|
||||
.send(proto::UpdateWorktreeSettings {
|
||||
project_id: self.project_id,
|
||||
worktree_id: remote_worktree_id.to_proto(),
|
||||
path: directory.to_proto(),
|
||||
content: file_content.clone(),
|
||||
kind: Some(local_settings_kind_to_proto(kind).into()),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1193,6 +1254,37 @@ impl SettingsObserver {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_local_settings(
|
||||
worktree_id: WorktreeId,
|
||||
directory: &Arc<RelPath>,
|
||||
kind: LocalSettingsKind,
|
||||
file_content: &Option<String>,
|
||||
cx: &mut Context<'_, SettingsObserver>,
|
||||
) {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
let result = store.set_local_settings(
|
||||
worktree_id,
|
||||
directory.clone(),
|
||||
kind,
|
||||
file_content.as_deref(),
|
||||
cx,
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(InvalidSettingsError::LocalSettings { path, message }) => {
|
||||
log::error!("Failed to set local settings in {path:?}: {message}");
|
||||
cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Err(
|
||||
InvalidSettingsError::LocalSettings { path, message },
|
||||
)));
|
||||
}
|
||||
Err(e) => log::error!("Failed to set local settings: {e}"),
|
||||
Ok(()) => cx.emit(SettingsObserverEvent::LocalSettingsUpdated(Ok(directory
|
||||
.as_std_path()
|
||||
.join(local_settings_file_relative_path().as_std_path())))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind {
|
||||
match kind {
|
||||
proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings,
|
||||
|
||||
1933
crates/project/src/trusted_worktrees.rs
Normal file
1933
crates/project/src/trusted_worktrees.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ fn main() -> Result<(), anyhow::Error> {
|
||||
let client = Client::production(cx);
|
||||
let http_client = FakeHttpClient::with_200_response();
|
||||
let (_, rx) = watch::channel(None);
|
||||
let node = NodeRuntime::new(http_client, None, rx);
|
||||
let node = NodeRuntime::new(http_client, None, rx, None);
|
||||
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
|
||||
let registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone()));
|
||||
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
|
||||
@@ -73,6 +73,7 @@ fn main() -> Result<(), anyhow::Error> {
|
||||
registry,
|
||||
fs,
|
||||
Some(Default::default()),
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ pub struct PromptMetadata {
|
||||
#[serde(tag = "kind")]
|
||||
pub enum PromptId {
|
||||
User { uuid: UserPromptId },
|
||||
EditWorkflow,
|
||||
CommitMessage,
|
||||
}
|
||||
|
||||
impl PromptId {
|
||||
@@ -63,8 +63,31 @@ impl PromptId {
|
||||
UserPromptId::new().into()
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<UserPromptId> {
|
||||
match self {
|
||||
Self::User { uuid } => Some(*uuid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_built_in(&self) -> bool {
|
||||
!matches!(self, PromptId::User { .. })
|
||||
match self {
|
||||
Self::User { .. } => false,
|
||||
Self::CommitMessage => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_edit(&self) -> bool {
|
||||
match self {
|
||||
Self::User { .. } | Self::CommitMessage => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_content(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::User { .. } => None,
|
||||
Self::CommitMessage => Some(include_str!("../../git_ui/src/commit_message_prompt.txt")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +117,7 @@ impl std::fmt::Display for PromptId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PromptId::User { uuid } => write!(f, "{}", uuid.0),
|
||||
PromptId::EditWorkflow => write!(f, "Edit workflow"),
|
||||
PromptId::CommitMessage => write!(f, "Commit message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,10 +199,24 @@ impl PromptStore {
|
||||
let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
|
||||
let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
|
||||
|
||||
// Remove edit workflow prompt, as we decided to opt into it using
|
||||
// a slash command instead.
|
||||
metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
|
||||
bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
|
||||
// Insert default commit message prompt if not present
|
||||
if metadata.get(&txn, &PromptId::CommitMessage)?.is_none() {
|
||||
metadata.put(
|
||||
&mut txn,
|
||||
&PromptId::CommitMessage,
|
||||
&PromptMetadata {
|
||||
id: PromptId::CommitMessage,
|
||||
title: Some("Git Commit Message".into()),
|
||||
default: false,
|
||||
saved_at: Utc::now(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if bodies.get(&txn, &PromptId::CommitMessage)?.is_none() {
|
||||
let commit_message_prompt =
|
||||
include_str!("../../git_ui/src/commit_message_prompt.txt");
|
||||
bodies.put(&mut txn, &PromptId::CommitMessage, commit_message_prompt)?;
|
||||
}
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
@@ -387,8 +424,8 @@ impl PromptStore {
|
||||
body: Rope,
|
||||
cx: &Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if id.is_built_in() {
|
||||
return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
|
||||
if !id.can_edit() {
|
||||
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
|
||||
}
|
||||
|
||||
let prompt_metadata = PromptMetadata {
|
||||
@@ -430,7 +467,7 @@ impl PromptStore {
|
||||
) -> Task<Result<()>> {
|
||||
let mut cache = self.metadata_cache.write();
|
||||
|
||||
if id.is_built_in() {
|
||||
if !id.can_edit() {
|
||||
title = cache
|
||||
.metadata_by_id
|
||||
.get(&id)
|
||||
|
||||
@@ -158,3 +158,22 @@ message UpdateUserSettings {
|
||||
uint64 project_id = 1;
|
||||
string contents = 2;
|
||||
}
|
||||
|
||||
message TrustWorktrees {
|
||||
uint64 project_id = 1;
|
||||
repeated PathTrust trusted_paths = 2;
|
||||
}
|
||||
|
||||
message PathTrust {
|
||||
oneof content {
|
||||
uint64 workspace = 1;
|
||||
uint64 worktree_id = 2;
|
||||
string abs_path = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message RestrictWorktrees {
|
||||
uint64 project_id = 1;
|
||||
bool restrict_workspace = 2;
|
||||
repeated uint64 worktree_ids = 3;
|
||||
}
|
||||
|
||||
@@ -448,7 +448,10 @@ message Envelope {
|
||||
ExternalExtensionAgentsUpdated external_extension_agents_updated = 401;
|
||||
|
||||
GitCreateRemote git_create_remote = 402;
|
||||
GitRemoveRemote git_remove_remote = 403;// current max
|
||||
GitRemoveRemote git_remove_remote = 403;
|
||||
|
||||
TrustWorktrees trust_worktrees = 404;
|
||||
RestrictWorktrees restrict_worktrees = 405; // current max
|
||||
}
|
||||
|
||||
reserved 87 to 88, 396;
|
||||
|
||||
@@ -310,6 +310,8 @@ messages!(
|
||||
(GitCreateBranch, Background),
|
||||
(GitChangeBranch, Background),
|
||||
(GitRenameBranch, Background),
|
||||
(TrustWorktrees, Background),
|
||||
(RestrictWorktrees, Background),
|
||||
(CheckForPushedCommits, Background),
|
||||
(CheckForPushedCommitsResponse, Background),
|
||||
(GitDiff, Background),
|
||||
@@ -529,7 +531,9 @@ request_messages!(
|
||||
(GetAgentServerCommand, AgentServerCommand),
|
||||
(RemoteStarted, Ack),
|
||||
(GitGetWorktrees, GitWorktreesResponse),
|
||||
(GitCreateWorktree, Ack)
|
||||
(GitCreateWorktree, Ack),
|
||||
(TrustWorktrees, Ack),
|
||||
(RestrictWorktrees, Ack),
|
||||
);
|
||||
|
||||
lsp_messages!(
|
||||
@@ -702,7 +706,9 @@ entity_messages!(
|
||||
ExternalAgentLoadingStatusUpdated,
|
||||
NewExternalAgentVersionAvailable,
|
||||
GitGetWorktrees,
|
||||
GitCreateWorktree
|
||||
GitCreateWorktree,
|
||||
TrustWorktrees,
|
||||
RestrictWorktrees,
|
||||
);
|
||||
|
||||
entity_messages!(
|
||||
|
||||
@@ -16,6 +16,7 @@ use gpui::{
|
||||
|
||||
use language::{CursorShape, Point};
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
|
||||
use project::trusted_worktrees;
|
||||
use release_channel::ReleaseChannel;
|
||||
use remote::{
|
||||
ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
|
||||
@@ -646,6 +647,7 @@ pub async fn open_remote_project(
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
cx.new(|cx| {
|
||||
@@ -788,11 +790,20 @@ pub async fn open_remote_project(
|
||||
continue;
|
||||
}
|
||||
|
||||
if created_new_window {
|
||||
window
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.ok();
|
||||
}
|
||||
window
|
||||
.update(cx, |workspace, window, cx| {
|
||||
if created_new_window {
|
||||
window.remove_window();
|
||||
}
|
||||
trusted_worktrees::track_worktree_trust(
|
||||
workspace.project().read(cx).worktree_store(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
Ok(items) => {
|
||||
|
||||
@@ -1000,6 +1000,7 @@ impl RemoteServerProjects {
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ anyhow.workspace = true
|
||||
askpass.workspace = true
|
||||
clap.workspace = true
|
||||
client.workspace = true
|
||||
collections.workspace = true
|
||||
dap_adapters.workspace = true
|
||||
debug_adapter_extension.workspace = true
|
||||
env_logger.workspace = true
|
||||
@@ -81,7 +82,6 @@ action_log.workspace = true
|
||||
agent = { workspace = true, features = ["test-support"] }
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
clock = { workspace = true, features = ["test-support"] }
|
||||
collections.workspace = true
|
||||
dap = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::HashSet;
|
||||
use language::File;
|
||||
use lsp::LanguageServerId;
|
||||
|
||||
@@ -21,6 +22,7 @@ use project::{
|
||||
project_settings::SettingsObserver,
|
||||
search::SearchQuery,
|
||||
task_store::TaskStore,
|
||||
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
|
||||
worktree_store::WorktreeStore,
|
||||
};
|
||||
use rpc::{
|
||||
@@ -86,6 +88,7 @@ impl HeadlessProject {
|
||||
languages,
|
||||
extension_host_proxy: proxy,
|
||||
}: HeadlessAppState,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
debug_adapter_extension::init(proxy.clone(), cx);
|
||||
@@ -97,6 +100,16 @@ impl HeadlessProject {
|
||||
store
|
||||
});
|
||||
|
||||
if init_worktree_trust {
|
||||
project::trusted_worktrees::track_worktree_trust(
|
||||
worktree_store.clone(),
|
||||
None::<RemoteHostLocation>,
|
||||
Some((session.clone(), REMOTE_SERVER_PROJECT_ID)),
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
let environment =
|
||||
cx.new(|cx| ProjectEnvironment::new(None, worktree_store.downgrade(), None, true, cx));
|
||||
let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
|
||||
@@ -264,6 +277,8 @@ impl HeadlessProject {
|
||||
session.add_entity_request_handler(Self::handle_get_directory_environment);
|
||||
session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
|
||||
session.add_entity_request_handler(Self::handle_open_image_by_path);
|
||||
session.add_entity_request_handler(Self::handle_trust_worktrees);
|
||||
session.add_entity_request_handler(Self::handle_restrict_worktrees);
|
||||
|
||||
session.add_entity_request_handler(BufferStore::handle_update_buffer);
|
||||
session.add_entity_message_handler(BufferStore::handle_close_buffer);
|
||||
@@ -595,6 +610,53 @@ impl HeadlessProject {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_trust_worktrees(
|
||||
_: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::TrustWorktrees>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let trusted_worktrees = cx
|
||||
.update(|cx| TrustedWorktrees::try_get_global(cx))?
|
||||
.context("missing trusted worktrees")?;
|
||||
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.trust(
|
||||
envelope
|
||||
.payload
|
||||
.trusted_paths
|
||||
.into_iter()
|
||||
.filter_map(PathTrust::from_proto)
|
||||
.collect(),
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
pub async fn handle_restrict_worktrees(
|
||||
_: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::RestrictWorktrees>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let trusted_worktrees = cx
|
||||
.update(|cx| TrustedWorktrees::try_get_global(cx))?
|
||||
.context("missing trusted worktrees")?;
|
||||
trusted_worktrees.update(&mut cx, |trusted_worktrees, cx| {
|
||||
let mut restricted_paths = envelope
|
||||
.payload
|
||||
.worktree_ids
|
||||
.into_iter()
|
||||
.map(WorktreeId::from_proto)
|
||||
.map(PathTrust::Worktree)
|
||||
.collect::<HashSet<_>>();
|
||||
if envelope.payload.restrict_workspace {
|
||||
restricted_paths.insert(PathTrust::Workspace);
|
||||
}
|
||||
trusted_worktrees.restrict(restricted_paths, None, cx);
|
||||
})?;
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
pub async fn handle_open_new_buffer(
|
||||
this: Entity<Self>,
|
||||
_message: TypedEnvelope<proto::OpenNewBuffer>,
|
||||
|
||||
@@ -1933,6 +1933,7 @@ pub async fn init_test(
|
||||
languages,
|
||||
extension_host_proxy: proxy,
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -1977,5 +1978,5 @@ fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<P
|
||||
Project::init(&client, cx);
|
||||
});
|
||||
|
||||
cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
|
||||
cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, false, cx))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::HeadlessProject;
|
||||
use crate::headless_project::HeadlessAppState;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use client::ProxySettings;
|
||||
use project::trusted_worktrees;
|
||||
use util::ResultExt;
|
||||
|
||||
use extension::ExtensionHostProxy;
|
||||
@@ -34,6 +35,7 @@ use smol::Async;
|
||||
use smol::channel::{Receiver, Sender};
|
||||
use smol::io::AsyncReadExt;
|
||||
use smol::{net::unix::UnixListener, stream::StreamExt as _};
|
||||
use std::pin::Pin;
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsStr,
|
||||
@@ -417,6 +419,7 @@ pub fn execute_run(
|
||||
|
||||
log::info!("gpui app started, initializing server");
|
||||
let session = start_server(listeners, log_rx, cx, is_wsl_interop);
|
||||
trusted_worktrees::init(Some((session.clone(), REMOTE_SERVER_PROJECT_ID)), None, cx);
|
||||
|
||||
GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
|
||||
git_hosting_providers::init(cx);
|
||||
@@ -449,10 +452,13 @@ pub fn execute_run(
|
||||
)
|
||||
};
|
||||
|
||||
let trust_task = trusted_worktrees::wait_for_default_workspace_trust("Node runtime", cx)
|
||||
.map(|trust_task| Box::pin(trust_task) as Pin<Box<_>>);
|
||||
let node_runtime = NodeRuntime::new(
|
||||
http_client.clone(),
|
||||
Some(shell_env_loaded_rx),
|
||||
node_settings_rx,
|
||||
trust_task,
|
||||
);
|
||||
|
||||
let mut languages = LanguageRegistry::new(cx.background_executor().clone());
|
||||
@@ -468,6 +474,7 @@ pub fn execute_run(
|
||||
languages,
|
||||
extension_host_proxy,
|
||||
},
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -21,9 +21,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
use theme::ThemeSettings;
|
||||
use title_bar::platform_title_bar::PlatformTitleBar;
|
||||
use ui::{
|
||||
Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Render, Tooltip, prelude::*,
|
||||
};
|
||||
use ui::{Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
|
||||
use zed_actions::assistant::InlineAssist;
|
||||
@@ -44,15 +42,12 @@ actions!(
|
||||
/// Duplicates the selected rule.
|
||||
DuplicateRule,
|
||||
/// Toggles whether the selected rule is a default rule.
|
||||
ToggleDefaultRule
|
||||
ToggleDefaultRule,
|
||||
/// Restores a built-in rule to its default content.
|
||||
RestoreDefaultContent
|
||||
]
|
||||
);
|
||||
|
||||
const BUILT_IN_TOOLTIP_TEXT: &str = concat!(
|
||||
"This rule supports special functionality.\n",
|
||||
"It's read-only, but you can remove it from your default rules."
|
||||
);
|
||||
|
||||
pub trait InlineAssistDelegate {
|
||||
fn assist(
|
||||
&self,
|
||||
@@ -270,23 +265,35 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
.background_spawn(async move {
|
||||
let matches = search.await;
|
||||
|
||||
let (default_rules, non_default_rules): (Vec<_>, Vec<_>) =
|
||||
matches.iter().partition(|rule| rule.default);
|
||||
let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
|
||||
matches.into_iter().partition(|rule| rule.id.is_built_in());
|
||||
let (default_rules, other_rules): (Vec<_>, Vec<_>) =
|
||||
user_rules.into_iter().partition(|rule| rule.default);
|
||||
|
||||
let mut filtered_entries = Vec::new();
|
||||
|
||||
if !default_rules.is_empty() {
|
||||
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
|
||||
if !built_in_rules.is_empty() {
|
||||
filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
|
||||
|
||||
for rule in default_rules {
|
||||
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
|
||||
for rule in built_in_rules {
|
||||
filtered_entries.push(RulePickerEntry::Rule(rule));
|
||||
}
|
||||
|
||||
filtered_entries.push(RulePickerEntry::Separator);
|
||||
}
|
||||
|
||||
for rule in non_default_rules {
|
||||
filtered_entries.push(RulePickerEntry::Rule(rule.clone()));
|
||||
if !default_rules.is_empty() {
|
||||
filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
|
||||
|
||||
for rule in default_rules {
|
||||
filtered_entries.push(RulePickerEntry::Rule(rule));
|
||||
}
|
||||
|
||||
filtered_entries.push(RulePickerEntry::Separator);
|
||||
}
|
||||
|
||||
for rule in other_rules {
|
||||
filtered_entries.push(RulePickerEntry::Rule(rule));
|
||||
}
|
||||
|
||||
let selected_index = prev_prompt_id
|
||||
@@ -341,21 +348,27 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
RulePickerEntry::Header(title) => Some(
|
||||
ListSubHeader::new(title.clone())
|
||||
.end_slot(
|
||||
IconButton::new("info", IconName::Info)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text(
|
||||
"Default Rules are attached by default with every new thread.",
|
||||
))
|
||||
.into_any_element(),
|
||||
)
|
||||
.inset(true)
|
||||
.into_any_element(),
|
||||
),
|
||||
RulePickerEntry::Header(title) => {
|
||||
let tooltip_text = if title.as_ref() == "Built-in Rules" {
|
||||
"Built-in rules are those included out of the box with Zed."
|
||||
} else {
|
||||
"Default Rules are attached by default with every new thread."
|
||||
};
|
||||
|
||||
Some(
|
||||
ListSubHeader::new(title.clone())
|
||||
.end_slot(
|
||||
IconButton::new("info", IconName::Info)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text(tooltip_text))
|
||||
.into_any_element(),
|
||||
)
|
||||
.inset(true)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
RulePickerEntry::Separator => Some(
|
||||
h_flex()
|
||||
.py_1()
|
||||
@@ -376,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
.truncate()
|
||||
.mr_10(),
|
||||
)
|
||||
.end_slot::<IconButton>(default.then(|| {
|
||||
.end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
|
||||
IconButton::new("toggle-default-rule", IconName::Paperclip)
|
||||
.toggle_state(true)
|
||||
.icon_color(Color::Accent)
|
||||
@@ -386,62 +399,52 @@ impl PickerDelegate for RulePickerDelegate {
|
||||
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
|
||||
}))
|
||||
}))
|
||||
.end_hover_slot(
|
||||
h_flex()
|
||||
.child(if prompt_id.is_built_in() {
|
||||
div()
|
||||
.id("built-in-rule")
|
||||
.child(Icon::new(IconName::FileLock).color(Color::Muted))
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Built-in rule",
|
||||
None,
|
||||
BUILT_IN_TOOLTIP_TEXT,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
} else {
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Delete Rule"))
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.emit(RulePickerEvent::Deleted { prompt_id })
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
IconButton::new("toggle-default-rule", IconName::Plus)
|
||||
.selected_icon(IconName::Dash)
|
||||
.toggle_state(default)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if default {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.map(|this| {
|
||||
if default {
|
||||
this.tooltip(Tooltip::text(
|
||||
"Remove from Default Rules",
|
||||
))
|
||||
.when(!prompt_id.is_built_in(), |this| {
|
||||
this.end_hover_slot(
|
||||
h_flex()
|
||||
.child(
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Delete Rule"))
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.emit(RulePickerEvent::Deleted { prompt_id })
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("toggle-default-rule", IconName::Plus)
|
||||
.selected_icon(IconName::Dash)
|
||||
.toggle_state(default)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if default {
|
||||
Color::Accent
|
||||
} else {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
cx,
|
||||
)
|
||||
Color::Muted
|
||||
})
|
||||
.map(|this| {
|
||||
if default {
|
||||
this.tooltip(Tooltip::text(
|
||||
"Remove from Default Rules",
|
||||
))
|
||||
} else {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.emit(RulePickerEvent::ToggledDefault {
|
||||
prompt_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
|
||||
})),
|
||||
),
|
||||
)
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -573,7 +576,7 @@ impl RulesLibrary {
|
||||
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
if prompt_id.is_built_in() {
|
||||
if !prompt_id.can_edit() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -661,6 +664,33 @@ impl RulesLibrary {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_default_content_for_active_rule(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(active_rule_id) = self.active_rule_id {
|
||||
self.restore_default_content(active_rule_id, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_default_content(
|
||||
&mut self,
|
||||
prompt_id: PromptId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(default_content) = prompt_id.default_content() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
|
||||
rule_editor.body_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(default_content, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_default_for_rule(
|
||||
&mut self,
|
||||
prompt_id: PromptId,
|
||||
@@ -721,7 +751,7 @@ impl RulesLibrary {
|
||||
});
|
||||
|
||||
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
||||
if prompt_id.is_built_in() {
|
||||
if !prompt_id.can_edit() {
|
||||
editor.set_read_only(true);
|
||||
editor.set_show_edit_predictions(Some(false), window, cx);
|
||||
}
|
||||
@@ -1148,30 +1178,38 @@ impl RulesLibrary {
|
||||
fn render_active_rule_editor(
|
||||
&self,
|
||||
editor: &Entity<Editor>,
|
||||
read_only: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_color = if read_only {
|
||||
cx.theme().colors().text_muted
|
||||
} else {
|
||||
cx.theme().colors().text
|
||||
};
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.on_action(cx.listener(Self::move_down_from_title))
|
||||
.pl_1()
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.rounded_sm()
|
||||
.group_hover("active-editor-header", |this| {
|
||||
this.border_color(cx.theme().colors().border_variant)
|
||||
.when(!read_only, |this| {
|
||||
this.group_hover("active-editor-header", |this| {
|
||||
this.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
})
|
||||
.on_action(cx.listener(Self::move_down_from_title))
|
||||
.child(EditorElement::new(
|
||||
&editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().system().transparent,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: TextStyle {
|
||||
color: cx.theme().colors().editor_foreground,
|
||||
color: text_color,
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_size: HeadlineSize::Large.rems().into(),
|
||||
font_size: HeadlineSize::Medium.rems().into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: relative(settings.buffer_line_height.value()),
|
||||
..Default::default()
|
||||
@@ -1186,6 +1224,68 @@ impl RulesLibrary {
|
||||
))
|
||||
}
|
||||
|
||||
fn render_duplicate_rule_button(&self) -> impl IntoElement {
|
||||
IconButton::new("duplicate-rule", IconName::BookCopy)
|
||||
.tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(DuplicateRule), cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_built_in_rule_controls(&self) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.render_duplicate_rule_button())
|
||||
.child(
|
||||
IconButton::new("restore-default", IconName::RotateCcw)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Restore to Default Content",
|
||||
&RestoreDefaultContent,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(RestoreDefaultContent), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("toggle-default-rule", IconName::Paperclip)
|
||||
.toggle_state(default)
|
||||
.when(default, |this| this.icon_color(Color::Accent))
|
||||
.map(|this| {
|
||||
if default {
|
||||
this.tooltip(Tooltip::text("Remove from Default Rules"))
|
||||
} else {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(ToggleDefaultRule), cx);
|
||||
}),
|
||||
)
|
||||
.child(self.render_duplicate_rule_button())
|
||||
.child(
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
.tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(DeleteRule), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
|
||||
div()
|
||||
.id("rule-editor")
|
||||
@@ -1198,9 +1298,9 @@ impl RulesLibrary {
|
||||
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
|
||||
let rule_editor = &self.rule_editors[&prompt_id];
|
||||
let focus_handle = rule_editor.body_editor.focus_handle(cx);
|
||||
let model = LanguageModelRegistry::read_global(cx)
|
||||
.default_model()
|
||||
.map(|default| default.model);
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
let model = registry.default_model().map(|default| default.model);
|
||||
let built_in = prompt_id.is_built_in();
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
@@ -1214,14 +1314,15 @@ impl RulesLibrary {
|
||||
.child(
|
||||
h_flex()
|
||||
.group("active-editor-header")
|
||||
.pt_2()
|
||||
.pl_1p5()
|
||||
.pr_2p5()
|
||||
.h_12()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(
|
||||
self.render_active_rule_editor(&rule_editor.title_editor, cx),
|
||||
)
|
||||
.child(self.render_active_rule_editor(
|
||||
&rule_editor.title_editor,
|
||||
built_in,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
@@ -1258,89 +1359,15 @@ impl RulesLibrary {
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}))
|
||||
.child(if prompt_id.is_built_in() {
|
||||
div()
|
||||
.id("built-in-rule")
|
||||
.child(
|
||||
Icon::new(IconName::FileLock)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Built-in rule",
|
||||
None,
|
||||
BUILT_IN_TOOLTIP_TEXT,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.into_any()
|
||||
} else {
|
||||
IconButton::new("delete-rule", IconName::Trash)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Delete Rule",
|
||||
&DeleteRule,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(Box::new(DeleteRule), cx);
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
IconButton::new("duplicate-rule", IconName::BookCopy)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Duplicate Rule",
|
||||
&DuplicateRule,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(DuplicateRule),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
"toggle-default-rule",
|
||||
IconName::Paperclip,
|
||||
)
|
||||
.toggle_state(rule_metadata.default)
|
||||
.icon_color(if rule_metadata.default {
|
||||
Color::Accent
|
||||
.map(|this| {
|
||||
if built_in {
|
||||
this.child(self.render_built_in_rule_controls())
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.map(|this| {
|
||||
if rule_metadata.default {
|
||||
this.tooltip(Tooltip::text(
|
||||
"Remove from Default Rules",
|
||||
))
|
||||
} else {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Add to Default Rules",
|
||||
None,
|
||||
"Always included in every thread.",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_click(
|
||||
|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ToggleDefaultRule),
|
||||
cx,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
this.child(self.render_regular_rule_controls(
|
||||
rule_metadata.default,
|
||||
))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -1385,6 +1412,9 @@ impl Render for RulesLibrary {
|
||||
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
|
||||
this.toggle_default_for_active_rule(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
|
||||
this.restore_default_content_for_active_rule(window, cx)
|
||||
}))
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.font(ui_font)
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct AgentSettingsContent {
|
||||
pub default_height: Option<f32>,
|
||||
/// The default model to use when creating new chats and for other features when a specific model is not specified.
|
||||
pub default_model: Option<LanguageModelSelection>,
|
||||
/// Favorite models to show at the top of the model selector.
|
||||
#[serde(default)]
|
||||
pub favorite_models: Vec<LanguageModelSelection>,
|
||||
/// Model to use for the inline assistant. Defaults to default_model when not specified.
|
||||
pub inline_assistant_model: Option<LanguageModelSelection>,
|
||||
/// Model to use for the inline assistant when streaming tools are enabled.
|
||||
@@ -176,6 +179,16 @@ impl AgentSettingsContent {
|
||||
pub fn set_profile(&mut self, profile_id: Arc<str>) {
|
||||
self.default_profile = Some(profile_id);
|
||||
}
|
||||
|
||||
pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
|
||||
if !self.favorite_models.contains(&model) {
|
||||
self.favorite_models.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
|
||||
self.favorite_models.retain(|m| m != model);
|
||||
}
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
|
||||
@@ -187,6 +187,12 @@ pub struct SessionSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
pub restore_unsaved_buffers: Option<bool>,
|
||||
/// Whether or not to skip worktree trust checks.
|
||||
/// When trusted, project settings are synchronized automatically,
|
||||
/// language and MCP servers are downloaded and started automatically.
|
||||
///
|
||||
/// Default: false
|
||||
pub trust_all_worktrees: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, MergeFrom, Debug)]
|
||||
|
||||
@@ -138,6 +138,28 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SectionHeader("Security"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Trust All Projects By Default",
|
||||
description: "When opening Zed, avoid Restricted Mode by auto-trusting all projects, enabling use of all features without having to give permission to each new project.",
|
||||
field: Box::new(SettingField {
|
||||
json_path: Some("session.trust_all_projects"),
|
||||
pick: |settings_content| {
|
||||
settings_content
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|session| session.trust_all_worktrees.as_ref())
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.session
|
||||
.get_or_insert_default()
|
||||
.trust_all_worktrees = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SectionHeader("Workspace Restoration"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Restore Unsaved Buffers",
|
||||
|
||||
@@ -30,18 +30,20 @@ use gpui::{
|
||||
Subscription, WeakEntity, Window, actions, div,
|
||||
};
|
||||
use onboarding_banner::OnboardingBanner;
|
||||
use project::{Project, WorktreeSettings, git_store::GitStoreEvent};
|
||||
use project::{
|
||||
Project, WorktreeSettings, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
|
||||
};
|
||||
use remote::RemoteConnectionOptions;
|
||||
use settings::{Settings, SettingsLocation};
|
||||
use std::sync::Arc;
|
||||
use theme::ActiveTheme;
|
||||
use title_bar_settings::TitleBarSettings;
|
||||
use ui::{
|
||||
Avatar, Button, ButtonLike, ButtonStyle, Chip, ContextMenu, Icon, IconName, IconSize,
|
||||
IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, h_flex, prelude::*,
|
||||
Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
|
||||
PopoverMenuHandle, TintColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::{ResultExt, rel_path::RelPath};
|
||||
use workspace::{Workspace, notifications::NotifyResultExt};
|
||||
use workspace::{ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
|
||||
use zed_actions::{OpenRecent, OpenRemote};
|
||||
|
||||
pub use onboarding_banner::restore_banner;
|
||||
@@ -163,6 +165,7 @@ impl Render for TitleBar {
|
||||
title_bar
|
||||
.when(title_bar_settings.show_project_items, |title_bar| {
|
||||
title_bar
|
||||
.children(self.render_restricted_mode(cx))
|
||||
.children(self.render_project_host(cx))
|
||||
.child(self.render_project_name(cx))
|
||||
})
|
||||
@@ -291,7 +294,12 @@ impl TitleBar {
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify()));
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
}
|
||||
|
||||
let banner = cx.new(|cx| {
|
||||
OnboardingBanner::new(
|
||||
@@ -317,7 +325,7 @@ impl TitleBar {
|
||||
client,
|
||||
_subscriptions: subscriptions,
|
||||
banner,
|
||||
screen_share_popover_handle: Default::default(),
|
||||
screen_share_popover_handle: PopoverMenuHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +435,48 @@ impl TitleBar {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_restricted_mode(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
|
||||
.map(|trusted_worktrees| {
|
||||
trusted_worktrees
|
||||
.read(cx)
|
||||
.has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !has_restricted_worktrees {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
Button::new("restricted_mode_trigger", "Restricted Mode")
|
||||
.style(ButtonStyle::Tinted(TintColor::Warning))
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Warning)
|
||||
.icon(IconName::Warning)
|
||||
.icon_color(Color::Warning)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_position(IconPosition::Start)
|
||||
.tooltip(|_, cx| {
|
||||
Tooltip::with_meta(
|
||||
"You're in Restricted Mode",
|
||||
Some(&ToggleWorktreeSecurity),
|
||||
"Mark this project as trusted and unlock all features",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.show_worktree_trust_security_modal(true, window, cx)
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
if self.project.read(cx).is_via_remote_server() {
|
||||
return self.render_remote_project_connection(cx);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod configured_api_card;
|
||||
mod tool_call;
|
||||
|
||||
pub use configured_api_card::*;
|
||||
pub use tool_call::*;
|
||||
|
||||
176
crates/ui/src/components/ai/tool_call.rs
Normal file
176
crates/ui/src/components/ai/tool_call.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::prelude::*;
|
||||
use gpui::{AnyElement, IntoElement, ParentElement, SharedString};
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct ToolCall {
|
||||
icon: IconName,
|
||||
title: SharedString,
|
||||
actions_slot: Option<AnyElement>,
|
||||
content: Option<AnyElement>,
|
||||
use_card_layout: bool,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
icon: IconName::ToolSearch,
|
||||
title: title.into(),
|
||||
actions_slot: None,
|
||||
use_card_layout: false,
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = icon;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
|
||||
self.actions_slot = Some(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl IntoElement) -> Self {
|
||||
self.content = Some(content.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn use_card_layout(mut self, use_card_layout: bool) -> Self {
|
||||
self.use_card_layout = use_card_layout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ToolCall {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.when(self.use_card_layout, |this| {
|
||||
this.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.overflow_hidden()
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.when(self.use_card_layout, |this| {
|
||||
this.p_1()
|
||||
.bg(cx.theme().colors().element_background.opacity(0.2))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.when(self.use_card_layout, |this| this.px_1())
|
||||
.hover(|s| s.bg(cx.theme().colors().element_hover))
|
||||
.gap_1p5()
|
||||
.rounded_xs()
|
||||
.child(
|
||||
Icon::new(self.icon)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Label::new(self.title)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.when_some(self.actions_slot, |this, action| this.child(action)),
|
||||
)
|
||||
.when_some(self.content, |this, content| {
|
||||
this.child(
|
||||
div()
|
||||
.map(|this| {
|
||||
if self.use_card_layout {
|
||||
this.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
} else {
|
||||
this.pl_4()
|
||||
.ml_1p5()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
}
|
||||
})
|
||||
.child(content),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ToolCall {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Agent
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
let container = || {
|
||||
v_flex()
|
||||
.p_2()
|
||||
.w_128()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
};
|
||||
|
||||
let muted_icon_button = |id: &'static str, icon: IconName| {
|
||||
IconButton::new(id, icon)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
};
|
||||
|
||||
let examples = vec![
|
||||
single_example(
|
||||
"Non-card (header only)",
|
||||
container()
|
||||
.child(
|
||||
ToolCall::new("Search repository")
|
||||
.icon(IconName::ToolSearch)
|
||||
.actions_slot(muted_icon_button(
|
||||
"toolcall-noncard-expand",
|
||||
IconName::ChevronDown,
|
||||
)),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
single_example(
|
||||
"Non-card + content",
|
||||
container()
|
||||
.child(
|
||||
ToolCall::new("Edit file: src/main.rs")
|
||||
.icon(IconName::File)
|
||||
.content(
|
||||
Label::new("Tool output here — markdown, list, etc.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
single_example(
|
||||
"Card layout + actions",
|
||||
container()
|
||||
.child(
|
||||
ToolCall::new("Run Command")
|
||||
.icon(IconName::ToolTerminal)
|
||||
.use_card_layout(true)
|
||||
.actions_slot(muted_icon_button(
|
||||
"toolcall-card-expand",
|
||||
IconName::ChevronDown,
|
||||
))
|
||||
.content(
|
||||
Label::new("git status")
|
||||
.size(LabelSize::Small)
|
||||
.buffer_font(cx),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
];
|
||||
|
||||
Some(example_group(examples).vertical().into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,161 @@
|
||||
use crate::component_prelude::*;
|
||||
use crate::prelude::*;
|
||||
use crate::{Checkbox, ListBulletItem, ToggleState};
|
||||
use gpui::Action;
|
||||
use gpui::FocusHandle;
|
||||
use gpui::IntoElement;
|
||||
use gpui::Stateful;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
type ActionHandler = Box<dyn FnOnce(Stateful<Div>) -> Stateful<Div>>;
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct AlertModal {
|
||||
id: ElementId,
|
||||
header: Option<AnyElement>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
title: SharedString,
|
||||
primary_action: SharedString,
|
||||
dismiss_label: SharedString,
|
||||
footer: Option<AnyElement>,
|
||||
title: Option<SharedString>,
|
||||
primary_action: Option<SharedString>,
|
||||
dismiss_label: Option<SharedString>,
|
||||
width: Option<DefiniteLength>,
|
||||
key_context: Option<String>,
|
||||
action_handlers: Vec<ActionHandler>,
|
||||
focus_handle: Option<FocusHandle>,
|
||||
}
|
||||
|
||||
impl AlertModal {
|
||||
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
header: None,
|
||||
children: smallvec![],
|
||||
title: title.into(),
|
||||
primary_action: "Ok".into(),
|
||||
dismiss_label: "Cancel".into(),
|
||||
footer: None,
|
||||
title: None,
|
||||
primary_action: None,
|
||||
dismiss_label: None,
|
||||
width: None,
|
||||
key_context: None,
|
||||
action_handlers: Vec::new(),
|
||||
focus_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn header(mut self, header: impl IntoElement) -> Self {
|
||||
self.header = Some(header.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn footer(mut self, footer: impl IntoElement) -> Self {
|
||||
self.footer = Some(footer.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
|
||||
self.primary_action = primary_action.into();
|
||||
self.primary_action = Some(primary_action.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
|
||||
self.dismiss_label = dismiss_label.into();
|
||||
self.dismiss_label = Some(dismiss_label.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
|
||||
self.width = Some(width.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn key_context(mut self, key_context: impl Into<String>) -> Self {
|
||||
self.key_context = Some(key_context.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_action<A: Action>(
|
||||
mut self,
|
||||
listener: impl Fn(&A, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.action_handlers
|
||||
.push(Box::new(move |div| div.on_action(listener)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
|
||||
self.focus_handle = Some(focus_handle.clone());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AlertModal {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
let width = self.width.unwrap_or_else(|| px(440.).into());
|
||||
let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
|
||||
|
||||
let mut modal = v_flex()
|
||||
.when_some(self.key_context, |this, key_context| {
|
||||
this.key_context(key_context.as_str())
|
||||
})
|
||||
.when_some(self.focus_handle, |this, focus_handle| {
|
||||
this.track_focus(&focus_handle)
|
||||
})
|
||||
.id(self.id)
|
||||
.elevation_3(cx)
|
||||
.w(px(440.))
|
||||
.p_5()
|
||||
.child(
|
||||
.w(width)
|
||||
.bg(cx.theme().colors().elevated_surface_background)
|
||||
.overflow_hidden();
|
||||
|
||||
for handler in self.action_handlers {
|
||||
modal = handler(modal);
|
||||
}
|
||||
|
||||
if let Some(header) = self.header {
|
||||
modal = modal.child(header);
|
||||
} else if let Some(title) = self.title {
|
||||
modal = modal.child(
|
||||
v_flex()
|
||||
.pt_3()
|
||||
.pr_3()
|
||||
.pl_3()
|
||||
.pb_1()
|
||||
.child(Headline::new(title).size(HeadlineSize::Small)),
|
||||
);
|
||||
}
|
||||
|
||||
if !self.children.is_empty() {
|
||||
modal = modal.child(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.text_ui(cx)
|
||||
.text_color(Color::Muted.color(cx))
|
||||
.gap_1()
|
||||
.child(Headline::new(self.title).size(HeadlineSize::Small))
|
||||
.children(self.children),
|
||||
)
|
||||
.child(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(footer) = self.footer {
|
||||
modal = modal.child(footer);
|
||||
} else if has_default_footer {
|
||||
let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
|
||||
let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
|
||||
|
||||
modal = modal.child(
|
||||
h_flex()
|
||||
.h(rems(1.75))
|
||||
.p_3()
|
||||
.items_center()
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Button::new(
|
||||
self.primary_action.clone(),
|
||||
self.primary_action,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.justify_end()
|
||||
.gap_1()
|
||||
.child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
|
||||
.child(Button::new(primary_action.clone(), primary_action)),
|
||||
);
|
||||
}
|
||||
|
||||
modal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,24 +178,75 @@ impl Component for AlertModal {
|
||||
Some("A modal dialog that presents an alert message with primary and dismiss actions.")
|
||||
}
|
||||
|
||||
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
|
||||
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
Some(
|
||||
v_flex()
|
||||
.gap_6()
|
||||
.p_4()
|
||||
.children(vec![example_group(
|
||||
vec![
|
||||
single_example(
|
||||
"Basic Alert",
|
||||
AlertModal::new("simple-modal", "Do you want to leave the current call?")
|
||||
.child("The current window will be closed, and connections to any shared projects will be terminated."
|
||||
)
|
||||
.primary_action("Leave Call")
|
||||
.into_any_element(),
|
||||
)
|
||||
],
|
||||
)])
|
||||
.into_any_element()
|
||||
.children(vec![
|
||||
example_group(vec![single_example(
|
||||
"Basic Alert",
|
||||
AlertModal::new("simple-modal")
|
||||
.title("Do you want to leave the current call?")
|
||||
.child(
|
||||
"The current window will be closed, and connections to any shared projects will be terminated."
|
||||
)
|
||||
.primary_action("Leave Call")
|
||||
.dismiss_label("Cancel")
|
||||
.into_any_element(),
|
||||
)]),
|
||||
example_group(vec![single_example(
|
||||
"Custom Header",
|
||||
AlertModal::new("custom-header-modal")
|
||||
.header(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.bg(cx.theme().colors().background)
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Icon::new(IconName::Warning).color(Color::Warning))
|
||||
.child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.pl(IconSize::default().rems() + rems(0.5))
|
||||
.child(Label::new("~/projects/my-project").color(Color::Muted))
|
||||
)
|
||||
)
|
||||
.child(
|
||||
"Untrusted workspaces are opened in Restricted Mode to protect your system.
|
||||
Review .zed/settings.json for any extensions or commands configured by this project.",
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_1()
|
||||
.child(Label::new("Restricted mode prevents:").color(Color::Muted))
|
||||
.child(ListBulletItem::new("Project settings from being applied"))
|
||||
.child(ListBulletItem::new("Language servers from running"))
|
||||
.child(ListBulletItem::new("MCP integrations from installing"))
|
||||
)
|
||||
.footer(
|
||||
h_flex()
|
||||
.p_3()
|
||||
.justify_between()
|
||||
.child(
|
||||
Checkbox::new("trust-parent", ToggleState::Unselected)
|
||||
.label("Trust all projects in parent directory")
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted))
|
||||
.child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
|
||||
)
|
||||
)
|
||||
.width(rems(40.))
|
||||
.into_any_element(),
|
||||
)]),
|
||||
])
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,7 +911,7 @@ pub fn surrounding_html_tag(
|
||||
while let Some(cur_node) = last_child_node {
|
||||
if cur_node.child_count() >= 2 {
|
||||
let first_child = cur_node.child(0);
|
||||
let last_child = cur_node.child(cur_node.child_count() - 1);
|
||||
let last_child = cur_node.child(cur_node.child_count() as u32 - 1);
|
||||
if let (Some(first_child), Some(last_child)) = (first_child, last_child) {
|
||||
let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range()));
|
||||
let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));
|
||||
|
||||
@@ -171,28 +171,19 @@ impl Render for ModalLayer {
|
||||
};
|
||||
|
||||
div()
|
||||
.occlude()
|
||||
.absolute()
|
||||
.size_full()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.when(active_modal.modal.fade_out_background(cx), |el| {
|
||||
.inset_0()
|
||||
.occlude()
|
||||
.when(active_modal.modal.fade_out_background(cx), |this| {
|
||||
let mut background = cx.theme().colors().elevated_surface_background;
|
||||
background.fade_out(0.2);
|
||||
el.bg(background)
|
||||
this.bg(background)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.hide_modal(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.h(px(0.0))
|
||||
.top_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.track_focus(&active_modal.focus_handle)
|
||||
.child(
|
||||
|
||||
373
crates/workspace/src/security_modal.rs
Normal file
373
crates/workspace/src/security_modal.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! A UI interface for managing the [`TrustedWorktrees`] data.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use collections::{HashMap, HashSet};
|
||||
use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity};
|
||||
|
||||
use project::{
|
||||
WorktreeId,
|
||||
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
|
||||
worktree_store::WorktreeStore,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*,
|
||||
};
|
||||
|
||||
use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
|
||||
|
||||
pub struct SecurityModal {
|
||||
restricted_paths: HashMap<Option<WorktreeId>, RestrictedPath>,
|
||||
home_dir: Option<PathBuf>,
|
||||
trust_parents: bool,
|
||||
worktree_store: WeakEntity<WorktreeStore>,
|
||||
remote_host: Option<RemoteHostLocation>,
|
||||
focus_handle: FocusHandle,
|
||||
trusted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct RestrictedPath {
|
||||
abs_path: Option<Arc<Path>>,
|
||||
is_file: bool,
|
||||
host: Option<RemoteHostLocation>,
|
||||
}
|
||||
|
||||
impl Focusable for SecurityModal {
|
||||
fn focus_handle(&self, _: &ui::App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for SecurityModal {}
|
||||
|
||||
impl ModalView for SecurityModal {
|
||||
fn fade_out_background(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
|
||||
match self.trusted {
|
||||
Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
|
||||
Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
|
||||
None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
|
||||
}
|
||||
DismissDecision::Dismiss(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SecurityModal {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.restricted_paths.is_empty() {
|
||||
self.dismiss(cx);
|
||||
return v_flex().into_any_element();
|
||||
}
|
||||
|
||||
let header_label = if self.restricted_paths.len() == 1 {
|
||||
"Unrecognized Project"
|
||||
} else {
|
||||
"Unrecognized Projects"
|
||||
};
|
||||
|
||||
let trust_label = self.build_trust_label();
|
||||
|
||||
AlertModal::new("security-modal")
|
||||
.width(rems(40.))
|
||||
.key_context("SecurityModal")
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
|
||||
this.trust_and_dismiss(cx);
|
||||
}))
|
||||
.on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
|
||||
security_modal.trusted = Some(false);
|
||||
security_modal.dismiss(cx);
|
||||
}))
|
||||
.header(
|
||||
v_flex()
|
||||
.p_3()
|
||||
.gap_1()
|
||||
.rounded_t_md()
|
||||
.bg(cx.theme().colors().editor_background.opacity(0.5))
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::Warning).color(Color::Warning))
|
||||
.child(Label::new(header_label)),
|
||||
)
|
||||
.children(self.restricted_paths.values().map(|restricted_path| {
|
||||
let abs_path = restricted_path.abs_path.as_ref().and_then(|abs_path| {
|
||||
if restricted_path.is_file {
|
||||
abs_path.parent()
|
||||
} else {
|
||||
Some(abs_path.as_ref())
|
||||
}
|
||||
});
|
||||
|
||||
let label = match abs_path {
|
||||
Some(abs_path) => match &restricted_path.host {
|
||||
Some(remote_host) => match &remote_host.user_name {
|
||||
Some(user_name) => format!(
|
||||
"{} ({}@{})",
|
||||
self.shorten_path(abs_path).display(),
|
||||
user_name,
|
||||
remote_host.host_identifier
|
||||
),
|
||||
None => format!(
|
||||
"{} ({})",
|
||||
self.shorten_path(abs_path).display(),
|
||||
remote_host.host_identifier
|
||||
),
|
||||
},
|
||||
None => self.shorten_path(abs_path).display().to_string(),
|
||||
},
|
||||
None => match &restricted_path.host {
|
||||
Some(remote_host) => match &remote_host.user_name {
|
||||
Some(user_name) => format!(
|
||||
"Empty project ({}@{})",
|
||||
user_name, remote_host.host_identifier
|
||||
),
|
||||
None => {
|
||||
format!("Empty project ({})", remote_host.host_identifier)
|
||||
}
|
||||
},
|
||||
None => "Empty project".to_string(),
|
||||
},
|
||||
};
|
||||
h_flex()
|
||||
.pl(IconSize::default().rems() + rems(0.5))
|
||||
.child(Label::new(label).color(Color::Muted))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
Label::new(
|
||||
"Untrusted projects are opened in Restricted Mode to protect your system.",
|
||||
)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Label::new(
|
||||
"Review .zed/settings.json for any extensions or commands configured by this project.",
|
||||
)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.child(Label::new("Restricted Mode prevents:").color(Color::Muted))
|
||||
.child(ListBulletItem::new("Project settings from being applied"))
|
||||
.child(ListBulletItem::new("Language servers from running"))
|
||||
.child(ListBulletItem::new("MCP Server integrations from installing")),
|
||||
)
|
||||
.map(|this| match trust_label {
|
||||
Some(trust_label) => this.child(
|
||||
Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
|
||||
.label(trust_label)
|
||||
.on_click(cx.listener(
|
||||
|security_modal, state: &ToggleState, _, cx| {
|
||||
security_modal.trust_parents = state.selected();
|
||||
cx.notify();
|
||||
cx.stop_propagation();
|
||||
},
|
||||
)),
|
||||
),
|
||||
None => this,
|
||||
}),
|
||||
)
|
||||
.footer(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.pb_3()
|
||||
.gap_1()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("rm", "Stay in Restricted Mode")
|
||||
.key_binding(
|
||||
KeyBinding::for_action(
|
||||
&ToggleWorktreeSecurity,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(cx.listener(move |security_modal, _, _, cx| {
|
||||
security_modal.trusted = Some(false);
|
||||
security_modal.dismiss(cx);
|
||||
cx.stop_propagation();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("tc", "Trust and Continue")
|
||||
.style(ButtonStyle::Filled)
|
||||
.layer(ui::ElevationIndex::ModalSurface)
|
||||
.key_binding(
|
||||
KeyBinding::for_action(&menu::Confirm, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(cx.listener(move |security_modal, _, _, cx| {
|
||||
security_modal.trust_and_dismiss(cx);
|
||||
cx.stop_propagation();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityModal {
|
||||
pub fn new(
|
||||
worktree_store: WeakEntity<WorktreeStore>,
|
||||
remote_host: Option<impl Into<RemoteHostLocation>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut this = Self {
|
||||
worktree_store,
|
||||
remote_host: remote_host.map(|host| host.into()),
|
||||
restricted_paths: HashMap::default(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
trust_parents: false,
|
||||
home_dir: std::env::home_dir(),
|
||||
trusted: None,
|
||||
};
|
||||
this.refresh_restricted_paths(cx);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
fn build_trust_label(&self) -> Option<Cow<'static, str>> {
|
||||
let mut has_restricted_files = false;
|
||||
let available_parents = self
|
||||
.restricted_paths
|
||||
.values()
|
||||
.filter(|restricted_path| {
|
||||
has_restricted_files |= restricted_path.is_file;
|
||||
!restricted_path.is_file
|
||||
})
|
||||
.filter_map(|restricted_path| restricted_path.abs_path.as_ref()?.parent())
|
||||
.collect::<SmallVec<[_; 2]>>();
|
||||
match available_parents.len() {
|
||||
0 => {
|
||||
if has_restricted_files {
|
||||
Some(Cow::Borrowed("Trust all single files"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
1 => Some(Cow::Owned(format!(
|
||||
"Trust all projects in the {:?} folder",
|
||||
self.shorten_path(available_parents[0])
|
||||
))),
|
||||
_ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
|
||||
}
|
||||
}
|
||||
|
||||
fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
|
||||
match &self.home_dir {
|
||||
Some(home_dir) => path
|
||||
.strip_prefix(home_dir)
|
||||
.map(|stripped| Path::new("~").join(stripped))
|
||||
.map(Cow::Owned)
|
||||
.unwrap_or(Cow::Borrowed(path)),
|
||||
None => Cow::Borrowed(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
let mut paths_to_trust = self
|
||||
.restricted_paths
|
||||
.keys()
|
||||
.map(|worktree_id| match worktree_id {
|
||||
Some(worktree_id) => PathTrust::Worktree(*worktree_id),
|
||||
None => PathTrust::Workspace,
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
if self.trust_parents {
|
||||
paths_to_trust.extend(self.restricted_paths.values().filter_map(
|
||||
|restricted_paths| {
|
||||
if restricted_paths.is_file {
|
||||
Some(PathTrust::Workspace)
|
||||
} else {
|
||||
let parent_abs_path =
|
||||
restricted_paths.abs_path.as_ref()?.parent()?.to_owned();
|
||||
Some(PathTrust::AbsPath(parent_abs_path))
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.trusted = Some(true);
|
||||
self.dismiss(cx);
|
||||
}
|
||||
|
||||
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
if let Some(worktree_store) = self.worktree_store.upgrade() {
|
||||
let mut new_restricted_worktrees = trusted_worktrees
|
||||
.read(cx)
|
||||
.restricted_worktrees(worktree_store.read(cx), self.remote_host.clone(), cx)
|
||||
.into_iter()
|
||||
.filter_map(|restricted_path| {
|
||||
let restricted_path = match restricted_path {
|
||||
Some((worktree_id, abs_path)) => {
|
||||
let worktree =
|
||||
worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
|
||||
(
|
||||
Some(worktree_id),
|
||||
RestrictedPath {
|
||||
abs_path: Some(abs_path),
|
||||
is_file: worktree.read(cx).is_single_file(),
|
||||
host: self.remote_host.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
None => (
|
||||
None,
|
||||
RestrictedPath {
|
||||
abs_path: None,
|
||||
is_file: false,
|
||||
host: self.remote_host.clone(),
|
||||
},
|
||||
),
|
||||
};
|
||||
Some(restricted_path)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
// Do not clutter the UI:
|
||||
// * trusting regular local worktrees assumes the workspace is trusted either, on the same host.
|
||||
// * trusting a workspace trusts all single-file worktrees on the same host.
|
||||
if new_restricted_worktrees.len() > 1 {
|
||||
new_restricted_worktrees.remove(&None);
|
||||
}
|
||||
|
||||
if self.restricted_paths != new_restricted_worktrees {
|
||||
self.trust_parents = false;
|
||||
self.restricted_paths = new_restricted_worktrees;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
} else if !self.restricted_paths.is_empty() {
|
||||
self.restricted_paths.clear();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod pane_group;
|
||||
mod path_list;
|
||||
mod persistence;
|
||||
pub mod searchable;
|
||||
mod security_modal;
|
||||
pub mod shared_screen;
|
||||
mod status_bar;
|
||||
pub mod tasks;
|
||||
@@ -77,7 +78,9 @@ use project::{
|
||||
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
|
||||
WorktreeSettings,
|
||||
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
|
||||
project_settings::ProjectSettings,
|
||||
toolchain_store::ToolchainStoreEvent,
|
||||
trusted_worktrees::TrustedWorktrees,
|
||||
};
|
||||
use remote::{
|
||||
RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
|
||||
@@ -86,7 +89,9 @@ use remote::{
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use session::AppSession;
|
||||
use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file};
|
||||
use settings::{
|
||||
CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file,
|
||||
};
|
||||
use shared_screen::SharedScreen;
|
||||
use sqlez::{
|
||||
bindable::{Bind, Column, StaticColumnCount},
|
||||
@@ -137,6 +142,7 @@ use crate::{
|
||||
SerializedAxis,
|
||||
model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup},
|
||||
},
|
||||
security_modal::SecurityModal,
|
||||
utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState},
|
||||
};
|
||||
|
||||
@@ -277,6 +283,12 @@ actions!(
|
||||
ZoomIn,
|
||||
/// Zooms out of the active pane.
|
||||
ZoomOut,
|
||||
/// If any worktrees are in restricted mode, shows a modal with possible actions.
|
||||
/// If the modal is shown already, closes it without trusting any worktree.
|
||||
ToggleWorktreeSecurity,
|
||||
/// Clears all trusted worktrees, placing them in restricted mode on next open.
|
||||
/// Requires restart to take effect on already opened projects.
|
||||
ClearTrustedWorktrees,
|
||||
/// Stops following a collaborator.
|
||||
Unfollow,
|
||||
/// Restores the banner.
|
||||
@@ -1217,6 +1229,17 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
cx.observe_global::<SettingsStore>(|_, cx| {
|
||||
if ProjectSettings::get_global(cx).session.trust_all_worktrees {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.auto_trust_all(cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.subscribe_in(&project, window, move |this, _, event, window, cx| {
|
||||
match event {
|
||||
project::Event::RemoteIdChanged(_) => {
|
||||
@@ -1474,7 +1497,7 @@ impl Workspace {
|
||||
}),
|
||||
];
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
this.update_window_title(window, cx);
|
||||
this.show_initial_notifications(cx);
|
||||
});
|
||||
@@ -1559,6 +1582,7 @@ impl Workspace {
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
env,
|
||||
true,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -5938,6 +5962,25 @@ impl Workspace {
|
||||
}
|
||||
},
|
||||
))
|
||||
.on_action(cx.listener(
|
||||
|workspace: &mut Workspace, _: &ToggleWorktreeSecurity, window, cx| {
|
||||
workspace.show_worktree_trust_security_modal(true, window, cx);
|
||||
},
|
||||
))
|
||||
.on_action(
|
||||
cx.listener(|_: &mut Workspace, _: &ClearTrustedWorktrees, _, cx| {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
let clear_task = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.clear_trusted_paths(cx)
|
||||
});
|
||||
cx.spawn(async move |_, cx| {
|
||||
clear_task.await;
|
||||
cx.update(|cx| reload(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(
|
||||
|workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| {
|
||||
workspace.reopen_closed_item(window, cx).detach();
|
||||
@@ -6418,6 +6461,41 @@ impl Workspace {
|
||||
file.project.all_languages.defaults.show_edit_predictions = Some(!show_edit_predictions)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_worktree_trust_security_modal(
|
||||
&mut self,
|
||||
toggle: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(security_modal) = self.active_modal::<SecurityModal>(cx) {
|
||||
if toggle {
|
||||
security_modal.update(cx, |security_modal, cx| {
|
||||
security_modal.dismiss(cx);
|
||||
})
|
||||
} else {
|
||||
security_modal.update(cx, |security_modal, cx| {
|
||||
security_modal.refresh_restricted_paths(cx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx)
|
||||
.map(|trusted_worktrees| {
|
||||
trusted_worktrees
|
||||
.read(cx)
|
||||
.has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if has_restricted_worktrees {
|
||||
let project = self.project().read(cx);
|
||||
let remote_host = project.remote_connection_options(cx);
|
||||
let worktree_store = project.worktree_store().downgrade();
|
||||
self.toggle_modal(window, cx, |_, cx| {
|
||||
SecurityModal::new(worktree_store, remote_host, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leader_border_for_pane(
|
||||
@@ -7968,6 +8046,7 @@ pub fn open_remote_project_with_new_connection(
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -13,6 +13,10 @@ pub enum IgnoreStackEntry {
|
||||
Global {
|
||||
ignore: Arc<Gitignore>,
|
||||
},
|
||||
RepoExclude {
|
||||
ignore: Arc<Gitignore>,
|
||||
parent: Arc<IgnoreStackEntry>,
|
||||
},
|
||||
Some {
|
||||
abs_base_path: Arc<Path>,
|
||||
ignore: Arc<Gitignore>,
|
||||
@@ -21,6 +25,12 @@ pub enum IgnoreStackEntry {
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IgnoreKind {
|
||||
Gitignore(Arc<Path>),
|
||||
RepoExclude,
|
||||
}
|
||||
|
||||
impl IgnoreStack {
|
||||
pub fn none() -> Self {
|
||||
Self {
|
||||
@@ -43,13 +53,19 @@ impl IgnoreStack {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(self, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Self {
|
||||
pub fn append(self, kind: IgnoreKind, ignore: Arc<Gitignore>) -> Self {
|
||||
let top = match self.top.as_ref() {
|
||||
IgnoreStackEntry::All => self.top.clone(),
|
||||
_ => Arc::new(IgnoreStackEntry::Some {
|
||||
abs_base_path,
|
||||
ignore,
|
||||
parent: self.top.clone(),
|
||||
_ => Arc::new(match kind {
|
||||
IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some {
|
||||
abs_base_path,
|
||||
ignore,
|
||||
parent: self.top.clone(),
|
||||
},
|
||||
IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude {
|
||||
ignore,
|
||||
parent: self.top.clone(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
Self {
|
||||
@@ -84,6 +100,17 @@ impl IgnoreStack {
|
||||
ignore::Match::Whitelist(_) => false,
|
||||
}
|
||||
}
|
||||
IgnoreStackEntry::RepoExclude { ignore, parent } => {
|
||||
match ignore.matched(abs_path, is_dir) {
|
||||
ignore::Match::None => IgnoreStack {
|
||||
repo_root: self.repo_root.clone(),
|
||||
top: parent.clone(),
|
||||
}
|
||||
.is_abs_path_ignored(abs_path, is_dir),
|
||||
ignore::Match::Ignore(_) => true,
|
||||
ignore::Match::Whitelist(_) => false,
|
||||
}
|
||||
}
|
||||
IgnoreStackEntry::Some {
|
||||
abs_base_path,
|
||||
ignore,
|
||||
|
||||
@@ -19,7 +19,8 @@ use futures::{
|
||||
};
|
||||
use fuzzy::CharBag;
|
||||
use git::{
|
||||
COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary,
|
||||
COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE,
|
||||
status::GitSummary,
|
||||
};
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority,
|
||||
@@ -71,6 +72,8 @@ use util::{
|
||||
};
|
||||
pub use worktree_settings::WorktreeSettings;
|
||||
|
||||
use crate::ignore::IgnoreKind;
|
||||
|
||||
pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// A set of local or remote files that are being opened as part of a project.
|
||||
@@ -233,6 +236,9 @@ impl Default for WorkDirectory {
|
||||
pub struct LocalSnapshot {
|
||||
snapshot: Snapshot,
|
||||
global_gitignore: Option<Arc<Gitignore>>,
|
||||
/// Exclude files for all git repositories in the worktree, indexed by their absolute path.
|
||||
/// The boolean indicates whether the gitignore needs to be updated.
|
||||
repo_exclude_by_work_dir_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
|
||||
/// All of the gitignore files in the worktree, indexed by their absolute path.
|
||||
/// The boolean indicates whether the gitignore needs to be updated.
|
||||
ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
|
||||
@@ -393,6 +399,7 @@ impl Worktree {
|
||||
let mut snapshot = LocalSnapshot {
|
||||
ignores_by_parent_abs_path: Default::default(),
|
||||
global_gitignore: Default::default(),
|
||||
repo_exclude_by_work_dir_abs_path: Default::default(),
|
||||
git_repositories: Default::default(),
|
||||
snapshot: Snapshot::new(
|
||||
cx.entity_id().as_u64(),
|
||||
@@ -2565,13 +2572,21 @@ impl LocalSnapshot {
|
||||
} else {
|
||||
IgnoreStack::none()
|
||||
};
|
||||
|
||||
if let Some((repo_exclude, _)) = repo_root
|
||||
.as_ref()
|
||||
.and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path))
|
||||
{
|
||||
ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone());
|
||||
}
|
||||
ignore_stack.repo_root = repo_root;
|
||||
for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
|
||||
if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
|
||||
ignore_stack = IgnoreStack::all();
|
||||
break;
|
||||
} else if let Some(ignore) = ignore {
|
||||
ignore_stack = ignore_stack.append(parent_abs_path.into(), ignore);
|
||||
ignore_stack =
|
||||
ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3646,13 +3661,23 @@ impl BackgroundScanner {
|
||||
let root_abs_path = self.state.lock().await.snapshot.abs_path.clone();
|
||||
|
||||
let repo = if self.scanning_enabled {
|
||||
let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
|
||||
let (ignores, exclude, repo) =
|
||||
discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.snapshot
|
||||
.ignores_by_parent_abs_path
|
||||
.extend(ignores);
|
||||
if let Some(exclude) = exclude {
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.snapshot
|
||||
.repo_exclude_by_work_dir_abs_path
|
||||
.insert(root_abs_path.as_path().into(), (exclude, false));
|
||||
}
|
||||
|
||||
repo
|
||||
} else {
|
||||
None
|
||||
@@ -3914,6 +3939,7 @@ impl BackgroundScanner {
|
||||
|
||||
let mut relative_paths = Vec::with_capacity(abs_paths.len());
|
||||
let mut dot_git_abs_paths = Vec::new();
|
||||
let mut work_dirs_needing_exclude_update = Vec::new();
|
||||
abs_paths.sort_unstable();
|
||||
abs_paths.dedup_by(|a, b| a.starts_with(b));
|
||||
{
|
||||
@@ -3987,6 +4013,18 @@ impl BackgroundScanner {
|
||||
continue;
|
||||
};
|
||||
|
||||
let absolute_path = abs_path.to_path_buf();
|
||||
if absolute_path.ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) {
|
||||
if let Some(repository) = snapshot
|
||||
.git_repositories
|
||||
.values()
|
||||
.find(|repo| repo.common_dir_abs_path.join(REPO_EXCLUDE) == absolute_path)
|
||||
{
|
||||
work_dirs_needing_exclude_update
|
||||
.push(repository.work_directory_abs_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) {
|
||||
for (_, repo) in snapshot
|
||||
.git_repositories
|
||||
@@ -4032,6 +4070,19 @@ impl BackgroundScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
if !work_dirs_needing_exclude_update.is_empty() {
|
||||
let mut state = self.state.lock().await;
|
||||
for work_dir_abs_path in work_dirs_needing_exclude_update {
|
||||
if let Some((_, needs_update)) = state
|
||||
.snapshot
|
||||
.repo_exclude_by_work_dir_abs_path
|
||||
.get_mut(&work_dir_abs_path)
|
||||
{
|
||||
*needs_update = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.state.lock().await.snapshot.scan_id += 1;
|
||||
|
||||
let (scan_job_tx, scan_job_rx) = channel::unbounded();
|
||||
@@ -4299,7 +4350,8 @@ impl BackgroundScanner {
|
||||
match build_gitignore(&child_abs_path, self.fs.as_ref()).await {
|
||||
Ok(ignore) => {
|
||||
let ignore = Arc::new(ignore);
|
||||
ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
|
||||
ignore_stack = ignore_stack
|
||||
.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
|
||||
new_ignore = Some(ignore);
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -4561,11 +4613,24 @@ impl BackgroundScanner {
|
||||
.await;
|
||||
|
||||
if path.is_empty()
|
||||
&& let Some((ignores, repo)) = new_ancestor_repo.take()
|
||||
&& let Some((ignores, exclude, repo)) = new_ancestor_repo.take()
|
||||
{
|
||||
log::trace!("updating ancestor git repository");
|
||||
state.snapshot.ignores_by_parent_abs_path.extend(ignores);
|
||||
if let Some((ancestor_dot_git, work_directory)) = repo {
|
||||
if let Some(exclude) = exclude {
|
||||
let work_directory_abs_path = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.snapshot
|
||||
.work_directory_abs_path(&work_directory);
|
||||
|
||||
state
|
||||
.snapshot
|
||||
.repo_exclude_by_work_dir_abs_path
|
||||
.insert(work_directory_abs_path.into(), (exclude, false));
|
||||
}
|
||||
state
|
||||
.insert_git_repository_for_path(
|
||||
work_directory,
|
||||
@@ -4663,6 +4728,36 @@ impl BackgroundScanner {
|
||||
{
|
||||
let snapshot = &mut self.state.lock().await.snapshot;
|
||||
let abs_path = snapshot.abs_path.clone();
|
||||
|
||||
snapshot.repo_exclude_by_work_dir_abs_path.retain(
|
||||
|work_dir_abs_path, (exclude, needs_update)| {
|
||||
if *needs_update {
|
||||
*needs_update = false;
|
||||
ignores_to_update.push(work_dir_abs_path.clone());
|
||||
|
||||
if let Some((_, repository)) = snapshot
|
||||
.git_repositories
|
||||
.iter()
|
||||
.find(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
|
||||
{
|
||||
let exclude_abs_path =
|
||||
repository.common_dir_abs_path.join(REPO_EXCLUDE);
|
||||
if let Ok(current_exclude) = self
|
||||
.executor
|
||||
.block(build_gitignore(&exclude_abs_path, self.fs.as_ref()))
|
||||
{
|
||||
*exclude = Arc::new(current_exclude);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot
|
||||
.git_repositories
|
||||
.iter()
|
||||
.any(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
|
||||
},
|
||||
);
|
||||
|
||||
snapshot
|
||||
.ignores_by_parent_abs_path
|
||||
.retain(|parent_abs_path, (_, needs_update)| {
|
||||
@@ -4717,7 +4812,8 @@ impl BackgroundScanner {
|
||||
|
||||
let mut ignore_stack = job.ignore_stack;
|
||||
if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
|
||||
ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
|
||||
ignore_stack =
|
||||
ignore_stack.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
|
||||
}
|
||||
|
||||
let mut entries_by_id_edits = Vec::new();
|
||||
@@ -4892,6 +4988,9 @@ impl BackgroundScanner {
|
||||
let preserve = ids_to_preserve.contains(work_directory_id);
|
||||
if !preserve {
|
||||
affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
|
||||
snapshot
|
||||
.repo_exclude_by_work_dir_abs_path
|
||||
.remove(&entry.work_directory_abs_path);
|
||||
}
|
||||
preserve
|
||||
});
|
||||
@@ -4931,8 +5030,10 @@ async fn discover_ancestor_git_repo(
|
||||
root_abs_path: &SanitizedPath,
|
||||
) -> (
|
||||
HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
|
||||
Option<Arc<Gitignore>>,
|
||||
Option<(PathBuf, WorkDirectory)>,
|
||||
) {
|
||||
let mut exclude = None;
|
||||
let mut ignores = HashMap::default();
|
||||
for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
|
||||
if index != 0 {
|
||||
@@ -4968,6 +5069,7 @@ async fn discover_ancestor_git_repo(
|
||||
// also mark where in the git repo the root folder is located.
|
||||
return (
|
||||
ignores,
|
||||
exclude,
|
||||
Some((
|
||||
ancestor_dot_git,
|
||||
WorkDirectory::AboveProject {
|
||||
@@ -4979,12 +5081,17 @@ async fn discover_ancestor_git_repo(
|
||||
};
|
||||
}
|
||||
|
||||
let repo_exclude_abs_path = ancestor_dot_git.join(REPO_EXCLUDE);
|
||||
if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await {
|
||||
exclude = Some(Arc::new(repo_exclude));
|
||||
}
|
||||
|
||||
// Reached root of git repository.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(ignores, None)
|
||||
(ignores, exclude, None)
|
||||
}
|
||||
|
||||
fn build_diff(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
|
||||
use anyhow::Result;
|
||||
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
|
||||
use git::GITIGNORE;
|
||||
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
|
||||
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
|
||||
use parking_lot::Mutex;
|
||||
use postage::stream::Stream;
|
||||
@@ -2412,6 +2412,94 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(executor);
|
||||
let project_dir = Path::new(path!("/project"));
|
||||
fs.insert_tree(
|
||||
project_dir,
|
||||
json!({
|
||||
".git": {
|
||||
"info": {
|
||||
"exclude": ".env.*"
|
||||
}
|
||||
},
|
||||
".env.example": "secret=xxxx",
|
||||
".env.local": "secret=1234",
|
||||
".gitignore": "!.env.example",
|
||||
"README.md": "# Repo Exclude",
|
||||
"src": {
|
||||
"main.rs": "fn main() {}",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let worktree = Worktree::local(
|
||||
project_dir,
|
||||
true,
|
||||
fs.clone(),
|
||||
Default::default(),
|
||||
true,
|
||||
&mut cx.to_async(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worktree
|
||||
.update(cx, |worktree, _| {
|
||||
worktree.as_local().unwrap().scan_complete()
|
||||
})
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
|
||||
// .gitignore overrides .git/info/exclude
|
||||
worktree.update(cx, |worktree, _cx| {
|
||||
let expected_excluded_paths = [];
|
||||
let expected_ignored_paths = [".env.local"];
|
||||
let expected_tracked_paths = [".env.example", "README.md", "src/main.rs"];
|
||||
let expected_included_paths = [];
|
||||
|
||||
check_worktree_entries(
|
||||
worktree,
|
||||
&expected_excluded_paths,
|
||||
&expected_ignored_paths,
|
||||
&expected_tracked_paths,
|
||||
&expected_included_paths,
|
||||
);
|
||||
});
|
||||
|
||||
// Ignore statuses are updated when .git/info/exclude file changes
|
||||
fs.write(
|
||||
&project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
|
||||
".env.example".as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worktree
|
||||
.update(cx, |worktree, _| {
|
||||
worktree.as_local().unwrap().scan_complete()
|
||||
})
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
|
||||
worktree.update(cx, |worktree, _cx| {
|
||||
let expected_excluded_paths = [];
|
||||
let expected_ignored_paths = [];
|
||||
let expected_tracked_paths = [".env.example", ".env.local", "README.md", "src/main.rs"];
|
||||
let expected_included_paths = [];
|
||||
|
||||
check_worktree_entries(
|
||||
worktree,
|
||||
&expected_excluded_paths,
|
||||
&expected_ignored_paths,
|
||||
&expected_tracked_paths,
|
||||
&expected_included_paths,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn check_worktree_entries(
|
||||
tree: &Worktree,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "0.218.0"
|
||||
version = "0.219.0"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
|
||||
@@ -27,7 +27,7 @@ use reqwest_client::ReqwestClient;
|
||||
use assets::Assets;
|
||||
use node_runtime::{NodeBinaryOptions, NodeRuntime};
|
||||
use parking_lot::Mutex;
|
||||
use project::project_settings::ProjectSettings;
|
||||
use project::{project_settings::ProjectSettings, trusted_worktrees};
|
||||
use recent_projects::{SshSettings, open_remote_project};
|
||||
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
|
||||
use session::{AppSession, Session};
|
||||
@@ -36,6 +36,7 @@ use std::{
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
process,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Instant,
|
||||
@@ -406,6 +407,7 @@ pub fn main() {
|
||||
});
|
||||
|
||||
app.run(move |cx| {
|
||||
trusted_worktrees::init(None, None, cx);
|
||||
menu::init();
|
||||
zed_actions::init();
|
||||
|
||||
@@ -474,7 +476,15 @@ pub fn main() {
|
||||
tx.send(Some(options)).log_err();
|
||||
})
|
||||
.detach();
|
||||
let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx);
|
||||
|
||||
let trust_task = trusted_worktrees::wait_for_default_workspace_trust("Node runtime", cx)
|
||||
.map(|trust_task| Box::pin(trust_task) as Pin<Box<_>>);
|
||||
let node_runtime = NodeRuntime::new(
|
||||
client.http_client(),
|
||||
Some(shell_env_loaded_rx),
|
||||
rx,
|
||||
trust_task,
|
||||
);
|
||||
|
||||
debug_adapter_extension::init(extension_host_proxy.clone(), cx);
|
||||
languages::init(languages.clone(), fs.clone(), node_runtime.clone(), cx);
|
||||
|
||||
157
docs/.rules
Normal file
157
docs/.rules
Normal file
@@ -0,0 +1,157 @@
|
||||
# Zed Documentation Guidelines
|
||||
|
||||
## Voice and Tone
|
||||
|
||||
### Core Principles
|
||||
|
||||
- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class."
|
||||
- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows.
|
||||
- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels.
|
||||
- **Second person**: Address the reader as "you." Avoid "the user" or "one."
|
||||
- **Present tense**: "Zed opens the file" not "Zed will open the file."
|
||||
|
||||
### What to Avoid
|
||||
|
||||
- Superlatives without substance ("incredibly fast," "seamlessly integrated")
|
||||
- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it
|
||||
- Apologetic tone for missing features—state the limitation and move on
|
||||
- Comparisons that disparage other tools—be factual, not competitive
|
||||
- Meta-commentary about honesty ("the honest take is...", "to be frank...", "honestly...")—let honesty show through frank assessments, not announcements
|
||||
|
||||
## Content Structure
|
||||
|
||||
### Page Organization
|
||||
|
||||
1. **Start with the goal**: Open with what the reader will accomplish, not background
|
||||
2. **Front-load the action**: Put the most common task first, edge cases later
|
||||
3. **Use headers liberally**: Readers scan; headers help them find what they need
|
||||
4. **End with "what's next"**: Link to related docs or logical next steps
|
||||
|
||||
### Section Patterns
|
||||
|
||||
For how-to content:
|
||||
1. Brief context (1-2 sentences max)
|
||||
2. Steps or instructions
|
||||
3. Example (code block or screenshot reference)
|
||||
4. Tips or gotchas (if any)
|
||||
|
||||
For reference content:
|
||||
1. What it is (definition)
|
||||
2. How to access/configure it
|
||||
3. Options/parameters table
|
||||
4. Examples
|
||||
|
||||
## Formatting Conventions
|
||||
|
||||
### Keybindings
|
||||
|
||||
- Use backticks for key combinations: `Cmd+Shift+P`
|
||||
- Show both macOS and Linux/Windows when they differ: `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows)
|
||||
- Use `+` to join simultaneous keys, space for sequences: `Cmd+K Cmd+C`
|
||||
|
||||
### Code and Settings
|
||||
|
||||
- Inline code for setting names, file paths, commands: `format_on_save`, `.zed/settings.json`, `zed .`
|
||||
- Code blocks for JSON config, multi-line commands, or file contents
|
||||
- Always show complete, working examples—not fragments
|
||||
|
||||
### Terminal Commands
|
||||
|
||||
Use `sh` code blocks for terminal commands, not plain backticks:
|
||||
|
||||
```sh
|
||||
brew install zed-editor/zed/zed
|
||||
```
|
||||
|
||||
Not:
|
||||
```
|
||||
brew install zed-editor/zed/zed
|
||||
```
|
||||
|
||||
For single inline commands in prose, backticks are fine: `zed .`
|
||||
|
||||
### Tables
|
||||
|
||||
Use tables for:
|
||||
- Keybinding comparisons between editors
|
||||
- Settings mappings (e.g., VS Code → Zed)
|
||||
- Feature comparisons with clear columns
|
||||
|
||||
Format:
|
||||
```
|
||||
| Action | Shortcut | Notes |
|
||||
| --- | --- | --- |
|
||||
| Open File | `Cmd+O` | Works from any context |
|
||||
```
|
||||
|
||||
### Tips and Notes
|
||||
|
||||
Use blockquote format with bold label:
|
||||
```
|
||||
> **Tip:** Practical advice that helps bridge gaps or saves time.
|
||||
```
|
||||
|
||||
Reserve tips for genuinely useful information, not padding.
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
### Settings Documentation
|
||||
|
||||
- **Settings Editor first**: Show how to find and change settings in the UI before showing JSON
|
||||
- **JSON as secondary**: Present JSON examples as "Or add this to your settings.json" for users who prefer direct editing
|
||||
- **Complete examples**: Include the full JSON structure, not just the value
|
||||
|
||||
### Migration Guides
|
||||
|
||||
- **Jobs to be done**: Frame around tasks ("How do I search files?") not features ("File Search Feature")
|
||||
- **Acknowledge the source**: Respect that users have muscle memory and preferences from their previous editor
|
||||
- **Keybindings tables**: Essential for migration docs—show what maps, what's different, what's missing
|
||||
- **Trade-offs section**: Be explicit about what the user gains and loses in the switch
|
||||
|
||||
### Feature Documentation
|
||||
|
||||
- **Start with the default**: Document the out-of-box experience first
|
||||
- **Configuration options**: Group related settings together
|
||||
- **Cross-link generously**: Link to related features, settings reference, and relevant guides
|
||||
|
||||
## Terminology
|
||||
|
||||
| Use | Instead of |
|
||||
| --- | --- |
|
||||
| folder | directory (in user-facing text) |
|
||||
| project | workspace (Zed doesn't have workspaces) |
|
||||
| Settings Editor | settings UI, preferences |
|
||||
| command palette | command bar, action search |
|
||||
| language server | LSP (spell out first use, then LSP is fine) |
|
||||
| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") |
|
||||
|
||||
## Examples
|
||||
|
||||
### Good: Direct and actionable
|
||||
```
|
||||
To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`.
|
||||
|
||||
Or add this to your settings.json:
|
||||
{
|
||||
"format_on_save": "on"
|
||||
}
|
||||
```
|
||||
|
||||
### Bad: Wordy and promotional
|
||||
```
|
||||
Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities.
|
||||
```
|
||||
|
||||
### Good: Honest about limitations
|
||||
```
|
||||
Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep.
|
||||
|
||||
**How to adapt:**
|
||||
- Use `Cmd+Shift+F` for project-wide text search
|
||||
- Use `Cmd+O` for symbol search (powered by your language server)
|
||||
```
|
||||
|
||||
### Bad: Defensive or dismissive
|
||||
```
|
||||
While some users might miss indexing, Zed's approach is actually better because it's faster.
|
||||
```
|
||||
@@ -23,6 +23,9 @@
|
||||
- [Visual Customization](./visual-customization.md)
|
||||
- [Vim Mode](./vim.md)
|
||||
- [Helix Mode](./helix.md)
|
||||
- [Privacy and Security](./ai/privacy-and-security.md)
|
||||
- [Worktree Trust](./worktree-trust.md)
|
||||
- [AI Improvement](./ai/ai-improvement.md)
|
||||
|
||||
<!-- - [Globs](./globs.md) -->
|
||||
<!-- - [Fonts](./fonts.md) -->
|
||||
@@ -69,8 +72,6 @@
|
||||
- [Models](./ai/models.md)
|
||||
- [Plans and Usage](./ai/plans-and-usage.md)
|
||||
- [Billing](./ai/billing.md)
|
||||
- [Privacy and Security](./ai/privacy-and-security.md)
|
||||
- [AI Improvement](./ai/ai-improvement.md)
|
||||
|
||||
# Extensions
|
||||
|
||||
@@ -86,9 +87,10 @@
|
||||
- [Agent Server Extensions](./extensions/agent-servers.md)
|
||||
- [MCP Server Extensions](./extensions/mcp-extensions.md)
|
||||
|
||||
# Migrate
|
||||
# Coming From...
|
||||
|
||||
- [VS Code](./migrate/vs-code.md)
|
||||
- [IntelliJ IDEA](./migrate/intellij.md)
|
||||
|
||||
# Language Support
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user